feat: add text truncation hook and marquee support (#217)

Introduce a hook to detect text truncation for dynamic UI updates
Replace static marquee spans with a reusable marquee component in files and labels
Enable copying of terminal selection on mouse up and touch end
This commit is contained in:
Bohdan Triapitsyn
2026-01-26 00:24:40 +02:00
committed by GitHub
parent f34027df10
commit efe9ad27b7
18 changed files with 537 additions and 57 deletions
@@ -289,6 +289,18 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
result_obj.insert("showReasoningTraces".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("showTextJustificationActivity") {
result_obj.insert("showTextJustificationActivity".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("nativeNotificationsEnabled") {
result_obj.insert("nativeNotificationsEnabled".to_string(), json!(b));
}
if let Some(Value::String(s)) = obj.get("notificationMode") {
let trimmed = s.trim();
if trimmed == "always" || trimmed == "hidden-only" {
result_obj.insert("notificationMode".to_string(), json!(trimmed));
}
}
if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") {
result_obj.insert("autoDeleteEnabled".to_string(), json!(b));
}
@@ -298,6 +310,12 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Bool(b)) = obj.get("autoCreateWorktree") {
result_obj.insert("autoCreateWorktree".to_string(), json!(b));
}
if let Some(Value::String(s)) = obj.get("toolCallExpansion") {
let trimmed = s.trim();
if trimmed == "collapsed" || trimmed == "activity" || trimmed == "detailed" {
result_obj.insert("toolCallExpansion".to_string(), json!(trimmed));
}
}
// Number fields
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
@@ -314,6 +332,47 @@ fn sanitize_settings_update(payload: &Value) -> Value {
}
}
if let Some(Value::Number(n)) = obj.get("fontSize") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(50).min(200);
result_obj.insert("fontSize".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("padding") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(50).min(200);
result_obj.insert("padding".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("cornerRadius") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(0).min(32);
result_obj.insert("cornerRadius".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("inputBarOffset") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(0).min(100);
result_obj.insert("inputBarOffset".to_string(), json!(clamped));
}
}
// Memory limit fields
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
let parsed = n
@@ -355,6 +414,25 @@ fn sanitize_settings_update(payload: &Value) -> Value {
}
}
if let Some(Value::String(s)) = obj.get("diffLayoutPreference") {
let trimmed = s.trim();
if trimmed == "dynamic" || trimmed == "inline" || trimmed == "side-by-side" {
result_obj.insert("diffLayoutPreference".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("diffViewMode") {
let trimmed = s.trim();
if trimmed == "single" || trimmed == "stacked" {
result_obj.insert("diffViewMode".to_string(), json!(trimmed));
}
}
if let Some(Value::Bool(b)) = obj.get("directoryShowHidden") {
result_obj.insert("directoryShowHidden".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("filesViewShowGitignored") {
result_obj.insert("filesViewShowGitignored".to_string(), json!(b));
}
// Array fields
if let Some(arr) = obj.get("approvedDirectories") {
result_obj.insert(
@@ -6,6 +6,7 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
@@ -125,6 +126,21 @@ interface FileChipProps {
onRemove: () => void;
}
const TruncatedMarquee = memo(({ text, title }: { text: string; title?: string }) => {
const labelRef = useRef<HTMLSpanElement>(null);
const isTruncated = useIsTextTruncated(labelRef, [text]);
return (
<span
ref={labelRef}
className={cn('marquee-text', isTruncated && 'marquee-text--active')}
title={title ?? text}
>
{text}
</span>
);
});
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
const getFileIcon = () => {
if (file.mimeType.startsWith('image/')) {
@@ -169,9 +185,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
</div>
{getFileIcon()}
<div className="overflow-hidden max-w-[200px]">
<span className="marquee-text" title={file.serverPath || displayName}>
{displayName}
</span>
<TruncatedMarquee text={displayName} title={file.serverPath || displayName} />
</div>
<span className="text-muted-foreground flex-shrink-0">
({formatFileSize(file.size)})
@@ -292,9 +306,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
>
{getFileIcon(file.mime)}
<div className="overflow-hidden max-w-[200px]">
<span className="marquee-text">
{extractFilename(file.filename)}
</span>
<TruncatedMarquee text={extractFilename(file.filename)} />
</div>
</div>
))}
@@ -47,6 +47,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>;
@@ -1083,6 +1084,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
return getModelDisplayName(currentModel);
};
const currentModelDisplayName = getCurrentModelDisplayName();
const modelLabelRef = React.useRef<HTMLSpanElement>(null);
const isModelLabelTruncated = useIsTextTruncated(modelLabelRef, [currentModelDisplayName, isCompact]);
const getAgentDisplayName = () => {
if (!uiAgentName) {
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
@@ -2122,19 +2127,20 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span
key={`${currentProviderId}-${currentModelId}`}
className={cn(
'model-controls__model-label overflow-hidden',
controlTextSize,
'font-medium whitespace-nowrap text-foreground min-w-0',
'max-w-[260px]'
)}
>
<span className="marquee-text">
{getCurrentModelDisplayName()}
<span
ref={modelLabelRef}
key={`${currentProviderId}-${currentModelId}`}
className={cn(
'model-controls__model-label overflow-hidden',
controlTextSize,
'font-medium whitespace-nowrap text-foreground min-w-0',
'max-w-[260px]'
)}
>
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -2246,13 +2252,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span
ref={modelLabelRef}
className={cn(
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0',
isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
)}
>
<span className="marquee-text">
{getCurrentModelDisplayName()}
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</button>
@@ -122,6 +122,55 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
}, []);
const copySelectionToClipboard = React.useCallback(async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const selection = window.getSelection();
if (!selection) {
return;
}
const text = selection.toString();
if (!text.trim()) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (anchorNode && !container.contains(anchorNode)) {
return;
}
if (focusNode && !container.contains(focusNode)) {
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
} catch {
// fall through to execCommand
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
} catch {
// ignore
}
}, []);
const resetWriteState = React.useCallback(() => {
pendingWriteRef.current = '';
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
@@ -782,6 +831,12 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
terminalRef.current?.focus();
}
}}
onMouseUp={() => {
void copySelectionToClipboard();
}}
onTouchEnd={() => {
void copySelectionToClipboard();
}}
>
{enableTouchScroll ? (
<textarea
+15 -18
View File
@@ -49,6 +49,7 @@ type GitViewSnapshot = {
directory?: string;
selectedPaths: string[];
commitMessage: string;
generatedHighlights: string[];
};
type GitmojiEntry = {
@@ -179,7 +180,7 @@ const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): Git
return null;
};
let gitViewSnapshot: GitViewSnapshot | null = null;
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
const useEffectiveDirectory = () => {
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
@@ -226,9 +227,8 @@ export const GitView: React.FC = () => {
} = useGitStore();
const initialSnapshot = React.useMemo(() => {
if (!gitViewSnapshot) return null;
if (gitViewSnapshot.directory !== currentDirectory) return null;
return gitViewSnapshot;
if (!currentDirectory) return null;
return gitViewSnapshots.get(currentDirectory) ?? null;
}, [currentDirectory]);
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
@@ -273,7 +273,9 @@ export const GitView: React.FC = () => {
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>([]);
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
initialSnapshot?.generatedHighlights ?? []
);
const clearGeneratedHighlights = React.useCallback(() => {
setGeneratedHighlights([]);
}, []);
@@ -346,19 +348,14 @@ export const GitView: React.FC = () => {
}, [expandedCommitHashes, currentDirectory, git, commitFilesMap, loadingCommitHashes]);
React.useEffect(() => {
return () => {
if (!currentDirectory) {
gitViewSnapshot = null;
return;
}
gitViewSnapshot = {
directory: currentDirectory,
selectedPaths: Array.from(selectedPaths),
commitMessage,
};
};
}, [commitMessage, currentDirectory, selectedPaths]);
if (!currentDirectory) return;
gitViewSnapshots.set(currentDirectory, {
directory: currentDirectory,
selectedPaths: Array.from(selectedPaths),
commitMessage,
generatedHighlights,
});
}, [commitMessage, currentDirectory, selectedPaths, generatedHighlights]);
React.useEffect(() => {
loadProfiles();
@@ -44,6 +44,7 @@ const MODIFIER_ARROW_SUFFIX: Record<Modifier, string> = {
cmd: '3',
};
const STREAM_OPTIONS = {
retry: {
maxRetries: 3,
@@ -429,6 +430,7 @@ export const TerminalView: React.FC = () => {
}
}, [clearBuffer, effectiveDirectory, setConnectionError, terminal]);
const handleViewportInput = React.useCallback(
(data: string) => {
if (!data) {
@@ -60,6 +60,15 @@ const branchToTitle = (branch: string): string => {
.replace(/\b\w/g, (c) => c.toUpperCase());
};
type PullRequestDraftSnapshot = {
title: string;
body: string;
draft: boolean;
isOpen: boolean;
};
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
const openExternal = async (url: string) => {
if (typeof window === 'undefined') return;
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
@@ -96,14 +105,20 @@ export const PullRequestSection: React.FC<{
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]);
const [isOpen, setIsOpen] = React.useState(true);
const snapshotKey = React.useMemo(() => `${directory}::${branch}`, [directory, branch]);
const initialSnapshot = React.useMemo(
() => pullRequestDraftSnapshots.get(snapshotKey) ?? null,
[snapshotKey]
);
const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true);
const [isLoading, setIsLoading] = React.useState(false);
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [title, setTitle] = React.useState(() => branchToTitle(branch));
const [body, setBody] = React.useState('');
const [draft, setDraft] = React.useState(false);
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
const [isGenerating, setIsGenerating] = React.useState(false);
@@ -359,11 +374,25 @@ export const PullRequestSection: React.FC<{
}, [branch, canShow, directory, github]);
React.useEffect(() => {
setTitle(branchToTitle(branch));
setBody('');
setDraft(false);
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
setTitle(snapshot?.title ?? branchToTitle(branch));
setBody(snapshot?.body ?? '');
setDraft(snapshot?.draft ?? false);
setIsOpen(snapshot?.isOpen ?? true);
void refresh();
}, [branch, refresh]);
}, [branch, refresh, snapshotKey]);
React.useEffect(() => {
if (!directory || !branch) {
return;
}
pullRequestDraftSnapshots.set(snapshotKey, {
title,
body,
draft,
isOpen,
});
}, [snapshotKey, title, body, draft, isOpen, directory, branch]);
const generateDescription = React.useCallback(async () => {
if (isGenerating) return;
@@ -0,0 +1,46 @@
import React from 'react';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
export const useIsTextTruncated = <T extends HTMLElement>(
ref: React.RefObject<T | null>,
deps: React.DependencyList = []
): boolean => {
const [isTruncated, setIsTruncated] = React.useState(false);
const checkTruncation = React.useCallback(() => {
const element = ref.current;
if (!element) {
return;
}
const next = element.scrollWidth > element.clientWidth + 1;
setIsTruncated(next);
}, [ref]);
useIsomorphicLayoutEffect(() => {
checkTruncation();
}, [checkTruncation, ...deps]);
React.useEffect(() => {
const element = ref.current;
if (!element || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => {
checkTruncation();
});
observer.observe(element);
return () => observer.disconnect();
}, [checkTruncation, ref]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleResize = () => checkTruncation();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [checkTruncation]);
return isTruncated;
};
+2 -2
View File
@@ -644,7 +644,7 @@ html:not(.dark) .chat-scroll {
white-space: nowrap;
}
.group:hover .marquee-text,
.marquee-text:hover {
.group:hover .marquee-text--active,
.marquee-text--active:hover {
animation: marquee-scroll 5s linear infinite alternate;
}
+12
View File
@@ -395,10 +395,22 @@ export interface SettingsPayload {
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
showReasoningTraces?: boolean;
showTextJustificationActivity?: boolean;
nativeNotificationsEnabled?: boolean;
notificationMode?: 'always' | 'hidden-only';
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
queueModeEnabled?: boolean;
gitmojiEnabled?: boolean;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
fontSize?: number;
padding?: number;
cornerRadius?: number;
inputBarOffset?: number;
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
diffViewMode?: 'single' | 'stacked';
directoryShowHidden?: boolean;
filesViewShowGitignored?: boolean;
[key: string]: unknown;
}
+60
View File
@@ -4,8 +4,18 @@ import type { DesktopSettings } from '@/lib/desktop';
type AppearanceSlice = {
showReasoningTraces: boolean;
showTextJustificationActivity: boolean;
nativeNotificationsEnabled: boolean;
notificationMode: 'always' | 'hidden-only';
autoDeleteEnabled: boolean;
autoDeleteAfterDays: number;
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
fontSize: number;
padding: number;
cornerRadius: number;
inputBarOffset: number;
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffViewMode: 'single' | 'stacked';
};
let initialized = false;
@@ -19,8 +29,18 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces,
showTextJustificationActivity: useUIStore.getState().showTextJustificationActivity,
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
notificationMode: useUIStore.getState().notificationMode,
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
toolCallExpansion: useUIStore.getState().toolCallExpansion,
fontSize: useUIStore.getState().fontSize,
padding: useUIStore.getState().padding,
cornerRadius: useUIStore.getState().cornerRadius,
inputBarOffset: useUIStore.getState().inputBarOffset,
diffLayoutPreference: useUIStore.getState().diffLayoutPreference,
diffViewMode: useUIStore.getState().diffViewMode,
};
let pending: Partial<DesktopSettings> | null = null;
@@ -46,8 +66,18 @@ export const startAppearanceAutoSave = (): void => {
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
showTextJustificationActivity: state.showTextJustificationActivity,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
autoDeleteEnabled: state.autoDeleteEnabled,
autoDeleteAfterDays: state.autoDeleteAfterDays,
toolCallExpansion: state.toolCallExpansion,
fontSize: state.fontSize,
padding: state.padding,
cornerRadius: state.cornerRadius,
inputBarOffset: state.inputBarOffset,
diffLayoutPreference: state.diffLayoutPreference,
diffViewMode: state.diffViewMode,
};
const diff: Partial<DesktopSettings> = {};
@@ -55,12 +85,42 @@ export const startAppearanceAutoSave = (): void => {
if (current.showReasoningTraces !== previous.showReasoningTraces) {
diff.showReasoningTraces = current.showReasoningTraces;
}
if (current.showTextJustificationActivity !== previous.showTextJustificationActivity) {
diff.showTextJustificationActivity = current.showTextJustificationActivity;
}
if (current.nativeNotificationsEnabled !== previous.nativeNotificationsEnabled) {
diff.nativeNotificationsEnabled = current.nativeNotificationsEnabled;
}
if (current.notificationMode !== previous.notificationMode) {
diff.notificationMode = current.notificationMode;
}
if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) {
diff.autoDeleteEnabled = current.autoDeleteEnabled;
}
if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
}
if (current.toolCallExpansion !== previous.toolCallExpansion) {
diff.toolCallExpansion = current.toolCallExpansion;
}
if (current.fontSize !== previous.fontSize) {
diff.fontSize = current.fontSize;
}
if (current.padding !== previous.padding) {
diff.padding = current.padding;
}
if (current.cornerRadius !== previous.cornerRadius) {
diff.cornerRadius = current.cornerRadius;
}
if (current.inputBarOffset !== previous.inputBarOffset) {
diff.inputBarOffset = current.inputBarOffset;
}
if (current.diffLayoutPreference !== previous.diffLayoutPreference) {
diff.diffLayoutPreference = current.diffLayoutPreference;
}
if (current.diffViewMode !== previous.diffViewMode) {
diff.diffViewMode = current.diffViewMode;
}
previous = current;
+11 -1
View File
@@ -52,6 +52,8 @@ export type DesktopSettings = {
pinnedDirectories?: string[];
showReasoningTraces?: boolean;
showTextJustificationActivity?: boolean;
nativeNotificationsEnabled?: boolean;
notificationMode?: 'always' | 'hidden-only';
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
defaultModel?: string; // format: "provider/model"
@@ -61,6 +63,15 @@ export type DesktopSettings = {
autoCreateWorktree?: boolean;
queueModeEnabled?: boolean;
gitmojiEnabled?: boolean;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
fontSize?: number;
padding?: number;
cornerRadius?: number;
inputBarOffset?: number;
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
diffViewMode?: 'single' | 'stacked';
directoryShowHidden?: boolean;
filesViewShowGitignored?: boolean;
// Memory limits for message viewport management
memoryLimitHistorical?: number; // Default fetch limit when loading/syncing (default: 90)
@@ -320,4 +331,3 @@ export const restartToApplyUpdate = async (): Promise<boolean> => {
return false;
}
};
+14 -3
View File
@@ -1,18 +1,22 @@
import React from 'react';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { updateDesktopSettings } from '@/lib/persistence';
const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden';
const SHOW_HIDDEN_EVENT = 'directory-show-hidden-change';
const readStoredShowHidden = (): boolean => {
if (typeof window === 'undefined') {
return false;
return true;
}
try {
const stored = getSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY);
if (stored === null) {
return true;
}
return stored === 'true';
} catch {
return false;
return true;
}
};
@@ -23,7 +27,10 @@ export const notifyDirectoryShowHiddenChanged = () => {
window.dispatchEvent(new Event(SHOW_HIDDEN_EVENT));
};
export const setDirectoryShowHidden = (value: boolean) => {
export const setDirectoryShowHidden = (
value: boolean,
options: { persist?: boolean } = {}
) => {
if (typeof window === 'undefined') {
return;
}
@@ -33,6 +40,10 @@ export const setDirectoryShowHidden = (value: boolean) => {
} catch {
// ignore storage errors
}
if (options.persist !== false) {
void updateDesktopSettings({ directoryShowHidden: value });
}
};
export const useDirectoryShowHidden = (): boolean => {
@@ -1,6 +1,7 @@
import React from 'react';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { updateDesktopSettings } from '@/lib/persistence';
const SHOW_GITIGNORED_STORAGE_KEY = 'filesViewShowGitignored';
const SHOW_GITIGNORED_EVENT = 'files-view-show-gitignored-change';
@@ -24,7 +25,10 @@ export const notifyFilesViewShowGitignoredChanged = () => {
window.dispatchEvent(new Event(SHOW_GITIGNORED_EVENT));
};
export const setFilesViewShowGitignored = (value: boolean) => {
export const setFilesViewShowGitignored = (
value: boolean,
options: { persist?: boolean } = {}
) => {
if (typeof window === 'undefined') {
return;
}
@@ -34,6 +38,10 @@ export const setFilesViewShowGitignored = (value: boolean) => {
} catch {
// ignore storage errors
}
if (options.persist !== false) {
void updateDesktopSettings({ filesViewShowGitignored: value });
}
};
export const useFilesViewShowGitignored = (): boolean => {
+105
View File
@@ -2,6 +2,8 @@ import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi,
import type { DesktopSettings } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -52,6 +54,12 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else {
localStorage.removeItem('gitmojiEnabled');
}
if (typeof settings.directoryShowHidden === 'boolean') {
localStorage.setItem('directoryTreeShowHidden', settings.directoryShowHidden ? 'true' : 'false');
}
if (typeof settings.filesViewShowGitignored === 'boolean') {
localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false');
}
};
type PersistApi = {
@@ -199,6 +207,54 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
queueStore.setQueueMode(settings.queueModeEnabled);
}
if (typeof settings.showTextJustificationActivity === 'boolean' && settings.showTextJustificationActivity !== store.showTextJustificationActivity) {
store.setShowTextJustificationActivity(settings.showTextJustificationActivity);
}
if (typeof settings.nativeNotificationsEnabled === 'boolean' && settings.nativeNotificationsEnabled !== store.nativeNotificationsEnabled) {
store.setNativeNotificationsEnabled(settings.nativeNotificationsEnabled);
}
if (typeof settings.notificationMode === 'string' && (settings.notificationMode === 'always' || settings.notificationMode === 'hidden-only')) {
if (settings.notificationMode !== store.notificationMode) {
store.setNotificationMode(settings.notificationMode);
}
}
if (typeof settings.toolCallExpansion === 'string'
&& (settings.toolCallExpansion === 'collapsed' || settings.toolCallExpansion === 'activity' || settings.toolCallExpansion === 'detailed')) {
if (settings.toolCallExpansion !== store.toolCallExpansion) {
store.setToolCallExpansion(settings.toolCallExpansion);
}
}
if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) {
store.setFontSize(settings.fontSize);
}
if (typeof settings.padding === 'number' && Number.isFinite(settings.padding) && settings.padding !== store.padding) {
store.setPadding(settings.padding);
}
if (typeof settings.cornerRadius === 'number' && Number.isFinite(settings.cornerRadius) && settings.cornerRadius !== store.cornerRadius) {
store.setCornerRadius(settings.cornerRadius);
}
if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) {
store.setInputBarOffset(settings.inputBarOffset);
}
if (typeof settings.diffLayoutPreference === 'string'
&& (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) {
if (settings.diffLayoutPreference !== store.diffLayoutPreference) {
store.setDiffLayoutPreference(settings.diffLayoutPreference);
}
}
if (typeof settings.diffViewMode === 'string'
&& (settings.diffViewMode === 'single' || settings.diffViewMode === 'stacked')) {
if (settings.diffViewMode !== store.diffViewMode) {
store.setDiffViewMode(settings.diffViewMode);
}
}
if (typeof settings.directoryShowHidden === 'boolean') {
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
}
if (typeof settings.filesViewShowGitignored === 'boolean') {
setFilesViewShowGitignored(settings.filesViewShowGitignored, { persist: false });
}
};
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
@@ -283,6 +339,55 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
if (typeof candidate.showTextJustificationActivity === 'boolean') {
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
}
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
}
if (typeof candidate.notificationMode === 'string' && (candidate.notificationMode === 'always' || candidate.notificationMode === 'hidden-only')) {
result.notificationMode = candidate.notificationMode;
}
if (
typeof candidate.toolCallExpansion === 'string'
&& (candidate.toolCallExpansion === 'collapsed'
|| candidate.toolCallExpansion === 'activity'
|| candidate.toolCallExpansion === 'detailed')
) {
result.toolCallExpansion = candidate.toolCallExpansion;
}
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
result.fontSize = candidate.fontSize;
}
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
result.padding = candidate.padding;
}
if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
result.cornerRadius = candidate.cornerRadius;
}
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
result.inputBarOffset = candidate.inputBarOffset;
}
if (
typeof candidate.diffLayoutPreference === 'string'
&& (candidate.diffLayoutPreference === 'dynamic'
|| candidate.diffLayoutPreference === 'inline'
|| candidate.diffLayoutPreference === 'side-by-side')
) {
result.diffLayoutPreference = candidate.diffLayoutPreference;
}
if (
typeof candidate.diffViewMode === 'string'
&& (candidate.diffViewMode === 'single' || candidate.diffViewMode === 'stacked')
) {
result.diffViewMode = candidate.diffViewMode;
}
if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden;
}
if (typeof candidate.filesViewShowGitignored === 'boolean') {
result.filesViewShowGitignored = candidate.filesViewShowGitignored;
}
if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) {
result.memoryLimitHistorical = candidate.memoryLimitHistorical;
+1 -1
View File
@@ -33,7 +33,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
persist(
(set, get) => ({
queuedMessages: {},
queueModeEnabled: false,
queueModeEnabled: true,
addToQueue: (sessionId, message) => {
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+3 -3
View File
@@ -145,7 +145,7 @@ export const useUIStore = create<UIStore>()(
sidebarSection: 'sessions',
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: false,
showReasoningTraces: true,
showTextJustificationActivity: false,
autoDeleteEnabled: false,
autoDeleteAfterDays: 30,
@@ -160,10 +160,10 @@ export const useUIStore = create<UIStore>()(
inputBarOffset: 0,
favoriteModels: [],
recentModels: [],
diffLayoutPreference: 'dynamic',
diffLayoutPreference: 'inline',
diffFileLayout: {},
diffWrapLines: false,
diffViewMode: 'single',
diffViewMode: 'stacked',
isTimelineDialogOpen: false,
nativeNotificationsEnabled: false,
notificationMode: 'hidden-only',
+48
View File
@@ -736,6 +736,18 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.showTextJustificationActivity === 'boolean') {
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
}
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
}
if (typeof candidate.notificationMode === 'string') {
const mode = candidate.notificationMode.trim();
if (mode === 'always' || mode === 'hidden-only') {
result.notificationMode = mode;
}
}
if (typeof candidate.autoDeleteEnabled === 'boolean') {
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
}
@@ -774,6 +786,42 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.gitmojiEnabled === 'boolean') {
result.gitmojiEnabled = candidate.gitmojiEnabled;
}
if (typeof candidate.toolCallExpansion === 'string') {
const mode = candidate.toolCallExpansion.trim();
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') {
result.toolCallExpansion = mode;
}
}
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize)));
}
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
}
if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius)));
}
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset)));
}
if (typeof candidate.diffLayoutPreference === 'string') {
const mode = candidate.diffLayoutPreference.trim();
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
result.diffLayoutPreference = mode;
}
}
if (typeof candidate.diffViewMode === 'string') {
const mode = candidate.diffViewMode.trim();
if (mode === 'single' || mode === 'stacked') {
result.diffViewMode = mode;
}
}
if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden;
}
if (typeof candidate.filesViewShowGitignored === 'boolean') {
result.filesViewShowGitignored = candidate.filesViewShowGitignored;
}
// Memory limits for message viewport management
if (typeof candidate.memoryLimitHistorical === 'number' && Number.isFinite(candidate.memoryLimitHistorical)) {