From 279fa8b6918b142b43b1cd8b85bf2ae1e88db46d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:19:25 +0300 Subject: [PATCH 01/66] fix(command-palette): match file results against the full relative path Cmd+P scored server file hits by basename only, so a query like "solo-is-a" returned nothing for solo-is-a-team-size/index.md. Score by relativePath and widen the server candidate limit to 40 so client-side reranking has enough to work with. --- .../ui/src/components/ui/CommandPalette.tsx | 2 +- .../ui/commandPaletteFilesState.test.ts | 23 +++++++++++++++++++ .../components/ui/commandPaletteFilesState.ts | 6 +++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 2fe8a942..509d2734 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -352,7 +352,7 @@ export const CommandPalette: React.FC = () => { return; } let cancelled = false; - void searchFiles(currentRoot, trimmedQuery, 10, { type: 'file' }) + void searchFiles(currentRoot, trimmedQuery, 40, { type: 'file' }) .then((results) => { if (cancelled) return; setFileResults( diff --git a/packages/ui/src/components/ui/commandPaletteFilesState.test.ts b/packages/ui/src/components/ui/commandPaletteFilesState.test.ts index d542ef73..3c5de7d3 100644 --- a/packages/ui/src/components/ui/commandPaletteFilesState.test.ts +++ b/packages/ui/src/components/ui/commandPaletteFilesState.test.ts @@ -16,4 +16,27 @@ describe('commandPaletteFilesState', () => { expect(scoreCommandPaletteFiles(fileResults, 'alpha', freshKey, staleKey)).toEqual([]); expect(scoreCommandPaletteFiles(fileResults, 'alpha', freshKey, freshKey)).toHaveLength(1); }); + + test('matches directory segments of the relative path, not just the basename', () => { + const fileResults = [ + { name: 'index.md', path: '/kb/solo-is-a-team-size/index.md', relativePath: 'solo-is-a-team-size/index.md' }, + { name: 'index.md', path: '/kb/software-developer/index.md', relativePath: 'software-developer/index.md' }, + ]; + const key = buildCommandPaletteFileSearchKey('/kb', 'solo-is-a'); + + const scored = scoreCommandPaletteFiles(fileResults, 'solo-is-a', key, key); + expect(scored).toHaveLength(1); + expect(scored[0].item.relativePath).toBe('solo-is-a-team-size/index.md'); + }); + + test('ranks prefix path matches above later substring matches', () => { + const fileResults = [ + { name: 'index.md', path: '/kb/notes/solo/index.md', relativePath: 'notes/solo/index.md' }, + { name: 'index.md', path: '/kb/solo-is-a-team-size/index.md', relativePath: 'solo-is-a-team-size/index.md' }, + ]; + const key = buildCommandPaletteFileSearchKey('/kb', 'solo'); + + const scored = scoreCommandPaletteFiles(fileResults, 'solo', key, key); + expect(scored[0].item.relativePath).toBe('solo-is-a-team-size/index.md'); + }); }); diff --git a/packages/ui/src/components/ui/commandPaletteFilesState.ts b/packages/ui/src/components/ui/commandPaletteFilesState.ts index ac5a9b15..0ea03770 100644 --- a/packages/ui/src/components/ui/commandPaletteFilesState.ts +++ b/packages/ui/src/components/ui/commandPaletteFilesState.ts @@ -11,7 +11,7 @@ export const buildCommandPaletteFileSearchKey = ( return JSON.stringify([currentRoot, trimmedQuery]); }; -export const scoreCommandPaletteFiles = ( +export const scoreCommandPaletteFiles = ( fileResults: T[], trimmedQuery: string, fileSearchKey: string, @@ -21,7 +21,9 @@ export const scoreCommandPaletteFiles = ( return []; } - return scoreByFuzzyQuery(fileResults, trimmedQuery, (file) => file.name, { + // Score against the full relative path: queries like "solo-is-a" must match + // solo-is-a-team-size/index.md even though the basename is just index.md. + return scoreByFuzzyQuery(fileResults, trimmedQuery, (file) => file.relativePath || file.name, { limit: 10, threshold: 0.4, }); From 0918bee5661515fa8cfc3cbd12249a37c822a3ca Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:22:40 +0300 Subject: [PATCH 02/66] fix(chat): rank @ mention files and directories together by match quality Directories and files were rendered as fixed category blocks, so an exact file match sat below unrelated directories. Merge both result sets and rank them with the shared fuzzy scorer against the full relative path. Multi-word queries now match tokens in any order (longest token queries the server, the rest filter client-side), and path truncation keeps the parent segments next to the file name so index.md-heavy trees stay distinguishable. --- .../chat/FileMentionAutocomplete.tsx | 100 ++++++------------ .../chat/fileMentionResults.test.ts | 53 ++++++++++ .../src/components/chat/fileMentionResults.ts | 60 +++++++++++ packages/ui/src/lib/utils.ts | 25 ++--- 4 files changed, 153 insertions(+), 85 deletions(-) create mode 100644 packages/ui/src/components/chat/fileMentionResults.test.ts create mode 100644 packages/ui/src/components/chat/fileMentionResults.ts diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 9192fbc7..963e8c46 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -14,6 +14,7 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; +import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults'; import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip'; type FileInfo = ProjectFileSearchHit; @@ -124,9 +125,11 @@ export const FileMentionAutocomplete = React.forwardRef normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2), [agents, normalizedSearchQuery.length], ); - const visibleDirectories = directories; const visibleRecentFiles = recentFiles; - const visibleFiles = files; + const visibleResults = React.useMemo( + () => rankFileMentionResults(files, directories, normalizedSearchQuery, 20), + [files, directories, normalizedSearchQuery], + ); React.useEffect(() => { const handlePointerDown = (event: MouseEvent | TouchEvent) => { @@ -152,13 +155,9 @@ export const FileMentionAutocomplete = React.forwardRef file.path)); - setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15)); + setFiles(hits.filter((hit) => !recentSet.has(hit.path))); }) .catch(() => { if (!cancelled) { @@ -210,13 +209,9 @@ export const FileMentionAutocomplete = React.forwardRef { if (!cancelled) { - setDirectories(hits.slice(0, 10)); + setDirectories(hits); } }) .catch(() => { @@ -282,7 +277,7 @@ export const FileMentionAutocomplete = React.forwardRef { selectedIndexRef.current = selectedIndex; @@ -332,7 +327,7 @@ export const FileMentionAutocomplete = React.forwardRef { const labelNode = labelRefs.current[selectedIndex]; @@ -376,7 +371,7 @@ export const FileMentionAutocomplete = React.forwardRef { const ext = file.extension?.toLowerCase(); @@ -482,38 +469,11 @@ export const FileMentionAutocomplete = React.forwardRef )} - {visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && ( -
- )} - {visibleDirectories.map((dir, index) => { - const rowIndex = visibleAgents.length + index; - const relativePath = dir.relativePath || dir.name; - const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); - const isSelected = selectedIndex === rowIndex; - - return ( -
{ itemRefs.current[rowIndex] = el; }} - className={cn( - "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg", - isSelected && "bg-interactive-selection" - )} - onClick={() => handleFileSelect(dir)} - onMouseMove={() => setSelectedIndex(rowIndex)} - > - - - {displayPath} - -
- ); - })} - {visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && ( + {visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
)} {visibleRecentFiles.map((file, index) => { - const rowIndex = visibleAgents.length + visibleDirectories.length + index; + const rowIndex = visibleAgents.length + index; const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const isSelected = selectedIndex === rowIndex; @@ -561,11 +521,11 @@ export const FileMentionAutocomplete = React.forwardRef ); })} - {visibleRecentFiles.length > 0 && visibleFiles.length > 0 && ( + {visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
)} - {visibleFiles.map((file, index) => { - const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index; + {visibleResults.map((file, index) => { + const rowIndex = visibleAgents.length + visibleRecentFiles.length + index; const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const isSelected = selectedIndex === rowIndex; @@ -582,7 +542,9 @@ export const FileMentionAutocomplete = React.forwardRef handleFileSelect(file)} onMouseMove={() => setSelectedIndex(rowIndex)} > - {getFileIcon(file)} + {file.kind === 'directory' + ? + : getFileIcon(file)} { labelRefs.current[rowIndex] = el; }} className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container" @@ -613,12 +575,12 @@ export const FileMentionAutocomplete = React.forwardRef + {item} ); })} - {visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && ( + {visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
{t('chat.fileMentionAutocomplete.empty')}
diff --git a/packages/ui/src/components/chat/fileMentionResults.test.ts b/packages/ui/src/components/chat/fileMentionResults.test.ts new file mode 100644 index 00000000..f8f66131 --- /dev/null +++ b/packages/ui/src/components/chat/fileMentionResults.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; + +import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults'; + +const hit = (relativePath: string) => { + const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath; + return { + name, + path: `/root/${relativePath}`, + relativePath, + extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, + }; +}; + +describe('tokenizeMentionQuery', () => { + test('normalizes leading ./ and slashes and splits on whitespace', () => { + expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']); + expect(tokenizeMentionQuery(' ')).toEqual([]); + }); +}); + +describe('mentionServerQuery', () => { + test('uses the longest token for the server search', () => { + expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a'); + expect(mentionServerQuery('')).toBe(''); + }); +}); + +describe('rankFileMentionResults', () => { + test('ranks files and directories together by match quality, not by category', () => { + const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')]; + const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')]; + + const ranked = rankFileMentionResults(files, directories, 'solo'); + const paths = ranked.map((entry) => entry.relativePath); + + expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']); + expect(paths).not.toContain('machine-learning/tensorflow/'); + }); + + test('multi-token queries match tokens in any order across the path', () => { + const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')]; + + const ranked = rankFileMentionResults(files, [], 'team solo'); + expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']); + }); + + test('tags each result with its kind', () => { + const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a'); + expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory'); + expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file'); + }); +}); diff --git a/packages/ui/src/components/chat/fileMentionResults.ts b/packages/ui/src/components/chat/fileMentionResults.ts new file mode 100644 index 00000000..c1b40d2d --- /dev/null +++ b/packages/ui/src/components/chat/fileMentionResults.ts @@ -0,0 +1,60 @@ +import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch'; +import type { ProjectFileSearchHit } from '@/lib/opencode/client'; + +export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' }; + +export const tokenizeMentionQuery = (query: string): string[] => + (query ?? '') + .trim() + .replace(/^\.\//, '') + .replace(/^\/+/, '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + +/** + * The opencode file search takes a single term, so multi-word queries send the + * most selective (longest) token and the remaining tokens filter client-side. + */ +export const mentionServerQuery = (query: string): string => { + const tokens = tokenizeMentionQuery(query); + if (tokens.length === 0) { + return ''; + } + return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest)); +}; + +/** + * Merge directory and file hits into one list ranked by match quality against + * the full relative path. Multi-token queries require every token to appear + * somewhere in the path, in any order. + */ +export function rankFileMentionResults( + files: ProjectFileSearchHit[], + directories: ProjectFileSearchHit[], + query: string, + limit = 20, +): FileMentionHit[] { + const merged: FileMentionHit[] = [ + ...directories.map((hit) => ({ ...hit, kind: 'directory' as const })), + ...files.map((hit) => ({ ...hit, kind: 'file' as const })), + ]; + + const tokens = tokenizeMentionQuery(query); + if (tokens.length === 0) { + return merged.slice(0, limit); + } + + const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name; + const candidates = tokens.length === 1 + ? merged + : merged.filter((hit) => { + const haystack = pathOf(hit).toLowerCase(); + return tokens.every((token) => haystack.includes(token)); + }); + + const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest)); + return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map( + (scored) => scored.item, + ); +} diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index 393f43a8..90fc3337 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -66,29 +66,22 @@ export const truncatePathMiddle = ( return source; } - const prefixBudget = Math.max(0, maxLength - (fileName.length + 2)); - if (prefixBudget <= 0) { - return `…/${fileName}`; - } - - let prefix = ''; - for (const segment of segments) { + // Keep the segments closest to the file name: in trees full of index.md the + // parent directory is the distinguishing part, so drop leading segments. + let suffix = fileName; + for (let i = segments.length - 1; i >= 0; i--) { + const segment = segments[i]; if (!segment) { continue; } - const candidate = prefix ? `${prefix}/${segment}` : segment; - if (candidate.length > prefixBudget) { + const candidate = `${segment}/${suffix}`; + if (candidate.length + 2 > maxLength) { break; } - prefix = candidate; + suffix = candidate; } - if (!prefix) { - const first = segments[0] ?? ''; - prefix = first ? first.slice(0, prefixBudget) : ''; - } - - return prefix ? `${prefix}…/${fileName}` : `…/${fileName}`; + return `…/${suffix}`; }; const normalizePath = (value: string) => { From 2f27f0ec4b225557f94343317a32e63e6d8886a8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:28:45 +0300 Subject: [PATCH 03/66] fix(terminal): reconcile tabs with server sessions and keep shown terminals alive The tab list lived only in per-tab sessionStorage, so a new browser tab, another device, or cleared storage showed an empty terminal sidebar while PTYs kept running server-side, and orphans leaked until the idle sweep. Add GET /api/terminal/sessions and adopt unknown server sessions into the local tab projection (additive only; a failed listing changes nothing). The idle sweep also reaped terminals in background tabs because only the active tab holds a WebSocket attachment. Add POST /api/terminal/touch and have open clients periodically refresh activity for every session their tabs reference. --- .../ui/src/components/views/TerminalView.tsx | 45 ++++++++++++++ packages/ui/src/lib/api/types.ts | 11 ++++ packages/ui/src/lib/terminalApi.ts | 28 ++++++++- .../ui/src/stores/useTerminalStore.test.ts | 41 +++++++++++++ packages/ui/src/stores/useTerminalStore.ts | 58 +++++++++++++++++++ .../web/server/lib/terminal/DOCUMENTATION.md | 2 + packages/web/server/lib/terminal/runtime.js | 30 +++++++++- .../web/server/lib/terminal/runtime.test.js | 26 +++++++++ packages/web/src/api/terminal.ts | 10 ++++ 9 files changed, 249 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 2fd4a6fb..3f465531 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -58,6 +58,7 @@ export const TerminalView: React.FC = ({ visible }) => { const setActiveTab = useTerminalStore((s) => s.setActiveTab); const closeTab = useTerminalStore((s) => s.closeTab); const setTabSessionId = useTerminalStore((s) => s.setTabSessionId); + const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions); const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle); const setConnecting = useTerminalStore((s) => s.setConnecting); const appendToBuffer = useTerminalStore((s) => s.appendToBuffer); @@ -176,6 +177,50 @@ export const TerminalView: React.FC = ({ visible }) => { directoryRef.current = effectiveDirectory; }, [effectiveDirectory]); + // The tab list is a per-client projection, so ask the server what actually + // exists for this directory and adopt sessions no local tab references + // (another device, a fresh browser tab, or a reload with cleared storage). + // A failed listing changes nothing: adoption is additive only. + React.useEffect(() => { + if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) { + return; + } + let cancelled = false; + const directory = effectiveDirectory; + void terminal.listSessions(directory) + .then((serverSessions) => { + if (cancelled || directoryRef.current !== directory) return; + adoptServerSessions(directory, serverSessions); + }) + .catch(() => { /* keep local tabs; the next mount or directory switch retries */ }); + return () => { + cancelled = true; + }; + }, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]); + + // The server reaps terminals with no attached socket after an idle timeout, + // but only the active tab holds an attachment. While this client is open, + // periodically mark every session its tabs reference as active so + // background tabs (and other directories' terminals) are not reaped. + React.useEffect(() => { + if (!terminal.touchSessions) { + return; + } + const touch = () => { + if (typeof navigator !== 'undefined' && !navigator.onLine) return; + const ids: string[] = []; + for (const dirState of useTerminalStore.getState().sessions.values()) { + for (const tab of dirState.tabs) { + if (tab.terminalSessionId) ids.push(tab.terminalSessionId); + } + } + if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {}); + }; + touch(); + const interval = setInterval(touch, 10 * 60 * 1000); + return () => clearInterval(interval); + }, [terminal]); + React.useEffect(() => { if (!showQuickKeys && activeModifier !== null) { setActiveModifier(null); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index eaca77ed..d6eef89e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -80,8 +80,19 @@ export interface ForceKillOptions { cwd?: string; } +export interface TerminalServerSession { + sessionId: string; + cwd: string; + status: 'running' | 'exited'; + createdAt: number | null; +} + export interface TerminalAPI { listShells?(): Promise; + /** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */ + listSessions?(cwd: string): Promise; + /** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */ + touchSessions?(sessionIds: string[]): Promise; createSession(options: CreateTerminalOptions): Promise; connect(sessionId: string, handlers: TerminalHandlers): Subscription; sendInput(sessionId: string, input: string): Promise; diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 72e25849..762c7bda 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -1,4 +1,4 @@ -import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types'; +import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types'; import { openRuntimeWebSocket } from './relay/runtime-socket'; import type { RelayTunnelWebSocket } from './relay/tunnel-client'; import { runtimeFetch } from './runtime-fetch'; @@ -356,6 +356,32 @@ export async function createTerminalSession(options: CreateTerminalOptions): Pro if (!response.ok) throw await responseError(response, 'Failed to create terminal session'); return response.json() as Promise; } +export async function listTerminalSessions(cwd: string): Promise { + const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`); + if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions'); + const payload: unknown = await response.json().catch(() => null); + const rawSessions = payload && typeof payload === 'object' && 'sessions' in payload ? payload.sessions : null; + if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions'); + const parsed: TerminalServerSession[] = []; + for (const entry of rawSessions as unknown[]) { + if (typeof entry !== 'object' || entry === null) continue; + // SAFETY: every field is verified below before the value is used. + const candidate = entry as Partial>; + if (typeof candidate.sessionId !== 'string' || typeof candidate.cwd !== 'string') continue; + if (candidate.status !== 'running' && candidate.status !== 'exited') continue; + parsed.push({ + sessionId: candidate.sessionId, + cwd: candidate.cwd, + status: candidate.status, + createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : null, + }); + } + return parsed; +} +export async function touchTerminalSessions(sessionIds: string[]): Promise { + if (sessionIds.length === 0) return; + await command('/api/terminal/touch', 'POST', { sessionIds }); +} export async function listTerminalShells(): Promise { const response = await runtimeFetch('/api/terminal/shells'); if (!response.ok) throw await responseError(response, 'Failed to list terminal shells'); diff --git a/packages/ui/src/stores/useTerminalStore.test.ts b/packages/ui/src/stores/useTerminalStore.test.ts index c60f0ffd..d6e2d8a2 100644 --- a/packages/ui/src/stores/useTerminalStore.test.ts +++ b/packages/ui/src/stores/useTerminalStore.test.ts @@ -12,6 +12,47 @@ const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo', describe('terminal state reconciliation', () => { afterEach(() => useTerminalStore.getState().clearAll()); + test('adopts unknown server sessions into the fresh placeholder tab', () => { + setup(); + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-1', status: 'running', createdAt: 100 }, + { sessionId: 'srv-2', status: 'exited', createdAt: null }, + ]); + const state = useTerminalStore.getState().getDirectoryState('/repo')!; + expect(state.tabs.map((tab) => tab.id)).toEqual(['srv-1', 'srv-2']); + expect(state.tabs[0].terminalSessionId).toBe('srv-1'); + expect(state.tabs[0].lifecycle).toBe('running'); + expect(state.tabs[1].lifecycle).toBe('exited'); + expect(state.activeTabId).toBe('srv-1'); + }); + + test('adoption is additive: existing tabs and referenced sessions survive', () => { + const tabId = setup(); + useTerminalStore.getState().appendToBuffer('/repo', tabId, 'output', 1); + useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-live'); + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-live', status: 'running', createdAt: 1 }, + { sessionId: 'srv-orphan', status: 'running', createdAt: 2 }, + ]); + const state = useTerminalStore.getState().getDirectoryState('/repo')!; + expect(state.tabs).toHaveLength(2); + expect(state.tabs[0].id).toBe(tabId); + expect(state.tabs[1].id).toBe('srv-orphan'); + expect(state.activeTabId).toBe(tabId); + }); + + test('re-adopting the same sessions changes nothing', () => { + setup(); + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-1', status: 'running', createdAt: 100 }, + ]); + const before = useTerminalStore.getState().sessions; + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-1', status: 'running', createdAt: 100 }, + ]); + expect(useTerminalStore.getState().sessions).toBe(before); + }); + test('applies snapshots atomically and deduplicates output by sequence', () => { const tabId = setup(); useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4); diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 485717ff..5771b072 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -71,6 +71,10 @@ interface TerminalStore { getBuffer: (directory: string, tabId: string) => TerminalBuffer; createTab: (directory: string) => string; + adoptServerSessions: ( + directory: string, + serverSessions: Array<{ sessionId: string; status: 'running' | 'exited'; createdAt: number | null }>, + ) => void; setActiveTab: (directory: string, tabId: string) => void; setTabLabel: (directory: string, tabId: string, label: string) => void; setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void; @@ -334,6 +338,60 @@ export const useTerminalStore = create()( return tabId; }, + /** + * The server owns which terminal sessions exist; the local tab list is + * only this client's projection. Adoption is strictly additive: server + * sessions no local tab references become tabs (id = session id, the + * create/attach contract), and nothing is ever removed here, so a + * failed or partial listing cannot destroy local tabs. + */ + adoptServerSessions: (directory, serverSessions) => { + const key = normalizeDirectory(directory); + if (!key || serverSessions.length === 0) return; + + set((state) => { + const existing = state.sessions.get(key); + const knownIds = new Set(); + for (const tab of existing?.tabs ?? []) { + knownIds.add(tab.id); + if (tab.terminalSessionId) knownIds.add(tab.terminalSessionId); + } + + const newcomers = serverSessions.filter((session) => !knownIds.has(session.sessionId)); + if (newcomers.length === 0) return state; + + const tabs = [...(existing?.tabs ?? [])]; + // A single untouched placeholder tab (fresh directory state) is + // replaced by the first adopted session instead of sitting next to it. + const placeholder = tabs.length === 1 + && tabs[0].terminalSessionId === null + && tabs[0].lifecycle === 'idle' + && !state.buffers.has(bufferKey(key, tabs[0].id)) + ? tabs[0] + : null; + if (placeholder) tabs.length = 0; + + for (const session of newcomers) { + const tab: TerminalTab = { + ...createEmptyTab(session.sessionId, placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)), + terminalSessionId: session.sessionId, + lifecycle: session.status, + createdAt: session.createdAt ?? Date.now(), + }; + tabs.push(tab); + } + + const previousActive = existing?.activeTabId ?? null; + const activeTabId = previousActive && tabs.some((tab) => tab.id === previousActive) + ? previousActive + : tabs[0]?.id ?? null; + + const newSessions = new Map(state.sessions); + newSessions.set(key, { tabs, activeTabId }); + return { sessions: newSessions }; + }); + }, + setActiveTab: (directory: string, tabId: string) => { const key = normalizeDirectory(directory); set((state) => { diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index 33fd9e54..f6271a71 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -19,6 +19,8 @@ HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path. +`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them. + ## PTY Lifecycle - IDs are client-provided or generated with `randomUUID()`. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 5e90ac93..d018b6fe 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -228,7 +228,7 @@ export function createTerminalRuntime({ } if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached'); const creation = (async () => { - const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false }; + const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() }; await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell }); sessions.set(id, session); return session; @@ -297,6 +297,34 @@ export function createTerminalRuntime({ res.status(500).json({ error: error?.message || 'Failed to list terminal shells' }); } }); + app.get('/api/terminal/sessions', (req, res) => { + const rawCwd = typeof req.query?.cwd === 'string' ? req.query.cwd.trim() : ''; + const cwdFilter = rawCwd ? path.resolve(rawCwd) : null; + const list = []; + for (const session of sessions.values()) { + if (cwdFilter && path.resolve(session.cwd) !== cwdFilter) continue; + list.push({ + sessionId: session.id, + cwd: session.cwd, + status: session.status, + createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null, + }); + } + res.json({ sessions: list }); + }); + app.post('/api/terminal/touch', (req, res) => { + const rawIds = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : []; + const now = Date.now(); + let touched = 0; + for (const id of rawIds) { + if (typeof id !== 'string') continue; + const session = sessions.get(id); + if (!session) continue; + session.lastActivity = now; + touched += 1; + } + res.json({ touched }); + }); app.post('/api/terminal/create', async (req, res) => { try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); } catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); } diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 8232be95..f0a972df 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -179,6 +179,32 @@ describe('terminal runtime', () => { } finally { await harness.runtime.shutdown(); } }); + it('lists sessions scoped to a working directory and refreshes activity via touch', async () => { + const harness = createHarness(); + try { + await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-a', cwd: '/repo' } }, createResponse()); + await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-b', cwd: '/other' } }, createResponse()); + + const all = createResponse(); + harness.routes.get.get('/api/terminal/sessions')({ query: {} }, all); + expect(all.body.sessions.map((s) => s.sessionId).sort()).toEqual(['term-a', 'term-b']); + + const scoped = createResponse(); + harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped); + expect(scoped.body.sessions).toEqual([ + { sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) }, + ]); + + const touch = createResponse(); + harness.routes.post.get('/api/terminal/touch')({ body: { sessionIds: ['term-a', 'missing', 42] } }, touch); + expect(touch.body).toEqual({ touched: 1 }); + + const malformed = createResponse(); + harness.routes.post.get('/api/terminal/touch')({ body: {} }, malformed); + expect(malformed.body).toEqual({ touched: 0 }); + } finally { await harness.runtime.shutdown(); } + }); + it('strips AppImage ARGV0 from PTY child environments', async () => { const previousArgv0 = process.env.ARGV0; process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage'; diff --git a/packages/web/src/api/terminal.ts b/packages/web/src/api/terminal.ts index 1c029ab2..c82f2446 100644 --- a/packages/web/src/api/terminal.ts +++ b/packages/web/src/api/terminal.ts @@ -8,6 +8,8 @@ import { restartTerminalSession, forceKillTerminal, listTerminalShells, + listTerminalSessions, + touchTerminalSessions, } from '@openchamber/ui/lib/terminalApi'; import type { TerminalAPI, @@ -23,6 +25,14 @@ export const createWebTerminalAPI = (): TerminalAPI => ({ return listTerminalShells(); }, + async listSessions(cwd: string) { + return listTerminalSessions(cwd); + }, + + async touchSessions(sessionIds: string[]) { + await touchTerminalSessions(sessionIds); + }, + async createSession(options: CreateTerminalOptions): Promise { return createTerminalSession(options); }, From d9f4b4b6ccc4f063fc0db84933a20df360ea41b7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:29:38 +0300 Subject: [PATCH 04/66] fix(terminal): stop mobile keyboards auto-capitalizing terminal input ghostty-web marks the terminal container contenteditable but only its hidden textarea opts out of IME text mangling, so iOS and Android keyboards uppercased the first letter of every command. Set autocapitalize/autocorrect/spellcheck off on the container after open. --- packages/ui/src/components/terminal/TerminalViewport.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 82657445..33d9eded 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -245,6 +245,13 @@ const TerminalViewport = React.forwardRef(({ const fitAddon = new module.FitAddon(); terminal.loadAddon(fitAddon); terminal.open(container); + // ghostty-web marks the container contenteditable for touch IME input but + // sets autocapitalize/autocorrect only on its hidden textarea. Mobile + // keyboards (iOS and Android) therefore auto-capitalize the first letter + // of every terminal command; disable IME text mangling on the container. + container.setAttribute('autocapitalize', 'off'); + container.setAttribute('autocorrect', 'off'); + container.setAttribute('spellcheck', 'false'); terminalRef.current = terminal; fitRef.current = fitAddon; subscriptions = [terminal.onData((data) => inputRef.current(data))]; From 2eefd46b70c786612089c0b812f27e72ea8ba453 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:50:03 +0300 Subject: [PATCH 05/66] docs: changelog entries for search, terminal, and mobile keyboard fixes --- CHANGELOG.md | 4 ++++ packages/vscode/CHANGELOG.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06bbfa1a..25b790fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to this project will be documented in this file. - Files: in a rendered markdown preview, select text and choose Comment to attach exactly that fragment (with a source line range when it can be located) plus your note to the next message. - Composer: hovering or tapping a context chip above the input opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. - Mobile: the chat comment input overlays the composer exactly and rides the keyboard; Enter makes a new line there, with attach on the button. +- **Terminal:** terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload now shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. +- Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. +- Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name like "solo-is-a" finds the file inside it. +- Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. - Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache. - Chat: OpenCode notices now share one style. - UI: draft target menus stay inside the chat area instead of overlapping the header. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2c7f9eef..52e80dab 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -4,6 +4,8 @@ - **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type. Add to chat is now Add to input. - Diff: hovering a line shows a + button that opens a comment for the line; clicking a line or dragging across lines opens the editor for that range. The comment editor and saved-comment cards match the chat's comment style. - Composer: hovering a context chip above the input opens a stacked preview of everything attached, where comments can be edited in place or items removed before sending. +- Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible. +- Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name finds the files inside it. - Chat: OpenCode notices now share one style. - The timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran). From 081056e6e54053d523469f5a70b22df856df1b94 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:54:14 +0300 Subject: [PATCH 06/66] feat(search): canonical rankByQuery matcher for searchable dropdowns One matcher for every dropdown filter: multi-token queries match in any order, matching is punctuation-insensitive, single-token queries tolerate typos, and results come back relevance-ordered (prefix, then word-boundary, then substring, original order on ties). matchesRankQuery is the boolean companion for lists that keep their own grouping. --- .../ui/src/lib/search/fuzzySearch.test.ts | 68 +++++++++ packages/ui/src/lib/search/fuzzySearch.ts | 130 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 packages/ui/src/lib/search/fuzzySearch.test.ts diff --git a/packages/ui/src/lib/search/fuzzySearch.test.ts b/packages/ui/src/lib/search/fuzzySearch.test.ts new file mode 100644 index 00000000..82a402bf --- /dev/null +++ b/packages/ui/src/lib/search/fuzzySearch.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; + +import { matchesRankQuery, rankByQuery } from './fuzzySearch'; + +const rank = (items: string[], query: string) => rankByQuery(items, query, (item) => [item]); + +describe('rankByQuery', () => { + test('orders word-boundary matches above mid-word matches, earlier positions first', () => { + const items = ['prefixed-thing', 'workspace-fix', 'feat/fix-scroll']; + expect(rank(items, 'fix')).toEqual(['feat/fix-scroll', 'workspace-fix', 'prefixed-thing']); + }); + + test('exact prefix comes first, ties keep original order', () => { + const items = ['main', 'feat/main-menu', 'maintenance', 'release/main']; + const ranked = rank(items, 'main'); + expect(ranked[0]).toBe('main'); + expect(ranked[1]).toBe('maintenance'); + expect(ranked.slice(2)).toEqual(['feat/main-menu', 'release/main']); + }); + + test('multi-token queries match in any order and all tokens are required', () => { + const items = ['feat/scroll-anchored-chat', 'fix/chat-header', 'feat/scroll-perf']; + expect(rank(items, 'chat scroll')).toEqual(['feat/scroll-anchored-chat']); + }); + + test('punctuation-insensitive compact matching finds joined words', () => { + const items = ['gpt-4o-mini', 'claude-sonnet-5']; + expect(rank(items, 'gpt4o')).toEqual(['gpt-4o-mini']); + expect(rank(items, 'sonnet5')).toEqual(['claude-sonnet-5']); + }); + + test('single-token queries tolerate typos via fuzzy fallback', () => { + const items = ['workspace-rail-layout', 'unrelated']; + expect(rank(items, 'worskpace')).toEqual(['workspace-rail-layout']); + }); + + test('fuzzy fallback can be disabled', () => { + const items = ['workspace-rail-layout']; + expect(rankByQuery(items, 'worskpace', (item) => [item], { fuzzy: false })).toEqual([]); + }); + + test('earlier fields outrank later fields', () => { + const items = [ + { name: 'docs', path: '/repo/build-agent' }, + { name: 'build-agent', path: '/repo/build-agent' }, + ]; + const ranked = rankByQuery(items, 'build', (item) => [item.name, item.path]); + expect(ranked[0].name).toBe('build-agent'); + expect(ranked).toHaveLength(2); + }); + + test('empty query returns items unchanged within the limit', () => { + expect(rank(['b', 'a'], ' ')).toEqual(['b', 'a']); + expect(rankByQuery(['a', 'b', 'c'], '', (item) => [item], { limit: 2 })).toEqual(['a', 'b']); + }); +}); + +describe('matchesRankQuery', () => { + test('requires every token across the fields', () => { + expect(matchesRankQuery(['GLM-5.3', 'Zhipu'], 'zhipu glm')).toBe(true); + expect(matchesRankQuery(['GLM-5.3', 'Zhipu'], 'zhipu gpt')).toBe(false); + }); + + test('is punctuation-insensitive and skips empty fields', () => { + expect(matchesRankQuery([null, 'claude-sonnet-5', undefined], 'sonnet5')).toBe(true); + expect(matchesRankQuery([''], 'a')).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/search/fuzzySearch.ts b/packages/ui/src/lib/search/fuzzySearch.ts index 99cd7446..6d809201 100644 --- a/packages/ui/src/lib/search/fuzzySearch.ts +++ b/packages/ui/src/lib/search/fuzzySearch.ts @@ -141,6 +141,136 @@ export function scoreByFuzzyQuery( return scored.slice(0, limit); } +const RANK_TOKEN_MISS = Number.POSITIVE_INFINITY; + +const tokenizeRankQuery = (query: string): string[] => + query.trim().toLowerCase().split(/\s+/).filter(Boolean); + +const compactText = (value: string): string => value.replace(/[^a-z0-9]+/g, ''); + +type RankFields = { fields: string[]; compact: string[] }; + +const buildRankFields = (texts: ReadonlyArray): RankFields => { + const fields: string[] = []; + const compact: string[] = []; + for (const text of texts) { + if (!text) continue; + const lower = text.toLowerCase(); + fields.push(lower); + compact.push(compactText(lower)); + } + return { fields, compact }; +}; + +/** + * Score one query token against an item's fields. Lower is better: + * field prefix < word-boundary substring < mid-word substring < + * punctuation-insensitive ("compact") substring. Earlier fields win ties, so + * callers should order `getTexts` by importance (name before path/description). + */ +const scoreRankToken = (token: string, { fields, compact }: RankFields): number => { + let best = RANK_TOKEN_MISS; + for (let fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) { + const field = fields[fieldIndex]; + const fieldPenalty = fieldIndex * 0.01; + const idx = field.indexOf(token); + let score = RANK_TOKEN_MISS; + if (idx === 0) { + score = fieldPenalty; + } else if (idx > 0) { + const boundary = !/[a-z0-9]/.test(field[idx - 1]); + score = (boundary ? 0.1 : 0.2) + idx / 1000 + fieldPenalty; + } else { + const compactIdx = compact[fieldIndex].indexOf(compactText(token)); + if (compactIdx >= 0 && token.length > 1) { + score = 0.4 + compactIdx / 1000 + fieldPenalty; + } + } + if (score < best) best = score; + } + return best; +}; + +export interface RankByQueryOptions { + limit?: number; + /** Typo-tolerant Fuse fallback for single-token queries (default true). */ + fuzzy?: boolean; +} + +/** + * The canonical dropdown matcher: every whitespace-separated query token must + * match somewhere in the item's fields (any order, punctuation-insensitive), + * and results come back ordered by relevance — exact/prefix matches first, + * then word-boundary and substring matches, original order breaking ties. + * Single-token queries additionally fall back to typo-tolerant fuzzy matching. + * + * Use this for every searchable dropdown (projects, agents, branches, models) + * instead of ad hoc `toLowerCase().includes` filters, so matching quality and + * ordering stay consistent across pickers. + */ +export function rankByQuery( + items: readonly T[], + query: string, + getTexts: (item: T) => ReadonlyArray, + options?: RankByQueryOptions, +): T[] { + const tokens = tokenizeRankQuery(query); + const limit = options?.limit ?? items.length; + if (tokens.length === 0) return items.slice(0, limit); + + const scored: { item: T; score: number; order: number }[] = []; + const missed: { item: T; joined: string; order: number }[] = []; + + for (let order = 0; order < items.length; order++) { + const item = items[order]; + const rankFields = buildRankFields(getTexts(item)); + let total = 0; + for (const token of tokens) { + const tokenScore = scoreRankToken(token, rankFields); + if (tokenScore === RANK_TOKEN_MISS) { + total = RANK_TOKEN_MISS; + break; + } + total += tokenScore; + } + if (total === RANK_TOKEN_MISS) { + missed.push({ item, joined: rankFields.fields.join(' '), order }); + } else { + scored.push({ item, score: total, order }); + } + } + + const fuzzyEnabled = options?.fuzzy ?? true; + if (fuzzyEnabled && tokens.length === 1 && tokens[0].length >= 3 && missed.length > 0) { + const fuse = new Fuse( + missed.map((entry) => entry.joined), + { threshold: 0.35, ignoreLocation: true, distance: 100, includeScore: true, minMatchCharLength: 2 }, + ); + for (const result of fuse.search(tokens[0])) { + const entry = missed[result.refIndex]; + scored.push({ item: entry.item, score: 1 + (result.score ?? 1), order: entry.order }); + } + } + + scored.sort((a, b) => (a.score - b.score) || (a.order - b.order)); + return scored.slice(0, limit).map((entry) => entry.item); +} + +/** + * Boolean companion to `rankByQuery` for lists that keep their own grouping or + * order: every token must match one of the fields, punctuation-insensitive, + * without the fuzzy fallback. + */ +export function matchesRankQuery( + texts: ReadonlyArray, + query: string, +): boolean { + const tokens = tokenizeRankQuery(query); + if (tokens.length === 0) return true; + const rankFields = buildRankFields(texts); + return tokens.every((token) => scoreRankToken(token, rankFields) !== RANK_TOKEN_MISS); +} + export function partitionByFuzzyQuery( items: T[], query: string, From b8716fe808d4ed8c2c9b9f85a6bad14102b057ed Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 15:00:52 +0300 Subject: [PATCH 07/66] refactor(search): unify dropdown filtering on the shared ranked matcher Branch, project, agent, model, provider, stash, SSH-host, skill-catalog and archive filters each had their own toLowerCase().includes (or no ordering at all); the git branch and gitmoji pickers also let cmdk re-filter and reorder on top of the manual filter, silently dropping rows. All of them now go through rankByQuery/matchesRankQuery: results are relevance-ordered, multi-word queries match in any order, matching ignores punctuation, and cmdk filtering is disabled where the ranked list is already final. rankBranchesForQuery keeps relevance order instead of re-sorting matches alphabetically; the model picker now also matches model ids. --- packages/ui/src/apps/MobileSessionsSheet.tsx | 13 ++-- .../chat/FileMentionAutocomplete.tsx | 15 ++-- .../ui/src/components/chat/ModelControls.tsx | 47 ++----------- .../chat/composer/ui/DraftTargetSelectors.tsx | 15 ++-- .../model-picker/ModelPickerList.tsx | 17 ++--- .../sections/providers/ProvidersPage.tsx | 22 ++---- .../remote-instances/RemoteInstancesPage.tsx | 12 ++-- .../skills/catalog/SkillsCatalogPage.tsx | 13 ++-- .../ui/src/components/views/ArchiveView.tsx | 3 +- packages/ui/src/components/views/DiffView.tsx | 6 +- packages/ui/src/components/views/GitView.tsx | 17 ++--- .../agent-manager/AgentManagerSidebar.tsx | 12 ++-- .../views/git/BranchIntegrationSection.tsx | 19 +++--- .../components/views/git/BranchSelector.tsx | 23 ++++--- .../views/git/IntegrateCommitsSection.tsx | 15 +++- .../components/views/git/StashesDialog.tsx | 10 +-- packages/ui/src/lib/search/fuzzySearch.ts | 68 ------------------- .../ui/src/lib/worktrees/branchSearch.test.ts | 33 +++++++++ packages/ui/src/lib/worktrees/branchSearch.ts | 47 ++++--------- 19 files changed, 145 insertions(+), 262 deletions(-) create mode 100644 packages/ui/src/lib/worktrees/branchSearch.test.ts diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 43a28f32..a64d94f0 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -43,6 +43,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getProjectLabel, normalizePath } from './mobilePaths'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useI18n } from '@/lib/i18n'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; import { @@ -188,11 +189,8 @@ const findExactProjectMatch = (projects: ProjectMeta[], directory: string): Proj return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null; }; -const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => { - if (!query) return true; - const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase(); - return haystack.includes(query); -}; +const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => + matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query); const MobileProjectIcon: React.FC<{ project: Pick; @@ -1355,7 +1353,7 @@ export const MobileSessionsSheet: React.FC = ({ open, const filteredNodes = React.useMemo(() => { if (!normalizedQuery) return projectNodes; return projectNodes.filter((node) => { - if (`${node.project.label} ${node.project.path}`.toLowerCase().includes(normalizedQuery)) return true; + if (matchesRankQuery([node.project.label, node.project.path], normalizedQuery)) return true; return node.buckets.some((bucket) => bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)), ); @@ -1385,8 +1383,7 @@ export const MobileSessionsSheet: React.FC = ({ open, const searchProjectMatches = React.useMemo(() => { if (!normalizedQuery) return [] as Array; - return projectsMeta - .filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery)) + return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path]) .map((project) => ({ ...project, sessionCount: sessions.filter((session) => { diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 963e8c46..5258b132 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -15,6 +15,7 @@ import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip'; type FileInfo = ProjectFileSearchHit; @@ -95,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef typeof value === 'string' && value.length > 0); const seen = new Set(); - const queryLower = normalizedSearchQuery.toLowerCase(); const mapped = ordered .filter((filePath) => { if (seen.has(filePath)) return false; seen.add(filePath); const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath; - if (!queryLower) return true; - return relative.toLowerCase().includes(queryLower); + return matchesRankQuery([relative], normalizedSearchQuery); }) .slice(0, 6) .map((filePath) => { @@ -256,21 +255,15 @@ export const FileMentionAutocomplete = React.forwardRef { const visibleAgents = getVisibleAgents(); - const normalizedQuery = (searchQuery ?? '').trim().toLowerCase(); - const filtered = visibleAgents + const subagents = visibleAgents .filter((agent) => agent.mode && agent.mode !== 'primary') - .filter((agent) => { - if (!normalizedQuery) return true; - const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase(); - return haystack.includes(normalizedQuery); - }) .map((agent) => ({ name: agent.name, description: agent.description, mode: agent.mode, })) .sort((a, b) => a.name.localeCompare(b.name)); - setAgents(filtered); + setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description])); }, [getVisibleAgents, searchQuery]); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index a290fde8..7a953090 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device'; import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata'; import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; -import { cn, fuzzyMatch } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -528,13 +529,7 @@ export const ModelControls: React.FC = ({ const sortedAndFilteredAgents = React.useMemo(() => { const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name)); - if (!agentSearchQuery.trim()) { - return sorted; - } - return sorted.filter((agent) => - fuzzyMatch(agent.name, agentSearchQuery) || - (agent.description && fuzzyMatch(agent.description, agentSearchQuery)) - ); + return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]); }, [selectableDesktopAgents, agentSearchQuery]); const defaultAgentName = React.useMemo(() => { @@ -580,38 +575,10 @@ export const ModelControls: React.FC = ({ return result; }, [providers, hiddenModels]); - const normalizeModelSearchValue = React.useCallback((value: string) => { - const lower = value.toLowerCase().trim(); - const compact = lower.replace(/[^a-z0-9]/g, ''); - const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean); - return { lower, compact, tokens }; - }, []); - - const matchesModelSearch = React.useCallback((candidate: string, query: string) => { - const normalizedQuery = normalizeModelSearchValue(query); - if (!normalizedQuery.lower) { - return true; - } - - const normalizedCandidate = normalizeModelSearchValue(candidate); - if (normalizedCandidate.lower.includes(normalizedQuery.lower)) { - return true; - } - - if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) { - return true; - } - - if (normalizedQuery.tokens.length === 0) { - return false; - } - - return normalizedQuery.tokens.every((queryToken) => - normalizedCandidate.tokens.some((candidateToken) => - candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken) - ) - ); - }, [normalizeModelSearchValue]); + const matchesModelSearch = React.useCallback( + (candidate: string, query: string) => matchesRankQuery([candidate], query), + [], + ); const currentModelForMetadata = currentModelId ? models.find((model: ProviderModel) => model.id === currentModelId) diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index c6fba04c..886aad6e 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select'; import { useI18n } from '@/lib/i18n'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import type { Theme } from '@/types/theme'; @@ -264,13 +265,7 @@ export function MobileDraftTargetSheets( className="h-9" />
- {projects - .filter((project) => { - const needle = query.trim().toLowerCase(); - if (!needle) return true; - return getProjectDisplayLabel(project).toLowerCase().includes(needle) - || project.path.toLowerCase().includes(needle); - }) + {rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path]) .map((project) => (
- {worktreeBranchOptions - .filter((option) => matches(option.label)) + {rankByQuery(worktreeBranchOptions, query, (option) => [option.label]) .map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))} {selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '') ? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current') diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index 132f8fa2..4a277737 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -12,6 +12,7 @@ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } import { CSS as DndCSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; import { Input } from '@/components/ui/input'; +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -455,18 +456,18 @@ export const ModelPickerList: React.FC = ({ return hiddenModels.some((hidden) => hidden.providerID === providerID && hidden.modelID === modelID); }, [hiddenModels]); - const matchesQuery = React.useCallback((modelName: string, providerName: string) => { - const query = searchQuery.trim().toLowerCase(); - if (!query) return true; - return modelName.toLowerCase().includes(query) || providerName.toLowerCase().includes(query); - }, [searchQuery]); + const matchesQuery = React.useCallback( + (modelName: string, providerName: string, modelID?: string) => + matchesRankQuery([modelName, modelID, providerName], searchQuery), + [searchQuery], + ); const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => { if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false; if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false; const providerName = providerById.get(providerID)?.name || providerID; - return matchesQuery(getModelDisplayName(model), providerName); + return matchesQuery(getModelDisplayName(model), providerName, modelID); }), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]); const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => { @@ -474,7 +475,7 @@ export const ModelPickerList: React.FC = ({ if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false; const providerName = providerById.get(providerID)?.name || providerID; - return matchesQuery(getModelDisplayName(model), providerName); + return matchesQuery(getModelDisplayName(model), providerName, modelID); }), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]); const orderedProviders = React.useMemo(() => { @@ -495,7 +496,7 @@ export const ModelPickerList: React.FC = ({ const modelID = typeof model.id === 'string' ? model.id : ''; if (!modelID || isHidden(provider.id, modelID)) return false; if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false; - return matchesQuery(getModelDisplayName(model), provider.name || provider.id); + return matchesQuery(getModelDisplayName(model), provider.name || provider.id, modelID); }); return { ...provider, models: filteredModels }; }) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index a84bb4fd..9ca24ca5 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -1,3 +1,4 @@ +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; @@ -603,15 +604,9 @@ export const ProvidersPage: React.FC = () => {
{(() => { - const query = providerSearchQuery.toLowerCase(); const customLabel = t('settings.providers.page.custom.optionLabel'); - const customMatches = !query - || customLabel.toLowerCase().includes(query) - || 'other'.includes(query) - || 'custom'.includes(query); - const filtered = unconnectedProviders.filter(p => { - return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query); - }); + const customMatches = matchesRankQuery([customLabel, 'other', 'custom'], providerSearchQuery); + const filtered = rankByQuery(unconnectedProviders, providerSearchQuery, (p) => [p.name || p.id, p.id]); if (filtered.length === 0 && !customMatches) { return

{t('settings.providers.page.connect.noProvidersFound')}

; } @@ -792,13 +787,10 @@ export const ProvidersPage: React.FC = () => { ? t('settings.providers.page.auth.useReconnectHint') : t('settings.providers.page.auth.incompleteHint'); - const filteredModels = providerModels.filter((model) => { - const name = typeof model?.name === 'string' ? model.name : ''; - const id = typeof model?.id === 'string' ? model.id : ''; - const query = modelQuery.trim().toLowerCase(); - if (!query) return true; - return name.toLowerCase().includes(query) || id.toLowerCase().includes(query); - }); + const filteredModels = rankByQuery(providerModels, modelQuery, (model) => [ + typeof model?.name === 'string' ? model.name : '', + typeof model?.id === 'string' ? model.id : '', + ]); if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) { return ( diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 558bb2ca..4bc0c087 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -1,3 +1,4 @@ +import { rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import QRCode from 'qrcode'; import { Button } from '@/components/ui/button'; @@ -1255,13 +1256,10 @@ export const RemoteInstancesPage: React.FC = () => { [createImportedInstance], ); - const filteredImportCandidates = React.useMemo(() => { - const query = sshHostSearch.trim().toLowerCase(); - if (!query) return importCandidates; - return importCandidates.filter((candidate) => { - return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query); - }); - }, [importCandidates, sshHostSearch]); + const filteredImportCandidates = React.useMemo( + () => rankByQuery(importCandidates, sshHostSearch, (candidate) => [candidate.host, candidate.sshCommand]), + [importCandidates, sshHostSearch], + ); // Opening a ready instance means pointing this window at the forwarded local // URL — the same navigation the host switcher performs after its own connect. diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index 621aa2fa..40db43a5 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -1,3 +1,4 @@ +import { rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -265,14 +266,12 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo const isSearching = search.trim().length > 0; const filtered = React.useMemo(() => { - const q = search.trim().toLowerCase(); - const matches = (item: SkillsCatalogItem) => - item.skillName.toLowerCase().includes(q) - || (item.description || '').toLowerCase().includes(q) - || (item.frontmatterName || '').toLowerCase().includes(q); - if (isSearching) { - return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches)); + return rankByQuery( + sources.flatMap((src) => itemsBySource[src.id] || []), + search, + (item) => [item.skillName, item.frontmatterName, item.description], + ); } if (!selectedSourceId) { return []; diff --git a/packages/ui/src/components/views/ArchiveView.tsx b/packages/ui/src/components/views/ArchiveView.tsx index b5f6b1c1..12c2b495 100644 --- a/packages/ui/src/components/views/ArchiveView.tsx +++ b/packages/ui/src/components/views/ArchiveView.tsx @@ -1,3 +1,4 @@ +import { rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { Icon } from '@/components/icon/Icon'; @@ -67,7 +68,7 @@ export function ArchiveView(): React.ReactNode { // while not searching. const filteredSessions = React.useMemo(() => { if (normalizedQuery) { - return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery)); + return rankByQuery(sortedSessions, normalizedQuery, (session) => [session.title]); } if (selectedDirectory === null) return sortedSessions; return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? []; diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 896208b7..28a15721 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -8,6 +8,7 @@ import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeD import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { cn } from '@/lib/utils'; +import { rankByQuery } from '@/lib/search/fuzzySearch'; import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types'; import { DropdownMenu, @@ -1953,12 +1954,11 @@ export const DiffView: React.FC = ({ } if (!branchBase) { - const searchTerm = basePickerSearch.trim().toLowerCase(); - const candidateBranches = (branches?.all ?? []) + const eligibleBranches = (branches?.all ?? []) .map((name: string) => name.replace(/^remotes\//, '')) .filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`)) - .filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm)) .sort(); + const candidateBranches = rankByQuery(eligibleBranches, basePickerSearch, (name) => [name]); return (
diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 28fcf9f1..008550e6 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -3,6 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types'; +import { rankByQuery } from '@/lib/search/fuzzySearch'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useShallow } from 'zustand/react/shallow'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; @@ -2585,7 +2586,8 @@ export const GitView: React.FC = ({ isActive }) => { {t('gitView.gitmoji.title')} - + {/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */} + = ({ isActive }) => { {t('gitView.gitmoji.empty')} - {(gitmojiEmojis.length === 0 - ? [] - : gitmojiEmojis.filter((entry) => { - const term = gitmojiSearch.trim().toLowerCase(); - if (!term) return true; - return ( - entry.emoji.includes(term) || - entry.code.toLowerCase().includes(term) || - entry.description.toLowerCase().includes(term) - ); - }) - ).map((entry) => ( + {rankByQuery(gitmojiEmojis, gitmojiSearch, (entry) => [entry.code, entry.description, entry.emoji]).map((entry) => ( handleSelectGitmoji(entry.emoji, entry.code)} diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx index def5b964..58565c47 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx @@ -1,3 +1,4 @@ +import { rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { toast } from '@/components/ui'; import { Input } from '@/components/ui/input'; @@ -214,13 +215,10 @@ export const AgentManagerSidebar: React.FC = ({ const MAX_VISIBLE = 5; - const filteredGroups = React.useMemo(() => { - if (!searchQuery.trim()) return groups; - const query = searchQuery.toLowerCase(); - return groups.filter(group => - group.name.toLowerCase().includes(query) - ); - }, [searchQuery, groups]); + const filteredGroups = React.useMemo( + () => rankByQuery(groups, searchQuery, (group) => [group.name]), + [searchQuery, groups], + ); const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE); const remainingCount = filteredGroups.length - MAX_VISIBLE; diff --git a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx index 0348d3be..71a330bc 100644 --- a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx +++ b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx @@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; +import { rankByQuery } from '@/lib/search/fuzzySearch'; import { useI18n } from '@/lib/i18n'; type OperationType = 'merge' | 'rebase'; @@ -94,22 +95,19 @@ export const BranchIntegrationSection: React.FC = // Filter branches based on search const filteredLocal = React.useMemo(() => { - const term = branchSearch.toLowerCase(); const remoteBranchNames = new Set( remoteBranches .map((branch) => branch.slice(branch.indexOf('/') + 1)) .filter(Boolean) ); - const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch)); - if (!term) return filtered; - return filtered.filter((b) => b.toLowerCase().includes(term)); + const candidates = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch)); + return rankByQuery(candidates, branchSearch, (branch) => [branch]); }, [branchSearch, localBranches, currentBranch, remoteBranches]); - const filteredRemote = React.useMemo(() => { - const term = branchSearch.toLowerCase(); - if (!term) return remoteBranches; - return remoteBranches.filter((b) => b.toLowerCase().includes(term)); - }, [branchSearch, remoteBranches]); + const filteredRemote = React.useMemo( + () => rankByQuery(remoteBranches, branchSearch, (branch) => [branch]), + [branchSearch, remoteBranches] + ); const resolveDefaultBranch = React.useCallback(() => { if (!defaultTargetBranch) return null; @@ -321,7 +319,8 @@ export const BranchIntegrationSection: React.FC = sideOffset={6} className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" > - + {/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */} + = ({ [newBranchName] ); - const filteredLocal = React.useMemo(() => { - const term = search.toLowerCase(); - if (!term) return localBranches; - return localBranches.filter((b) => b.toLowerCase().includes(term)); - }, [search, localBranches]); + const filteredLocal = React.useMemo( + () => rankByQuery(localBranches, search, (branch) => [branch]), + [search, localBranches] + ); - const filteredRemote = React.useMemo(() => { - const term = search.toLowerCase(); - if (!term) return remoteBranches; - return remoteBranches.filter((b) => b.toLowerCase().includes(term)); - }, [search, remoteBranches]); + const filteredRemote = React.useMemo( + () => rankByQuery(remoteBranches, search, (branch) => [branch]), + [search, remoteBranches] + ); const handleCheckout = (branch: string) => { if (branch === currentBranch) { @@ -184,7 +183,9 @@ export const BranchSelector: React.FC = ({ - + {/* Filtering and ordering are owned by rankByQuery above; cmdk's own + filter would re-filter and reorder the already-ranked rows. */} + s.currentSessionId); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); + const [branchSearch, setBranchSearch] = React.useState(''); const searchInputRef = React.useRef(null); + const filteredBranches = React.useMemo( + () => rankByQuery(localBranches, branchSearch, (branch) => [branch]), + [localBranches, branchSearch] + ); + const [targetBranch, setTargetBranch] = React.useState(defaultTargetBranch); React.useEffect(() => { setTargetBranch(defaultTargetBranch); @@ -380,10 +387,13 @@ export const IntegrateCommitsSection: React.FC<{ align="end" className="w-72 p-0 max-h-[var(--available-height)] flex flex-col overflow-hidden" > - + {/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */} + event.stopPropagation()} /> {t('gitView.branch.empty')} - {localBranches.map((branch) => ( + {filteredBranches.map((branch) => ( {branch} diff --git a/packages/ui/src/components/views/git/StashesDialog.tsx b/packages/ui/src/components/views/git/StashesDialog.tsx index 979a6fa4..b3753e82 100644 --- a/packages/ui/src/components/views/git/StashesDialog.tsx +++ b/packages/ui/src/components/views/git/StashesDialog.tsx @@ -1,3 +1,4 @@ +import { rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; @@ -69,11 +70,10 @@ export const StashesDialog: React.FC = ({ }; }, [directory, open, stashes]); - const filtered = React.useMemo(() => { - const normalized = query.trim().toLowerCase(); - if (!normalized) return stashes; - return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized)); - }, [query, stashes]); + const filtered = React.useMemo( + () => rankByQuery(stashes, query, (stash) => [stash.message, stash.ref, stash.relativeTime]), + [query, stashes], + ); const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => { await load(); diff --git a/packages/ui/src/lib/search/fuzzySearch.ts b/packages/ui/src/lib/search/fuzzySearch.ts index 6d809201..52e669b2 100644 --- a/packages/ui/src/lib/search/fuzzySearch.ts +++ b/packages/ui/src/lib/search/fuzzySearch.ts @@ -41,54 +41,6 @@ export function matchesFuzzyQuery( return fuse.search(query).length > 0; } -function getFuzzyMatchMask( - items: T[], - query: string, - getText: (item: T) => string, - options?: FuzzySearchOptions -): boolean[] { - if (!query) { - return items.map(() => true); - } - - const mergedOptions = { ...DEFAULT_FUZZY_OPTIONS, ...options }; - const queryLower = query.toLowerCase(); - const matches = new Array(items.length).fill(false); - const fuzzyCandidateTexts: string[] = []; - const fuzzyCandidateIndices: number[] = []; - - for (let i = 0; i < items.length; i++) { - const target = getText(items[i]); - if (!target) { - continue; - } - - if (mergedOptions.preferSubstring && target.toLowerCase().includes(queryLower)) { - matches[i] = true; - continue; - } - - fuzzyCandidateTexts.push(target); - fuzzyCandidateIndices.push(i); - } - - if (fuzzyCandidateTexts.length === 0) { - return matches; - } - - const fuse = new Fuse(fuzzyCandidateTexts, { - threshold: mergedOptions.threshold, - distance: mergedOptions.distance, - ignoreLocation: mergedOptions.ignoreLocation, - }); - - for (const result of fuse.search(query)) { - matches[fuzzyCandidateIndices[result.refIndex]] = true; - } - - return matches; -} - /** * Score-sorted fuzzy ranking. Strict (low threshold), prioritizes substring * matches (especially prefix matches), and returns the top N. @@ -271,23 +223,3 @@ export function matchesRankQuery( return tokens.every((token) => scoreRankToken(token, rankFields) !== RANK_TOKEN_MISS); } -export function partitionByFuzzyQuery( - items: T[], - query: string, - getText: (item: T) => string, - options?: FuzzySearchOptions -): { matching: T[]; other: T[] } { - const matches = getFuzzyMatchMask(items, query, getText, options); - const matching: T[] = []; - const other: T[] = []; - - for (let i = 0; i < items.length; i++) { - if (matches[i]) { - matching.push(items[i]); - continue; - } - other.push(items[i]); - } - - return { matching, other }; -} diff --git a/packages/ui/src/lib/worktrees/branchSearch.test.ts b/packages/ui/src/lib/worktrees/branchSearch.test.ts new file mode 100644 index 00000000..99a1fee6 --- /dev/null +++ b/packages/ui/src/lib/worktrees/branchSearch.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; + +import { rankBranchesForQuery } from './branchSearch'; + +describe('rankBranchesForQuery', () => { + test('empty query keeps everything in the other groups', () => { + const result = rankBranchesForQuery({ localBranches: ['main'], remoteBranches: ['origin/dev'], query: ' ' }); + expect(result.matching).toEqual([]); + expect(result.otherLocal).toEqual(['main']); + expect(result.otherRemote).toEqual(['origin/dev']); + }); + + test('orders matches by relevance, not alphabetically', () => { + const result = rankBranchesForQuery({ + localBranches: ['aaa-fix-scroll', 'fix/scroll', 'main'], + remoteBranches: ['origin/fix/scroll-old'], + query: 'fix', + }); + expect(result.matching[0]).toEqual({ label: 'fix/scroll', value: 'fix/scroll', source: 'local' }); + expect(result.matching.map((entry) => entry.label)).toEqual([ + 'fix/scroll', + 'aaa-fix-scroll', + 'origin/fix/scroll-old', + ]); + expect(result.otherLocal).toEqual(['main']); + expect(result.otherRemote).toEqual([]); + }); + + test('remote matches carry the remotes/ checkout value', () => { + const result = rankBranchesForQuery({ localBranches: [], remoteBranches: ['origin/feat/x'], query: 'feat' }); + expect(result.matching[0].value).toBe('remotes/origin/feat/x'); + }); +}); diff --git a/packages/ui/src/lib/worktrees/branchSearch.ts b/packages/ui/src/lib/worktrees/branchSearch.ts index 3bf564cf..d08953f4 100644 --- a/packages/ui/src/lib/worktrees/branchSearch.ts +++ b/packages/ui/src/lib/worktrees/branchSearch.ts @@ -1,4 +1,4 @@ -import { partitionByFuzzyQuery } from "@/lib/search/fuzzySearch"; +import { rankByQuery } from "@/lib/search/fuzzySearch"; export interface RankedBranchGroups { matching: Array<{ @@ -26,42 +26,19 @@ export function rankBranchesForQuery(args: { }; } - const localPartition = partitionByFuzzyQuery(localBranches, normalizedQuery, (branch) => branch); - const remotePartition = partitionByFuzzyQuery(remoteBranches, normalizedQuery, (branch) => branch); - const matching: RankedBranchGroups['matching'] = []; - const otherLocal = localPartition.other; - const otherRemote = remotePartition.other; - - for (const branch of localPartition.matching) { - matching.push({ - label: branch, - value: branch, - source: 'local', - }); - } - - for (const branch of remotePartition.matching) { - matching.push({ - label: branch, - value: `remotes/${branch}`, - source: 'remote', - }); - } - - matching.sort((a, b) => { - const byLabel = a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); - if (byLabel !== 0) { - return byLabel; - } - if (a.source !== b.source) { - return a.source.localeCompare(b.source); - } - return a.value.localeCompare(b.value); - }); + // Rank local and remote branches together so the order reflects match + // quality (an exact or prefix match lands first), not the source group or + // the alphabet. + const candidates: RankedBranchGroups['matching'] = [ + ...localBranches.map((branch) => ({ label: branch, value: branch, source: 'local' as const })), + ...remoteBranches.map((branch) => ({ label: branch, value: `remotes/${branch}`, source: 'remote' as const })), + ]; + const matching = rankByQuery(candidates, normalizedQuery, (branch) => [branch.label]); + const matched = new Set(matching); return { matching, - otherLocal, - otherRemote, + otherLocal: candidates.filter((entry) => entry.source === 'local' && !matched.has(entry)).map((entry) => entry.label), + otherRemote: candidates.filter((entry) => entry.source === 'remote' && !matched.has(entry)).map((entry) => entry.label), }; } From c02d7ff399b6078a50bd2828d84264b06983cd3a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 15:01:11 +0300 Subject: [PATCH 08/66] docs: changelog entry for unified dropdown search --- CHANGELOG.md | 1 + packages/vscode/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b790fe..d2d85339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - **Terminal:** terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload now shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name like "solo-is-a" finds the file inside it. +- **Search in dropdowns:** every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). The git branch and gitmoji pickers also stopped silently dropping rows that a second, built-in filter didn't like. - Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. - Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache. - Chat: OpenCode notices now share one style. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 52e80dab..58c288eb 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -6,6 +6,7 @@ - Composer: hovering a context chip above the input opens a stacked preview of everything attached, where comments can be edited in place or items removed before sending. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name finds the files inside it. +- Search in dropdowns: searchable pickers (agents, models, providers, branches) now put the best matches first, match multi-word queries in any order, and ignore punctuation (so "gpt4o" finds "gpt-4o"). - Chat: OpenCode notices now share one style. - The timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran). From da309a2a8de83a96028fef2a84b2bdb5f109d809 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 15:04:14 +0300 Subject: [PATCH 09/66] refactor(search): session sidebar and project-context filters use the shared matcher Sidebar session/folder/group search and the Todos, Memory, Plans and Notes filters required the whole query as one literal substring; they now match tokens in any order and ignore punctuation via matchesRankQuery, keeping their own list order and tree structure. --- .../session/project-context/MemorySection.tsx | 12 +++++------- .../session/project-context/NotesSection.tsx | 10 +++++----- .../session/project-context/PlansSection.tsx | 10 +++++----- .../session/project-context/TodosSection.tsx | 10 +++++----- .../session/sidebar/SessionGroupSection.tsx | 3 ++- .../session/sidebar/hooks/useSessionGrouping.ts | 3 ++- .../sidebar/hooks/useSessionSidebarSections.ts | 5 +++-- 7 files changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/components/session/project-context/MemorySection.tsx b/packages/ui/src/components/session/project-context/MemorySection.tsx index 45c8e22a..61b4ea42 100644 --- a/packages/ui/src/components/session/project-context/MemorySection.tsx +++ b/packages/ui/src/components/session/project-context/MemorySection.tsx @@ -1,3 +1,4 @@ +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { toast } from '@/components/ui'; @@ -186,13 +187,10 @@ export const MemorySection: React.FC<{ }; }, [markViewed, viewKey]); - const visibleEntries = React.useMemo(() => { - const needle = query.trim().toLowerCase(); - if (!needle) return entries; - return entries.filter((entry) => ( - entry.title.toLowerCase().includes(needle) || entry.body.toLowerCase().includes(needle) - )); - }, [entries, query]); + const visibleEntries = React.useMemo( + () => entries.filter((entry) => matchesRankQuery([entry.title, entry.body], query)), + [entries, query], + ); const handleDelete = React.useCallback(async (memoryId: string) => { if (!await deleteEntry(scope, memoryId)) { diff --git a/packages/ui/src/components/session/project-context/NotesSection.tsx b/packages/ui/src/components/session/project-context/NotesSection.tsx index a01b4d84..1f85be2c 100644 --- a/packages/ui/src/components/session/project-context/NotesSection.tsx +++ b/packages/ui/src/components/session/project-context/NotesSection.tsx @@ -1,3 +1,4 @@ +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { toast } from '@/components/ui'; @@ -178,11 +179,10 @@ export const NotesSection: React.FC<{ const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody); const deleteNote = useProjectContextStore((state) => state.deleteNote); - const visibleNotes = React.useMemo(() => { - const needle = query.trim().toLowerCase(); - if (!needle) return notes; - return notes.filter((note) => note.body.toLowerCase().includes(needle)); - }, [notes, query]); + const visibleNotes = React.useMemo( + () => notes.filter((note) => matchesRankQuery([note.body], query)), + [notes, query], + ); // The store keeps the failure reason; without passing it through, every // failure looks identical to the user and tells them nothing about the cause. diff --git a/packages/ui/src/components/session/project-context/PlansSection.tsx b/packages/ui/src/components/session/project-context/PlansSection.tsx index ef15d99e..a5d00fc1 100644 --- a/packages/ui/src/components/session/project-context/PlansSection.tsx +++ b/packages/ui/src/components/session/project-context/PlansSection.tsx @@ -1,3 +1,4 @@ +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { toast } from '@/components/ui'; @@ -146,11 +147,10 @@ export const PlansSection: React.FC<{ [onTogglePinned, projectRef, t] ); - const visiblePlans = React.useMemo(() => { - const needle = query.trim().toLowerCase(); - if (!needle) return plans; - return plans.filter((plan) => plan.title.toLowerCase().includes(needle)); - }, [plans, query]); + const visiblePlans = React.useMemo( + () => plans.filter((plan) => matchesRankQuery([plan.title], query)), + [plans, query], + ); const handleOpenPlan = React.useCallback( (plan: ProjectPlanLink) => { diff --git a/packages/ui/src/components/session/project-context/TodosSection.tsx b/packages/ui/src/components/session/project-context/TodosSection.tsx index fe028ac4..788dc583 100644 --- a/packages/ui/src/components/session/project-context/TodosSection.tsx +++ b/packages/ui/src/components/session/project-context/TodosSection.tsx @@ -1,3 +1,4 @@ +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { DndContext, @@ -183,11 +184,10 @@ export const TodosSection: React.FC<{ const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); // Filtering is display-only: every handler above still edits the full list, // so reordering or clearing while a filter is active cannot drop hidden items. - const visibleTodos = React.useMemo(() => { - const needle = query.trim().toLowerCase(); - if (!needle) return todos; - return todos.filter((todo) => todo.text.toLowerCase().includes(needle)); - }, [query, todos]); + const visibleTodos = React.useMemo( + () => todos.filter((todo) => matchesRankQuery([todo.text], query)), + [query, todos], + ); return (
diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index cfab4159..6aea3643 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -1,3 +1,4 @@ +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { Session } from '@opencode-ai/sdk/v2'; @@ -483,7 +484,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { return true; } - const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery); + const folderMatches = matchesRankQuery([entry.folder.name], normalizedSessionSearchQuery); if (folderMatches || entry.nodes.length > 0) { keepByFolderId.set(folderId, true); return true; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts index c81e23e9..b75ea6dd 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts @@ -1,3 +1,4 @@ +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; @@ -44,7 +45,7 @@ export const useSessionGrouping = (args: Args) => { } return nodes.flatMap((node) => { - const nodeMatches = buildSessionSearchText(node.session).includes(query); + const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query); if (nodeMatches) { return [node]; } diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts index ec53e8f5..90658193 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts @@ -1,3 +1,4 @@ +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import type { SessionGroup, SessionNode, GroupSearchData } from '../types'; @@ -161,10 +162,10 @@ export const useSessionSidebarSections = (args: Args) => { section.groups.forEach((group) => { const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery); const matchedSessionCount = countNodes(filteredNodes); - const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery); + const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery); const scopeKey = normalizePath(group.directory ?? null); const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : []; - const folderNameMatchCount = scopeFolders.filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length; + const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length; result.set(group, { filteredNodes, From 98c2a616c3a7ae739181ba86c055f7201a9f5264 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 15:04:23 +0300 Subject: [PATCH 10/66] docs: extend dropdown search changelog bullet with sidebar filters --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d85339..e024f7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file. - **Terminal:** terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload now shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name like "solo-is-a" finds the file inside it. -- **Search in dropdowns:** every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). The git branch and gitmoji pickers also stopped silently dropping rows that a second, built-in filter didn't like. +- **Search in dropdowns:** every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). The git branch and gitmoji pickers also stopped silently dropping rows that a second, built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way now. - Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. - Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache. - Chat: OpenCode notices now share one style. From 841eca572091d28fcbe077ca5b17d418734cba98 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 16:08:04 +0300 Subject: [PATCH 11/66] feat(surface): switch app shells when the viewport crosses the phone threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile-vs-desktop surface is stamped once at boot, so a browser window narrowed past the phone threshold kept the desktop shell (and its legacy squeezed layout) until a manual reload. A viewport watcher now reloads into the other shell once the resize settles — the same mechanism the old Settings toggle used. Fixed shells (Capacitor, desktop, VS Code) and ?surface= overrides never switch. With the new mobile app reachable this way, the old/new mobile layout preference is gone: phones always get the mobile app. --- bun.lock | 8 +-- .../openchamber/OpenChamberVisualSettings.tsx | 41 +---------- .../ui/src/lib/i18n/messages/de.settings.ts | 3 - .../ui/src/lib/i18n/messages/en.settings.ts | 3 - .../ui/src/lib/i18n/messages/es.settings.ts | 3 - .../ui/src/lib/i18n/messages/fr.settings.ts | 3 - .../ui/src/lib/i18n/messages/ja.settings.ts | 3 - .../ui/src/lib/i18n/messages/ko.settings.ts | 3 - .../ui/src/lib/i18n/messages/pl.settings.ts | 3 - .../src/lib/i18n/messages/pt-BR.settings.ts | 3 - .../ui/src/lib/i18n/messages/uk.settings.ts | 3 - .../src/lib/i18n/messages/zh-CN.settings.ts | 3 - .../src/lib/i18n/messages/zh-TW.settings.ts | 3 - packages/ui/src/lib/mobileLayoutPreference.ts | 34 ---------- packages/ui/src/lib/runtimeSurface.ts | 68 ++++++++++++++++--- packages/web/src/main.tsx | 6 +- 16 files changed, 67 insertions(+), 123 deletions(-) delete mode 100644 packages/ui/src/lib/mobileLayoutPreference.ts diff --git a/bun.lock b/bun.lock index 2b7a26c2..98e47d8f 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.19.0", + "version": "1.20.0", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.19.0", + "version": "1.20.0", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -238,7 +238,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.19.0", + "version": "1.20.0", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.21", @@ -261,7 +261,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.19.0", + "version": "1.20.0", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 8de1244c..589e98d2 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -32,7 +32,6 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS, import { useI18n, type Locale } from '@/lib/i18n'; import { useConfigStore } from '@/stores/useConfigStore'; import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; -import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference'; import { setDirectoryShowHidden, useDirectoryShowHidden, @@ -151,17 +150,6 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option[] = [ }, ]; -const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [ - { - value: 'default', - labelKey: 'settings.openchamber.visual.option.mobileLayout.default', - }, - { - value: 'new', - labelKey: 'settings.openchamber.visual.option.mobileLayout.new', - }, -]; - type PwaInstallNameWindow = Window & { __OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string; __OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape'; @@ -629,10 +617,9 @@ export const OpenChamberVisualSettings: React.FC const hasThemeSettings = shouldShow('theme') && !isVSCode; const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition; const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart'); - const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode; const hasAppearanceSettings = isVSCode ? hasLocalizationSettings - : (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); + : (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile); const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode); const hasBehaviorSettings = shouldShow('mermaidRendering') @@ -723,7 +710,6 @@ export const OpenChamberVisualSettings: React.FC ? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell] : terminalLoginShells.filter((shell) => shell !== terminalShell)); }; - const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState(() => getStoredMobileLayoutPreference()); const [pwaInstallName, setPwaInstallName] = React.useState(''); const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); const selectedTimeFormatLabel = React.useMemo(() => { @@ -743,16 +729,6 @@ export const OpenChamberVisualSettings: React.FC return option ? tUnsafe(option.labelKey) : undefined; }, [mobileKeyboardMode, tUnsafe]); - const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => { - if (value === mobileLayoutPreference) { - return; - } - - setMobileLayoutPreference(value); - setStoredMobileLayoutPreference(value); - window.location.reload(); - }, [mobileLayoutPreference]); - const applyPwaInstallName = React.useCallback(async (value: string) => { if (typeof window === 'undefined') { return; @@ -877,21 +853,6 @@ export const OpenChamberVisualSettings: React.FC ))} - {showMobileLayoutSetting && ( - - - ({ - value: option.value, - label: tUnsafe(option.labelKey), - }))} - onChange={handleMobileLayoutPreferenceChange} - aria-label={t('settings.openchamber.visual.section.mobileLayout')} - /> - - - )}
diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 6a3410d0..28f6c5ef 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1845,9 +1845,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeRaw': 'Rohes Markdown', 'settings.voice.page.field.ttsInputModeSummarized': 'zusammengefasst', 'settings.openchamber.visual.section.colorMode': 'Farbmodus', - 'settings.openchamber.visual.section.mobileLayout': 'Mobiles Layout', - 'settings.openchamber.visual.option.mobileLayout.default': 'Alt', - 'settings.openchamber.visual.option.mobileLayout.new': 'Neu', 'settings.openchamber.visual.section.localization': 'Lokalisierung', 'settings.openchamber.visual.section.spacingAndLayout': 'Abstand & Layout', 'settings.openchamber.visual.section.navigation': 'Navigation', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 525d76f3..fe98e79c 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1913,9 +1913,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': 'summarized', 'settings.openchamber.visual.section.colorMode': 'Color Mode', 'settings.openchamber.visual.section.colorModeAndTheme': 'Color mode & Theme', - 'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout', - 'settings.openchamber.visual.option.mobileLayout.default': 'Old', - 'settings.openchamber.visual.option.mobileLayout.new': 'New', 'settings.openchamber.visual.section.localization': 'Localization', 'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout', 'settings.openchamber.visual.section.densityAndType': 'Density & type', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b841e8da..ee7e3163 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de color", "settings.openchamber.visual.section.colorModeAndTheme": "Modo de color y tema", - "settings.openchamber.visual.section.mobileLayout": "Diseño móvil", - "settings.openchamber.visual.option.mobileLayout.default": "Anterior", - "settings.openchamber.visual.option.mobileLayout.new": "Nuevo", "settings.openchamber.visual.section.localization": "Localización", "settings.openchamber.visual.section.spacingAndLayout": "Espaciado y diseño", "settings.openchamber.visual.section.densityAndType": "Densidad y tipografía", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 88d46f33..9ad25052 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -2147,9 +2147,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé', 'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut', 'settings.voice.page.field.ttsInputModeSummarized': 'résumé', - 'settings.openchamber.visual.section.mobileLayout': 'Mise en page mobile', - 'settings.openchamber.visual.option.mobileLayout.default': 'Ancienne', - 'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle', 'settings.openchamber.visual.field.dockBadge': 'Badge du Dock', 'settings.openchamber.visual.field.dockBadgeHint': 'Afficher sur l’icône du Dock de macOS le nombre de discussions avec une activité non vue.', 'settings.openchamber.visual.actions.saveAndRestart': 'Enregistrer et redémarrer', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index f4070831..20454d38 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1923,9 +1923,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': '要約', 'settings.openchamber.visual.section.colorMode': 'カラーモード', 'settings.openchamber.visual.section.colorModeAndTheme': 'カラーモードとテーマ', - 'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト', - 'settings.openchamber.visual.option.mobileLayout.default': '旧', - 'settings.openchamber.visual.option.mobileLayout.new': '新', 'settings.openchamber.visual.section.localization': 'ローカライゼーション', 'settings.openchamber.visual.section.spacingAndLayout': '間隔とレイアウト', 'settings.openchamber.visual.section.densityAndType': '密度と書体', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index e4e2ef9e..d97dc80a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': '요약', 'settings.openchamber.visual.section.colorMode': '색상 모드', 'settings.openchamber.visual.section.colorModeAndTheme': '색상 모드 및 테마', - 'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃', - 'settings.openchamber.visual.option.mobileLayout.default': '이전', - 'settings.openchamber.visual.option.mobileLayout.new': '새로움', 'settings.openchamber.visual.section.localization': '지역화', 'settings.openchamber.visual.section.spacingAndLayout': '간격 및 레이아웃', 'settings.openchamber.visual.section.densityAndType': '밀도 및 서체', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 7bebca44..a84587d8 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1196,9 +1196,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.chatFeatures': 'Funkcje', 'settings.openchamber.visual.section.colorMode': 'Tryb kolorów', 'settings.openchamber.visual.section.colorModeAndTheme': 'Tryb kolorów i motyw', - 'settings.openchamber.visual.section.mobileLayout': 'Układ mobilny', - 'settings.openchamber.visual.option.mobileLayout.default': 'Poprzedni', - 'settings.openchamber.visual.option.mobileLayout.new': 'Nowy', 'settings.openchamber.visual.section.diffLayout': 'Układ diffa', 'settings.openchamber.visual.section.diffLayoutAria': 'Układ diffa', 'settings.openchamber.visual.section.localization': 'Lokalizacja', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index ce24a8d0..3a515bfd 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de cor", "settings.openchamber.visual.section.colorModeAndTheme": "Modo de cor e tema", - "settings.openchamber.visual.section.mobileLayout": "Layout móvel", - "settings.openchamber.visual.option.mobileLayout.default": "Anterior", - "settings.openchamber.visual.option.mobileLayout.new": "Novo", "settings.openchamber.visual.section.localization": "Localização", "settings.openchamber.visual.section.spacingAndLayout": "Espaçamento e layout", "settings.openchamber.visual.section.densityAndType": "Densidade e tipografia", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 2d7dcb16..c7ac4bbc 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { "settings.voice.page.field.ttsInputModeSummarized": "скорочений", "settings.openchamber.visual.section.colorMode": "Режим теми", "settings.openchamber.visual.section.colorModeAndTheme": "Режим кольору та тема", - "settings.openchamber.visual.section.mobileLayout": "Мобільний макет", - "settings.openchamber.visual.option.mobileLayout.default": "Попередній", - "settings.openchamber.visual.option.mobileLayout.new": "Новий", "settings.openchamber.visual.section.localization": "Локалізація", "settings.openchamber.visual.section.spacingAndLayout": "Відступи й компонування", "settings.openchamber.visual.section.densityAndType": "Щільність і шрифти", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index d648d30e..6b0f45cb 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.openchamber.visual.section.colorMode': '颜色模式', 'settings.openchamber.visual.section.colorModeAndTheme': '颜色模式与主题', - 'settings.openchamber.visual.section.mobileLayout': '移动端布局', - 'settings.openchamber.visual.option.mobileLayout.default': '旧版', - 'settings.openchamber.visual.option.mobileLayout.new': '新版', 'settings.openchamber.visual.section.localization': '本地化', 'settings.openchamber.visual.section.spacingAndLayout': '间距与布局', 'settings.openchamber.visual.section.densityAndType': '密度与字体', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 28e71162..fcf9dae2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1801,7 +1801,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局', 'settings.openchamber.visual.section.densityAndType': '密度與字型', 'settings.openchamber.visual.section.appInstall': '應用程式安裝', - 'settings.openchamber.visual.section.mobileLayout': '行動版版面', 'settings.openchamber.visual.section.navigation': '導覽', 'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式', 'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式', @@ -1855,8 +1854,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.mobileKeyboardModeAria': '行動裝置鍵盤行為', 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': '選擇鍵盤行為', 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': '重設行動裝置鍵盤行為', - 'settings.openchamber.visual.option.mobileLayout.default': '舊版', - 'settings.openchamber.visual.option.mobileLayout.new': '新版', 'settings.openchamber.visual.field.interfaceFontSize': '介面字體大小', 'settings.openchamber.visual.field.interfaceFont': '介面字體', 'settings.openchamber.visual.field.selectInterfaceFontAria': '選擇介面字體', diff --git a/packages/ui/src/lib/mobileLayoutPreference.ts b/packages/ui/src/lib/mobileLayoutPreference.ts deleted file mode 100644 index 10050bf2..00000000 --- a/packages/ui/src/lib/mobileLayoutPreference.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type MobileLayoutPreference = 'default' | 'new'; - -const MOBILE_LAYOUT_PREFERENCE_KEY = 'openchamber-mobile-layout'; - -const normalizeMobileLayoutPreference = (value: unknown): MobileLayoutPreference => { - // 'new' is the default; only an explicit 'default' (the legacy/"Old" layout) - // opts out of it. - return value === 'default' ? 'default' : 'new'; -}; - -export const getStoredMobileLayoutPreference = (): MobileLayoutPreference => { - if (typeof window === 'undefined') { - return 'new'; - } - - try { - return normalizeMobileLayoutPreference(window.localStorage.getItem(MOBILE_LAYOUT_PREFERENCE_KEY)); - } catch { - return 'new'; - } -}; - -export const setStoredMobileLayoutPreference = (value: MobileLayoutPreference): boolean => { - if (typeof window === 'undefined') { - return false; - } - - try { - window.localStorage.setItem(MOBILE_LAYOUT_PREFERENCE_KEY, value); - return true; - } catch { - return false; - } -}; diff --git a/packages/ui/src/lib/runtimeSurface.ts b/packages/ui/src/lib/runtimeSurface.ts index dddeaa1d..786a1090 100644 --- a/packages/ui/src/lib/runtimeSurface.ts +++ b/packages/ui/src/lib/runtimeSurface.ts @@ -1,6 +1,5 @@ import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isCapacitorApp } from '@/lib/platform'; -import { getStoredMobileLayoutPreference } from '@/lib/mobileLayoutPreference'; export type HostedSurface = 'desktop' | 'mobile'; @@ -11,6 +10,7 @@ declare global { } const MOBILE_SURFACE_MAX_WIDTH = 768; +const SURFACE_SWITCH_DEBOUNCE_MS = 800; const isTouchOrCoarsePointer = (): boolean => { if (typeof window === 'undefined') return false; @@ -22,12 +22,29 @@ const isTouchOrCoarsePointer = (): boolean => { return coarsePointer || touchPoints > 0; }; +const hasSurfaceUrlOverride = (): boolean => { + if (typeof window === 'undefined') return false; + const override = new URLSearchParams(window.location.search).get('surface'); + return override === 'mobile' || override === 'desktop'; +}; + +/** Viewport half of the surface decision; re-evaluated on resize by the watcher. */ +const isPhoneViewport = (): boolean => { + if (typeof window === 'undefined') return false; + const width = Math.min( + window.innerWidth || Number.POSITIVE_INFINITY, + window.screen?.width || Number.POSITIVE_INFINITY, + ); + return Number.isFinite(width) + && width <= MOBILE_SURFACE_MAX_WIDTH + && isTouchOrCoarsePointer(); +}; + /** * Single authority for the mobile-vs-desktop surface decision. * * Priority: explicit stamp (set once at boot) → URL override → Capacitor - * shell (always the mobile surface) → desktop shells → phone heuristic - * gated by the stored mobile layout preference. + * shell (always the mobile surface) → desktop shells → phone heuristic. */ const detectHostedSurface = (): HostedSurface => { if (typeof window === 'undefined') return 'desktop'; @@ -45,14 +62,7 @@ const detectHostedSurface = (): HostedSurface => { if (isCapacitorApp()) return 'mobile'; if (isDesktopShell() || isVSCodeRuntime()) return 'desktop'; - const width = Math.min( - window.innerWidth || Number.POSITIVE_INFINITY, - window.screen?.width || Number.POSITIVE_INFINITY, - ); - const likelyPhone = Number.isFinite(width) - && width <= MOBILE_SURFACE_MAX_WIDTH - && isTouchOrCoarsePointer(); - return likelyPhone && getStoredMobileLayoutPreference() === 'new' ? 'mobile' : 'desktop'; + return isPhoneViewport() ? 'mobile' : 'desktop'; }; /** @@ -69,3 +79,39 @@ export const resolveHostedSurface = (): HostedSurface => { }; export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile'; + +/** + * The surface is stamped once at boot, so a browser window that crosses the + * phone threshold after load would otherwise keep the wrong app shell (the + * app trees, stores, and sync bootstrap differ, so an in-place switch is not + * safe). Watch for the viewport heuristic disagreeing with the stamp and + * reload — the same mechanism a surface change has always used — once the + * resize settles. Fixed shells never switch: Capacitor is always mobile, + * desktop/VS Code shells are always desktop, and an explicit ?surface= + * override wins over the heuristic. + */ +export const watchHostedSurfaceViewport = (): (() => void) => { + if (typeof window === 'undefined') return () => {}; + if (isCapacitorApp() || isDesktopShell() || isVSCodeRuntime()) return () => {}; + if (hasSurfaceUrlOverride()) return () => {}; + + let timeout: ReturnType | null = null; + const handleResize = () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + const stamped = window.__OPENCHAMBER_SURFACE__; + const desired: HostedSurface = isPhoneViewport() ? 'mobile' : 'desktop'; + if (stamped && stamped !== desired) { + window.__OPENCHAMBER_SURFACE__ = undefined; + window.location.reload(); + } + }, SURFACE_SWITCH_DEBOUNCE_MS); + }; + + window.addEventListener('resize', handleResize); + return () => { + if (timeout) clearTimeout(timeout); + window.removeEventListener('resize', handleResize); + }; +}; diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 83e06823..4dd0d7a9 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,7 +2,7 @@ import { createConfiguredWebAPIs, getDesktopRelayRestoreReady } from './runtimeC import { registerSW } from 'virtual:pwa-register'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; -import { resolveHostedSurface, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface'; +import { resolveHostedSurface, watchHostedSurfaceViewport, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface'; import { isEmbeddedSessionChat, requestEmbeddedSessionRuntimeBootstrap, @@ -90,6 +90,10 @@ const start = async (): Promise => { : null; window.__OPENCHAMBER_RUNTIME_APIS__ = createConfiguredWebAPIs(embeddedBootstrap); + // Reload into the other app shell when the viewport crosses the phone + // threshold after boot (no-op in fixed shells and with ?surface= overrides). + watchHostedSurfaceViewport(); + if (hostedSurface === 'mobile') { const { renderMobileApp } = await import('@openchamber/ui/apps/renderMobileApp'); renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__); From 8b7d7803de95933f5c0ab468e68f88fef4c4cef3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 16:15:16 +0300 Subject: [PATCH 12/66] refactor(layout): remove the legacy mobile layout from MainLayout and Header Phone viewports run the separate MobileApp shell (and a viewport crossing now reloads into it), so the desktop layout's mobile branch was unreachable: the drawer machinery, the full-screen secondaryView surface switch (including the terminal/diagram desktop carve-out nothing could trigger), the mobile header with its tab bar, the Cmd+number tab shortcuts, the mobile quota panel, and the surface guard that reset non-chat tabs. DrawerContext had no consumers left and is deleted. Header drops from 2630 to 1853 lines; the desktop render is unchanged. --- packages/ui/src/components/layout/Header.tsx | 793 +----------------- .../ui/src/components/layout/MainLayout.tsx | 464 ++-------- packages/ui/src/contexts/DrawerContext.tsx | 29 - 3 files changed, 78 insertions(+), 1208 deletions(-) delete mode 100644 packages/ui/src/contexts/DrawerContext.tsx diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index e6b4ddc0..e9cd8c4a 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -13,10 +13,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; -import { DiffIcon } from '@/components/icons/DiffIcon'; -import { useUIStore, type ContextPanelMode, type MainTab } from '@/stores/useUIStore'; +import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; @@ -39,33 +37,17 @@ import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; -import { cn, hasModifier } from '@/lib/utils'; -import { McpDropdownContent } from '@/components/mcp/McpDropdown'; -import { McpIcon } from '@/components/icons/McpIcon'; -import { ProviderLogo } from '@/components/ui/ProviderLogo'; -import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota'; -import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; -import { updateDesktopSettings } from '@/lib/persistence'; -import { formatTimeForPreference } from '@/lib/timeFormat'; +import { cn } from '@/lib/utils'; import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { - getAllModelFamilies, - getDisplayModelName, - groupModelsByFamily, - sortModelFamilies, } from '@/lib/quota/model-families'; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, } from '@/components/ui/collapsible'; -import type { UsageWindow } from '@/types'; import type { GitHubAuthStatus } from '@/lib/api/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; -import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop'; import { desktopHostsGet, redactSensitiveUrl } from '@/lib/desktopHosts'; @@ -90,7 +72,6 @@ import { Button } from '@/components/ui/button'; import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors'; -const MOBILE_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors'; type HeaderIconActionButtonProps = { visible?: boolean; @@ -416,14 +397,6 @@ const formatCompactHeaderLabel = (value: string): string => { return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed; }; -const formatTime = (timestamp: number | null, timeFormatPreference: 'auto' | '12h' | '24h') => { - if (!timestamp) return '-'; - try { - return formatTimeForPreference(timestamp, timeFormatPreference, { fallback: '-' }); - } catch { - return '-'; - } -}; const normalize = (value: string): string => { if (!value) return ''; @@ -444,32 +417,6 @@ const getActiveContextMode = (panelState: { return activeTab?.mode ?? null; }; -interface TabConfig { - id: MainTab; - label: string; - icon: IconName | 'diff'; - badge?: number; - showDot?: boolean; -} - -interface RateLimitGroup { - providerId: string; - providerName: string; - entries: Array<[string, UsageWindow]>; - error?: string; - modelFamilies?: Array<{ - familyId: string | null; - familyLabel: string; - models: Array<[string, UsageWindow]>; - }>; -} - -interface HeaderProps { - onToggleLeftDrawer?: () => void; - onToggleRightDrawer?: () => void; - leftDrawerOpen?: boolean; - rightDrawerOpen?: boolean; -} type HeaderSessionSnapshot = { title: string | null; @@ -480,24 +427,15 @@ type HeaderSessionSnapshot = { parentId: string | null; }; -export const Header: React.FC = ({ - onToggleLeftDrawer, - onToggleRightDrawer, - leftDrawerOpen, - rightDrawerOpen, -}) => { +export const Header: React.FC = () => { streamPerfCount('ui.header.render'); const { t } = useI18n(); - const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); - const toggleSidebar = useUIStore((state) => state.toggleSidebar); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const openContextOverview = useUIStore((state) => state.openContextOverview); const openContextPlan = useUIStore((state) => state.openContextPlan); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const activeMainTab = useUIStore((state) => state.activeMainTab); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const runtimeApis = useRuntimeAPIs(); @@ -548,12 +486,7 @@ export const Header: React.FC = ({ }, [activeProject]); const quotaResults = useQuotaStore((state) => state.results); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); - const isQuotaLoading = useQuotaStore((state) => state.isLoading); - const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated); - const quotaDisplayMode = useQuotaStore((state) => state.displayMode); - const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); - const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode); const { isMobile } = useDeviceInfo(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); @@ -639,14 +572,11 @@ export const Header: React.FC = ({ } }, [contextUsage, currentSessionId, isContextUsageResolvedForSession]); - const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null; const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null; const githubAccounts = githubAuthStatus?.accounts ?? []; const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false); - const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false); const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false); - const [isUsageRefreshSpinning, setIsUsageRefreshSpinning] = React.useState(false); const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local'); const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true); const [remoteUpdateDialogOpen, setRemoteUpdateDialogOpen] = React.useState(false); @@ -654,7 +584,6 @@ export const Header: React.FC = ({ const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false); const [remoteUpdateError, setRemoteUpdateError] = React.useState(null); const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]); - const [mobileServicesTab, setMobileServicesTab] = React.useState<'usage' | 'mcp'>('usage'); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); // While the work-status panel is on screen it already reports the project, // the branch and the context fill — three paces away in the same window. @@ -817,126 +746,11 @@ export const Header: React.FC = ({ }, [checkRemoteInstanceUpdate, remoteUpdateInfo?.available]); useQuotaAutoRefresh(); - const selectedModels = useQuotaStore((state) => state.selectedModels); - const expandedFamilies = useQuotaStore((state) => state.expandedFamilies); - const toggleFamilyExpanded = useQuotaStore((state) => state.toggleFamilyExpanded); - const rateLimitGroups = React.useMemo(() => { - const groups: RateLimitGroup[] = []; - - for (const provider of QUOTA_PROVIDERS) { - if (!dropdownProviderIds.includes(provider.id)) { - continue; - } - const result = quotaResults.find((entry) => entry.providerId === provider.id); - const windows = (result?.usage?.windows ?? {}) as Record; - const models = result?.usage?.models; - const entries = Object.entries(windows); - - const group: RateLimitGroup = { - providerId: provider.id, - providerName: provider.name, - entries, - error: (result && !result.ok && result.configured) ? result.error : undefined, - }; - - // Add model families if provider has per-model quotas - if (models && Object.keys(models).length > 0) { - const providerSelectedModels = selectedModels[provider.id] ?? []; - // hasExplicitSelection = true means user has selected specific models to show - // If the array exists but is empty, treat as "show all" (user cleared selection) - const hasExplicitSelection = providerSelectedModels.length > 0; - const modelGroups = groupModelsByFamily(models, provider.id); - const families = getAllModelFamilies(provider.id); - const sortedFamilies = sortModelFamilies(families); - - group.modelFamilies = []; - - // Add predefined families first - for (const family of sortedFamilies) { - const modelNames = modelGroups.get(family.id) ?? []; - if (modelNames.length === 0) continue; - - // Filter to selected models only, OR show all if nothing selected - const selectedModelNames = hasExplicitSelection - ? modelNames.filter((m: string) => providerSelectedModels.includes(m)) - : modelNames; - if (selectedModelNames.length === 0) continue; - - const familyModels: Array<[string, UsageWindow]> = []; - for (const modelName of selectedModelNames) { - const modelUsage = models[modelName] as { windows?: Record } | undefined; - if (modelUsage?.windows) { - const windowEntries = Object.entries(modelUsage.windows); - if (windowEntries.length > 0) { - familyModels.push([modelName, windowEntries[0][1]]); - } - } - } - - if (familyModels.length > 0) { - group.modelFamilies.push({ - familyId: family.id, - familyLabel: family.label, - models: familyModels, - }); - } - } - - // Add "Other" family for remaining models - const otherModelNames = modelGroups.get(null) ?? []; - const selectedOtherModels = hasExplicitSelection - ? otherModelNames.filter((m: string) => providerSelectedModels.includes(m)) - : otherModelNames; - if (selectedOtherModels.length > 0) { - const otherModels: Array<[string, UsageWindow]> = []; - for (const modelName of selectedOtherModels) { - const modelUsage = models[modelName] as { windows?: Record } | undefined; - if (modelUsage?.windows) { - const windowEntries = Object.entries(modelUsage.windows); - if (windowEntries.length > 0) { - otherModels.push([modelName, windowEntries[0][1]]); - } - } - } - if (otherModels.length > 0) { - group.modelFamilies.push({ - familyId: null, - familyLabel: t('header.services.modelFamily.other'), - models: otherModels, - }); - } - } - } - - if (entries.length > 0 || (group.modelFamilies && group.modelFamilies.length > 0) || group.error) { - groups.push(group); - } - } - - return groups; - }, [dropdownProviderIds, quotaResults, selectedModels, t]); - const hasRateLimits = rateLimitGroups.length > 0; React.useEffect(() => { void loadQuotaSettings(); }, [loadQuotaSettings]); - const handleDisplayModeChange = React.useCallback(async (mode: 'usage' | 'remaining') => { - setQuotaDisplayMode(mode); - try { - await updateDesktopSettings({ usageDisplayMode: mode }); - } catch (error) { - console.warn('Failed to update usage display mode:', error); - } - }, [setQuotaDisplayMode]); - const handleUsageRefresh = React.useCallback(() => { - if (isUsageRefreshSpinning) return; - setIsUsageRefreshSpinning(true); - const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500)); - Promise.all([fetchAllQuotas(), minSpinPromise]).finally(() => { - setIsUsageRefreshSpinning(false); - }); - }, [fetchAllQuotas, isUsageRefreshSpinning]); const currentSessionSnapshot = currentSessionId ? currentGlobalSession ?? null @@ -1330,17 +1144,10 @@ export const Header: React.FC = ({ }; }, [actionDirectory, activeProjectRef]); - const projectActionsContext = React.useMemo(() => { - if (activeProjectRef && actionDirectory) { - return { projectRef: activeProjectRef, directory: actionDirectory }; - } - return lastProjectActionsContextRef.current; - }, [actionDirectory, activeProjectRef]); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const isSessionPlanAvailable = useSessionUIStore((state) => state.isSessionPlanAvailable); const planTabAvailable = planModeEnabled && currentSessionId ? isSessionPlanAvailable(currentSessionId) : false; - const showPlanTab = planTabAvailable; const lastPlanSessionKeyRef = React.useRef(''); // Reset plan tab availability when session changes @@ -1404,32 +1211,7 @@ export const Header: React.FC = ({ } }, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]); - const blurActiveElement = React.useCallback(() => { - if (typeof document === 'undefined') { - return; - } - const active = document.activeElement as HTMLElement | null; - if (!active) { - return; - } - - const tagName = active.tagName; - const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; - - if (isInput || active.isContentEditable) { - active.blur(); - } - }, []); - - const handleOpenSessionSwitcher = React.useCallback(() => { - if (isMobile) { - blurActiveElement(); - setSessionSwitcherOpen(!isSessionSwitcherOpen); - return; - } - toggleSidebar(); - }, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]); const handleOpenDraftMiniChat = React.useCallback(() => { void invokeDesktop('desktop_open_draft_mini_chat_window', { @@ -1496,47 +1278,6 @@ export const Header: React.FC = ({ const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS; - const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS; - const mobileActiveHeaderItem = React.useMemo(() => { - if (isMobileRateLimitsOpen) { - return 'services'; - } - if (leftDrawerOpen) { - return 'sessions'; - } - if (rightDrawerOpen) { - return 'git'; - } - return activeMainTab; - }, [activeMainTab, isMobileRateLimitsOpen, leftDrawerOpen, rightDrawerOpen]); - - const closeMobileHeaderPanels = React.useCallback(() => { - setIsMobileRateLimitsOpen(false); - if (leftDrawerOpen && onToggleLeftDrawer) { - onToggleLeftDrawer(); - } - if (rightDrawerOpen && onToggleRightDrawer) { - onToggleRightDrawer(); - } - if (!onToggleLeftDrawer && isSessionSwitcherOpen) { - setSessionSwitcherOpen(false); - } - }, [isSessionSwitcherOpen, leftDrawerOpen, onToggleLeftDrawer, onToggleRightDrawer, rightDrawerOpen, setSessionSwitcherOpen]); - - const handleMobileLeftDrawerToggle = React.useCallback(() => { - if (!leftDrawerOpen) { - setIsMobileRateLimitsOpen(false); - } - onToggleLeftDrawer?.(); - }, [leftDrawerOpen, onToggleLeftDrawer]); - - const handleMobileRightDrawerToggle = React.useCallback(() => { - if (!rightDrawerOpen) { - setIsMobileRateLimitsOpen(false); - } - onToggleRightDrawer?.(); - }, [onToggleRightDrawer, rightDrawerOpen]); - // Left padding the header needs to clear the OS window controls (macOS // traffic lights / window-controls-overlay). When the sidebar is open this // space is owned by the sidebar's top strip instead, so the header drops back @@ -1701,44 +1442,10 @@ export const Header: React.FC = ({ } }, [isDesktopApp]); - const tabs: TabConfig[] = React.useMemo(() => { - if (isMobile) { - const base: TabConfig[] = [ - { id: 'chat', label: t('layout.mainTab.chat'), icon: "chat-4" }, - ]; - - if (showPlanTab) { - base.push({ id: 'plan', label: t('layout.mainTab.plan'), icon: "file-text" }); - } - - base.push( - { id: 'diff', label: t('layout.mainTab.diff'), icon: 'diff' }, - { id: 'files', label: t('layout.mainTab.files'), icon: "folder-6" }, - { id: 'terminal', label: t('layout.mainTab.terminal'), icon: "terminal-box" }, - { id: 'context', label: t('layout.mainTab.context'), icon: "file-list-2" }, - { id: 'diagram', label: t('layout.mainTab.diagram'), icon: 'file' }, - ); - - return base; - } - - // Desktop: no tabs in header - return []; - }, [isMobile, showPlanTab, t]); - const shortcutLabel = React.useCallback((actionId: string) => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); }, [shortcutOverrides]); - useEffect(() => { - // Project actions may intentionally promote the terminal to the desktop - // main view, and diagram clicks open the diagram viewer; every other - // legacy main tab now lives in the context panel on desktop. - if (!isMobile && activeMainTab !== 'chat' && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') { - setActiveMainTab('chat'); - } - }, [activeMainTab, isMobile, setActiveMainTab]); - // Desktop keeps instances only: quota and MCP now live in the work-status // panel, which reports them per session rather than per window. The mobile // menu below is untouched — it has no panel to defer to. @@ -1751,31 +1458,6 @@ export const Header: React.FC = ({ }, [isDesktopApp, t]); - const mobileServicesTabItems = React.useMemo(() => { - return [ - { id: 'usage', label: t('layout.services.usage'), icon: }, - { id: 'mcp', label: 'MCP', icon: }, - ]; - }, [t]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (hasModifier(e) && !e.shiftKey && !e.altKey) { - const num = parseInt(e.key, 10); - if (num >= 1 && num <= tabs.length) { - e.preventDefault(); - if (isMobile) { - blurActiveElement(); - closeMobileHeaderPanels(); - } - setActiveMainTab(tabs[num - 1].id); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [blurActiveElement, closeMobileHeaderPanels, isMobile, setActiveMainTab, tabs]); - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides); @@ -1822,55 +1504,6 @@ export const Header: React.FC = ({ handleOpenContextPlan, ]); - const renderTab = (tab: TabConfig) => { - const isActive = activeMainTab === tab.id; - const isDiffTab = tab.icon === 'diff'; - const tabIconName = isDiffTab ? null : (tab.icon as IconName); - const isChatTab = tab.id === 'chat'; - - const renderIcon = (iconSize: number) => { - if (isDiffTab) { - return ; - } - return tabIconName ? : null; - }; - - const tabButton = ( - - ); - - return {tabButton}; - }; - const desktopSidebarActions = ( <> @@ -2097,12 +1730,6 @@ export const Header: React.FC = ({
)} - {tabs.length > 0 && ( -
- {tabs.map((tab) => renderTab(tab))} -
- )} -
@@ -2173,414 +1800,10 @@ export const Header: React.FC = ({
); - const renderMobile = () => ( -
-
- {/* Use drawer toggle when onToggleLeftDrawer is provided, otherwise use legacy session switcher */} - {onToggleLeftDrawer ? ( - - ) : isSessionSwitcherOpen ? ( - - ) : ( - - )} - - {!onToggleLeftDrawer && isSessionSwitcherOpen && ( - {t('header.sessions.title')} - )} -
- - {(!isSessionSwitcherOpen || Boolean(onToggleLeftDrawer)) && ( - <> -
-
-
-
- {tabs.map((tab) => { - const isActive = activeMainTab === tab.id; - const isDiffTab = tab.icon === 'diff'; - const tabIconName = isDiffTab ? null : (tab.icon as IconName); - return ( - - - - - -

{tab.label}

-
-
- ); - })} -
-
-
-
- -
- {projectActionsContext && ( - - )} - - {/* Mobile Services Menu (Usage + MCP) */} - { - if (open) { - if (leftDrawerOpen && onToggleLeftDrawer) { - onToggleLeftDrawer(); - } - if (rightDrawerOpen && onToggleRightDrawer) { - onToggleRightDrawer(); - } - } - setIsMobileRateLimitsOpen(open); - if (open && quotaResults.length === 0) { - fetchAllQuotas(); - } - }} - > - - - - - - - -

{t('header.services.title')}

-
-
- -
-
-
-
- { - const value = tabID as 'usage' | 'mcp'; - setMobileServicesTab(value); - if (value === 'usage' && quotaResults.length === 0) { - fetchAllQuotas(); - } - }} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - activePillButtonClassName="h-8" - className="h-full" - /> -
- -
-
- - {mobileServicesTab === 'mcp' && ( - - )} - - {mobileServicesTab === 'usage' && ( -
- {/* Mobile usage header */} -
-
-
- {t('header.services.rateLimits')} - - {formatTime(quotaLastUpdated, timeFormatPreference)} - -
-
-
- - · - -
- -
-
-
- - {!hasRateLimits && ( -
- {t('header.services.noRateLimits')} -
- )} - - {/* Mobile provider groups */} -
- {rateLimitGroups.map((group, index) => ( - - {index > 0 ? ( -
- ) : null} - - {/* Provider header */} -
- - {group.providerName} -
- - {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( -
- - {group.error ?? t('header.services.noRateLimitsReported')} - -
- ) : ( -
- {/* Window-level entries */} - {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference); - return ( -
-
-
- {formatWindowLabel(label)} - {resetLabel ? ( - - {resetLabel} - - ) : null} -
- - {metricLabel === '-' ? '' : metricLabel} - -
- -
- ); - })} - - {/* Model family collapsibles */} - {group.modelFamilies && group.modelFamilies.length > 0 && ( -
- {group.modelFamilies.map((family) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); - - return ( - toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} - > - - - {family.familyLabel} - - {isExpanded ? ( - - ) : ( - - )} - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - return ( -
-
- {getDisplayModelName(modelName)} - - {metricLabel === '-' ? '' : metricLabel} - -
- -
- ); - })} -
-
-
- ); - })} -
- )} -
- )} - - ))} -
-
- )} -
- - - - {onToggleRightDrawer ? ( - - - - - -

{rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}

-
-
- ) : null} -
- - )} -
- ); - - const headerClassName = cn( - 'header-safe-area relative z-10 bg-background', - // Mobile keeps a full-width divider. On desktop the divider lives on the chat - // content wrapper instead, so it doesn't run between the header and the right - // sidebar (they read as one continuous surface). - isMobile && 'border-b border-border/50' - ); + // The divider lives on the chat content wrapper instead of the header, so it + // doesn't run between the header and the right sidebar (they read as one + // continuous surface). + const headerClassName = 'header-safe-area relative z-10 bg-background'; return ( <> @@ -2589,7 +1812,7 @@ export const Header: React.FC = ({ className={headerClassName} style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties} > - {isMobile ? renderMobile() : renderDesktop()} + {renderDesktop()} { if (!open) setPendingHeaderRetentionAction(null); }}> diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 1a25e06c..094a69c6 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -1,10 +1,8 @@ -import React, { useRef, useEffect } from 'react'; -import { animate, motion, useMotionValue } from 'motion/react'; +import React from 'react'; import { Header } from './Header'; import { Sidebar } from './Sidebar'; import { SidebarTopBar } from './SidebarTopBar'; import { TitlebarLeftControls } from './TitlebarLeftControls'; -import { ProjectContextPanel } from './RightSidebarTabs'; import { ContextPanel } from './ContextPanel'; import { ContextPanelRail } from './ContextPanelRail'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -18,8 +16,6 @@ import { ArchiveView } from '@/components/views/ArchiveView'; import { WorktreesView } from '@/components/views/WorktreesView'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { MultiRunLauncher } from '@/components/multirun'; -import { TerminalView } from '@/components/views/TerminalView'; -import { DrawerProvider } from '@/contexts/DrawerContext'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -30,24 +26,18 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { ChatView } from '@/components/views/ChatView'; -// Keep TerminalView eager: the bottom dock reserves its height immediately, so -// suspending here leaves a large blank panel on slower machines. -// Other heavy views stay on-demand to reduce initial bundle parse time: -// DiffView/FilesView pull the CodeMirror and @pierre/diffs stacks into the -// startup graph when imported statically. -const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView }))); -const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView }))); -const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView }))); -const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView }))); -const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView }))); -const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); +/** + * Desktop-surface layout: the chat owns the main area, and every other + * surface (git, diff, files, terminal, ...) opens in the ContextPanel via the + * rail. Phone-sized viewports run the separate MobileApp shell — a viewport + * crossing the threshold reloads into it (see watchHostedSurfaceViewport). + */ export const MainLayout: React.FC = () => { const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const activeSurface = useUIStore((state) => state.activeSurface); const setIsMobile = useUIStore((state) => state.setIsMobile); - const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); // Mount the windowed settings dialog only after its first open: rendering @@ -67,10 +57,9 @@ export const MainLayout: React.FC = () => { const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen); const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen); const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId); - // Any full-page surface replacing the chat area. While open, the chat and - // secondary views are fully hidden (not just covered) so none of their - // floating chrome bleeds through, and selecting a session / draft / main - // tab anywhere closes the surface. + // Any full-page surface replacing the chat area. While open, the chat is + // fully hidden (not just covered) so none of its floating chrome bleeds + // through, and selecting a session or draft anywhere closes the surface. const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen; React.useEffect(() => { @@ -91,157 +80,6 @@ export const MainLayout: React.FC = () => { }; }, []); const { isMobile } = useDeviceInfo(); - const mobilePanelsResetRef = React.useRef(false); - - // Mobile drawer state - const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false); - const [mobileRightSidebarOpen, setMobileRightSidebarOpen] = React.useState(false); - const [mobileLeftDrawerVisible, setMobileLeftDrawerVisible] = React.useState(false); - const [mobileRightDrawerVisible, setMobileRightDrawerVisible] = React.useState(false); - const setMobileSessionPanelOpen = React.useCallback((open: boolean) => { - setMobileLeftDrawerOpen(open); - useUIStore.getState().setSessionSwitcherOpen(open); - }, []); - const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth); - - // Left drawer motion value - const leftDrawerX = useMotionValue(-initialDrawerWidthRef.current); - const leftDrawerWidth = useRef(0); - - // Right drawer motion value - const rightDrawerX = useMotionValue(initialDrawerWidthRef.current); - const rightDrawerWidth = useRef(0); - - // Compute drawer width - useEffect(() => { - if (isMobile) { - leftDrawerWidth.current = window.innerWidth; - rightDrawerWidth.current = window.innerWidth; - } - }, [isMobile]); - - // Sync left drawer state and motion value - useEffect(() => { - if (!isMobile) { - setMobileLeftDrawerVisible(false); - return; - } - if (mobileLeftDrawerOpen) { - setMobileLeftDrawerVisible(true); - } - animate(leftDrawerX, mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - }, [mobileLeftDrawerOpen, isMobile, leftDrawerX]); - - // Sync right drawer state and motion value - useEffect(() => { - if (!isMobile) { - setMobileRightDrawerVisible(false); - return; - } - if (mobileRightSidebarOpen) { - setMobileRightDrawerVisible(true); - } - animate(rightDrawerX, mobileRightSidebarOpen ? 0 : rightDrawerWidth.current, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - }, [isMobile, mobileRightSidebarOpen, rightDrawerX]); - - useEffect(() => { - if (!isMobile) return; - return leftDrawerX.on('change', (value) => { - const width = leftDrawerWidth.current || initialDrawerWidthRef.current; - const visible = mobileLeftDrawerOpen || value > -width + 0.5; - setMobileLeftDrawerVisible((previous) => previous === visible ? previous : visible); - }); - }, [isMobile, leftDrawerX, mobileLeftDrawerOpen]); - - useEffect(() => { - if (!isMobile) return; - return rightDrawerX.on('change', (value) => { - const width = rightDrawerWidth.current || initialDrawerWidthRef.current; - const visible = mobileRightSidebarOpen || value < width - 0.5; - setMobileRightDrawerVisible((previous) => previous === visible ? previous : visible); - }); - }, [isMobile, mobileRightSidebarOpen, rightDrawerX]); - - // Sync session switcher close events to left drawer. - useEffect(() => { - if (isMobile && !isSessionSwitcherOpen && mobileLeftDrawerOpen) { - setMobileSessionPanelOpen(false); - } - }, [isSessionSwitcherOpen, isMobile, mobileLeftDrawerOpen, setMobileSessionPanelOpen]); - - useEffect(() => { - if (!isMobile) { - mobilePanelsResetRef.current = false; - return; - } - - if (mobilePanelsResetRef.current) { - return; - } - - mobilePanelsResetRef.current = true; - setMobileSessionPanelOpen(false); - setMobileRightSidebarOpen(false); - }, [isMobile, setMobileSessionPanelOpen]); - - useEffect(() => { - if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) { - return; - } - - let disposed = false; - let timeoutId: number | undefined; - - const scheduleDraftOpen = (delayMs: number) => { - timeoutId = window.setTimeout(() => { - if (disposed) { - return; - } - - const sessionState = useSessionUIStore.getState(); - const uiState = useUIStore.getState(); - if (uiState.activeMainTab !== 'chat' || uiState.isSettingsDialogOpen || sessionState.currentSessionId || sessionState.newSessionDraft?.open) { - return; - } - - if (sessionState.isLoading) { - scheduleDraftOpen(250); - return; - } - - sessionState.openNewSessionDraft({ automatic: true }); - }, delayMs); - }; - - scheduleDraftOpen(500); - - return () => { - disposed = true; - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - } - }; - }, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]); - - // Ensure mobile drawers are closed when opening full-screen settings - useEffect(() => { - if (!isMobile || !isSettingsDialogOpen) { - return; - } - - setMobileSessionPanelOpen(false); - setMobileRightSidebarOpen(false); - }, [isMobile, isSettingsDialogOpen, setMobileSessionPanelOpen]); useUpdatePolling(); @@ -252,247 +90,85 @@ export const MainLayout: React.FC = () => { } }, [isMobile, setIsMobile]); - const handleToggleMobileRightDrawer = React.useCallback(() => { - if (mobileLeftDrawerOpen) { - setMobileSessionPanelOpen(false); - } - setMobileRightSidebarOpen(!mobileRightSidebarOpen); - }, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]); - - const secondaryView = React.useMemo(() => { - // Desktop surfaces live in the context panel; the only full-view - // overlays left there are the terminal (promoted by project actions) - // and the diagram viewer. Mobile keeps the full tab set. - if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') { - return null; - } - switch (activeSurface) { - case 'plan': - return ; - case 'git': - return ; - case 'diff': - return ; - case 'terminal': - return ; - case 'files': - return ; - case 'context': - return ; - case 'diagram': - return ; - default: - return null; - } - }, [activeSurface, isMobile, mobileRightSidebarOpen]); - const isChatActive = activeSurface === 'chat'; return (
- {isMobile ? ( - { - const nextOpen = !mobileLeftDrawerOpen; - if (mobileRightSidebarOpen) { - setMobileRightSidebarOpen(false); - } - setMobileSessionPanelOpen(nextOpen); - }, - toggleRightDrawer: handleToggleMobileRightDrawer, - leftDrawerX, - rightDrawerX, - leftDrawerWidth, - rightDrawerWidth, - setMobileLeftDrawerOpen: setMobileSessionPanelOpen, - setRightSidebarOpen: setMobileRightSidebarOpen, - }}> - {/* Mobile: header + drawer mode */} - {!isSettingsDialogOpen &&
{ - const nextOpen = !mobileLeftDrawerOpen; - if (mobileRightSidebarOpen) { - setMobileRightSidebarOpen(false); - } - setMobileSessionPanelOpen(nextOpen); - }} - onToggleRightDrawer={() => { - handleToggleMobileRightDrawer(); - }} - leftDrawerOpen={mobileLeftDrawerOpen} - rightDrawerOpen={mobileRightSidebarOpen} - />} - - {/* Main content area (fixed) */} -
+ {/* Full-height Sidebar beside [Header above (chat | RightSidebar)] */} +
+ } > -
-
- -
- {secondaryView && ( -
- {secondaryView} -
- )} - {isMultiRunLauncherOpen && ( -
- - setMultiRunLauncherOpen(false)} - onCancel={() => setMultiRunLauncherOpen(false)} - /> - -
- )} - - - - {/* Always mount SessionSidebar on mobile to match desktop behavior. - Conditional mount (mobileLeftDrawerVisible && ...) caused a - data-loading cascade on every drawer open: paginated sessions - fetch, worktree discovery, repo status, PR status, and 10+ memo - recomputations. On Android PWA this manifested as a >10s delay - before the drawer became interactive (issue #1695). Visibility is - controlled by the leftDrawerX transform (off-screen when closed). - The invisible class matters when fully hidden: leftDrawerWidth is - not recomputed on resize/rotation, so a closed drawer translated by - the old width could otherwise peek into the viewport; it also keeps - the off-screen sidebar out of the tab order and skips painting it. */} - - - - - - {mobileRightDrawerVisible && ( - - - - - - )} -
-
- - {/* Mobile settings: full screen */} - {isSettingsDialogOpen && ( -
- - - setSettingsDialogOpen(false)} /> - - -
- )} - - ) : ( - <> - {/* Persistent top-left controls (toggle + project actions) that - stay put while the sidebar/header animate beneath them. */} - - {/* Desktop: full-height Sidebar beside [Header above (chat | RightSidebar)] */} -
- } - > - - -
-
-
-
-
- {/* Holds the chat and the context panel together, so its - width does not move when the context panel opens. The - work-status panel measures this rather than the chat, - which the context panel animates. */} -
-
-
- + + +
+
+
+
+
+ {/* Holds the chat and the context panel together, so its + width does not move when the context panel opens. The + work-status panel measures this rather than the chat, + which the context panel animates. */} +
+
+
+ +
+ {isMultiRunLauncherOpen && ( +
+ + {/* isWindowed: the app Header already shows the surface + title, so skip the launcher's own title bar. */} + setMultiRunLauncherOpen(false)} + onCancel={() => setMultiRunLauncherOpen(false)} + /> +
- {secondaryView && ( -
- {secondaryView} -
- )} - {isMultiRunLauncherOpen && ( -
- - {/* isWindowed: the app Header already shows the surface - title, so skip the launcher's own title bar. */} - setMultiRunLauncherOpen(false)} - onCancel={() => setMultiRunLauncherOpen(false)} - /> - -
- )} - - - -
- -
+ )} + + + +
+
-
- -
+
+
+
+
- {/* Desktop settings: windowed dialog with blur */} - {settingsWindowMounted ? ( - - - - ) : null} - - )} - -
- + {/* Settings: windowed dialog with blur */} + {settingsWindowMounted ? ( + + + + ) : null} +
+
); }; diff --git a/packages/ui/src/contexts/DrawerContext.tsx b/packages/ui/src/contexts/DrawerContext.tsx deleted file mode 100644 index 3eafe4f3..00000000 --- a/packages/ui/src/contexts/DrawerContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import type { MotionValue } from 'motion/react'; - -export interface DrawerContextValue { - leftDrawerOpen: boolean; - rightDrawerOpen: boolean; - toggleLeftDrawer: () => void; - toggleRightDrawer: () => void; - // Motion values for real-time drawer dragging - leftDrawerX: MotionValue; - rightDrawerX: MotionValue; - leftDrawerWidth: React.MutableRefObject; - rightDrawerWidth: React.MutableRefObject; - setMobileLeftDrawerOpen: (open: boolean) => void; - setRightSidebarOpen: (open: boolean) => void; -} - -const DrawerContext = React.createContext(null); - -export const DrawerProvider: React.FC<{ - children: React.ReactNode; - value: DrawerContextValue; -}> = ({ children, value }) => { - return ( - - {children} - - ); -}; From 4da7604d7d9103cf042d2e027ba2f937636eef2b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 16:21:05 +0300 Subject: [PATCH 13/66] refactor(surface): drop the deprecated main-tab aliases and dead diagram surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainTab/activeMainTab/setActiveMainTab/setMainTabGuard were deprecated mirrors of the surface names — every call site now uses activeSurface/setActiveSurface/setSurfaceGuard directly and the aliases are gone, including the persisted mirror field. The 'diagram' surface had no way to open it (navigateToDiagram had no callers except a .drawio attachment click that navigated to a surface nothing rendered); the surface, DiagramView, and its store plumbing are removed, and a .drawio attachment now opens in the file panel. ?tab= deep links map to the matching context-panel surface instead of setting a main-area surface nothing renders, and a persisted non-chat surface can no longer rehydrate into a blank main area. --- packages/ui/src/App.tsx | 4 +- .../ui/src/components/chat/ChatContainer.tsx | 4 +- .../ui/src/components/chat/FileAttachment.tsx | 6 +- packages/ui/src/components/layout/Header.tsx | 14 +-- .../session/DirectoryExplorerDialog.tsx | 6 +- .../session/ScheduledTasksDialog.tsx | 4 +- .../src/components/session/SessionSidebar.tsx | 22 ++-- .../session/SessionSwitcherDropdown.tsx | 6 +- .../project-context/useProjectTodoSend.ts | 6 +- .../session/sidebar/SessionGroupSection.tsx | 12 +-- .../session/sidebar/SidebarProjectsList.tsx | 8 +- .../hooks/useProjectSessionSelection.ts | 10 +- .../sidebar/hooks/useSessionActions.ts | 6 +- .../ui/src/components/ui/CommandPalette.tsx | 6 +- .../ui/src/components/views/ArchiveView.tsx | 6 +- .../ui/src/components/views/DiagramView.tsx | 102 ------------------ .../ui/src/components/views/FilesView.tsx | 32 +++--- packages/ui/src/components/views/PlanView.tsx | 6 +- .../components/views/git/ConflictDialog.tsx | 6 +- .../views/git/IntegrateCommitsSection.tsx | 8 +- .../views/git/PullRequestSection.tsx | 14 +-- packages/ui/src/hooks/useKeyboardShortcuts.ts | 34 +++--- packages/ui/src/hooks/useMenuActions.ts | 12 +-- packages/ui/src/hooks/useRouter.ts | 16 ++- .../ui/src/lib/addSelectionToChat.test.ts | 12 +-- packages/ui/src/lib/addSelectionToChat.ts | 2 +- packages/ui/src/lib/router/types.ts | 2 +- packages/ui/src/stores/useUIStore.ts | 70 +++--------- 28 files changed, 151 insertions(+), 285 deletions(-) delete mode 100644 packages/ui/src/components/views/DiagramView.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 5c9e8806..c406536a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -626,7 +626,7 @@ function App({ apis }: AppProps) { const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null; - useUIStore.getState().setActiveMainTab('chat'); + useUIStore.getState().setActiveSurface('chat'); void useSessionUIStore.getState().setCurrentSession(sessionId, directory); }; @@ -675,7 +675,7 @@ function App({ apis }: AppProps) { ? detail.projectId.trim() : null; const hasProjectTarget = Boolean(directory || projectId); - useUIStore.getState().setActiveMainTab('chat'); + useUIStore.getState().setActiveSurface('chat'); useUIStore.getState().setSessionSwitcherOpen(false); useSessionUIStore.getState().openNewSessionDraft({ target: hasProjectTarget ? 'project' : 'chat', diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a38e313a..e8c46c4b 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1006,8 +1006,8 @@ export const ChatContainer: React.FC = ({ return; } - const { activeMainTab } = useUIStore.getState(); - if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) { + const { activeSurface } = useUIStore.getState(); + if (activeSurface !== 'chat' || hasBlockingChatOverlay()) { return; } diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 158a78f8..7f1d4f26 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -2,6 +2,7 @@ import React, { useRef, memo } from 'react'; import { useInputStore } from '@/sync/input-store'; import type { AttachedFile } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { openExternalUrl } from '@/lib/url'; @@ -833,7 +834,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } - -
-
- -
-
- ); -} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 05ca0323..a00ce036 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -939,7 +939,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); const pendingSelectFileRef = React.useRef(null); - const pendingTabRef = React.useRef(null); + const pendingTabRef = React.useRef(null); const pendingClosePathRef = React.useRef(null); const skipDirtyOnceRef = React.useRef(false); const copiedContentTimeoutRef = React.useRef(null); @@ -1029,7 +1029,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [isDragging, setIsDragging] = React.useState(false); // Session/config for sending comments - const setMainTabGuard = useUIStore((state) => state.setMainTabGuard); + const setSurfaceGuard = useUIStore((state) => state.setSurfaceGuard); const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation); const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); @@ -1098,10 +1098,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { React.useEffect(() => { setLineSelection(null); reset(); - setMainTabGuard(null); + setSurfaceGuard(null); setDraftContent(''); setIsSaving(false); - }, [selectedFile?.path, reset, setMainTabGuard]); + }, [selectedFile?.path, reset, setSurfaceGuard]); React.useEffect(() => { setCommentSelection(lineSelection); @@ -1713,11 +1713,11 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { React.useEffect(() => { if (!isDirty) { - setMainTabGuard(null); + setSurfaceGuard(null); return; } - const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => { + const guard = (_nextTab: import('@/stores/useUIStore').WorkspaceSurface) => { if (skipDirtyOnceRef.current) { skipDirtyOnceRef.current = false; return true; @@ -1727,15 +1727,15 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return false; }; - setMainTabGuard(guard); + setSurfaceGuard(guard); return () => { - const currentGuard = useUIStore.getState().mainTabGuard; + const currentGuard = useUIStore.getState().surfaceGuard; if (currentGuard === guard) { - setMainTabGuard(null); + setSurfaceGuard(null); } }; - }, [isDirty, setMainTabGuard]); + }, [isDirty, setSurfaceGuard]); React.useEffect(() => { if (autoSaveEnabled) { @@ -2180,10 +2180,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } if (nextTab) { - setMainTabGuard(null); - useUIStore.getState().setActiveMainTab(nextTab); + setSurfaceGuard(null); + useUIStore.getState().setActiveSurface(nextTab); } - }, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]); + }, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setSurfaceGuard, setSelectedPath]); const saveAndContinue = React.useCallback(async () => { const nextFile = pendingSelectFileRef.current; @@ -2234,10 +2234,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } if (nextTab) { - setMainTabGuard(null); - useUIStore.getState().setActiveMainTab(nextTab); + setSurfaceGuard(null); + useUIStore.getState().setActiveSurface(nextTab); } - }, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]); + }, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setSurfaceGuard, setSelectedPath]); const handleCloseFile = React.useCallback((path: string) => { const isActive = selectedFile?.path === path; diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 6efac6d0..a1ca3111 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -168,7 +168,7 @@ export const PlanView: React.FC = ({ targetPath = null, projectPl const activeProjectId = useProjectsStore((state) => state.activeProjectId); const gitDirectories = useGitStore((state) => state.directories); const effectiveDirectory = useEffectiveDirectory() ?? ''; - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const setActiveSurface = useUIStore((state) => state.setActiveSurface); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const runtimeApis = useRuntimeAPIs(); const { isMobile } = useDeviceInfo(); @@ -579,10 +579,10 @@ export const PlanView: React.FC = ({ targetPath = null, projectPl }, []); const routeToChat = React.useCallback(() => { - setActiveMainTab('chat'); + setActiveSurface('chat'); setSessionSwitcherOpen(false); onNavigatedToChat?.(); - }, [onNavigatedToChat, setActiveMainTab, setSessionSwitcherOpen]); + }, [onNavigatedToChat, setActiveSurface, setSessionSwitcherOpen]); const handleConfirmPlanSend = React.useCallback( async (execution: TodoSendExecution) => { diff --git a/packages/ui/src/components/views/git/ConflictDialog.tsx b/packages/ui/src/components/views/git/ConflictDialog.tsx index bd75bc4e..3df26e6a 100644 --- a/packages/ui/src/components/views/git/ConflictDialog.tsx +++ b/packages/ui/src/components/views/git/ConflictDialog.tsx @@ -41,7 +41,7 @@ export const ConflictDialog: React.FC = ({ const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const setActiveSurface = useUIStore((state) => state.setActiveSurface); const [isLoading, setIsLoading] = React.useState(false); const [conflictDetails, setConflictDetails] = React.useState(null); @@ -137,7 +137,7 @@ export const ConflictDialog: React.FC = ({ { text: context.payloadText, synthetic: true }, ]); - setActiveMainTab('chat'); + setActiveSurface('chat'); onClearState?.(); onOpenChange(false); }; @@ -159,7 +159,7 @@ export const ConflictDialog: React.FC = ({ ], }); // Navigate to chat tab so user sees the new session - setActiveMainTab('chat'); + setActiveSurface('chat'); onClearState?.(); onOpenChange(false); }; diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index fa750251..2d8db8ea 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -65,7 +65,7 @@ export const IntegrateCommitsSection: React.FC<{ }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setActiveSurface = useUIStore((s) => s.setActiveSurface); const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); const [branchSearch, setBranchSearch] = React.useState(''); const searchInputRef = React.useRef(null); @@ -236,7 +236,7 @@ export const IntegrateCommitsSection: React.FC<{ ], }); // Navigate to chat tab so user sees the new session - setActiveMainTab('chat'); + setActiveSurface('chat'); return; } @@ -251,8 +251,8 @@ export const IntegrateCommitsSection: React.FC<{ { text: context.instructionsText, synthetic: true }, { text: context.payloadText, synthetic: true }, ]); - setActiveMainTab('chat'); - }, [currentSessionId, setActiveMainTab, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]); + setActiveSurface('chat'); + }, [currentSessionId, setActiveSurface, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]); const handleMove = React.useCallback(async () => { if (ui.kind !== 'ready') return; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 3a258526..cb0dad8f 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -327,7 +327,7 @@ export const PullRequestSection: React.FC<{ const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const setActiveSurface = useUIStore((state) => state.setActiveSurface); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo(); @@ -986,14 +986,14 @@ export const PullRequestSection: React.FC<{ text: '', }); } - setActiveMainTab('chat'); + setActiveSurface('chat'); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('gitView.pr.toast.loadChecksFailed'), { description: message }); } finally { setIsAttachingChecks(false); } - }, [directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t]); + }, [directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveSurface, status?.repo, t]); const sendCommentsToChat = React.useCallback(async () => { if (!github?.prContext) { @@ -1021,14 +1021,14 @@ export const PullRequestSection: React.FC<{ for (const comment of timelineComments) { attachCommentDraft(target, comment); } - setActiveMainTab('chat'); + setActiveSurface('chat'); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('gitView.pr.toast.loadPrCommentsFailed'), { description: message }); } finally { setIsAttachingComments(false); } - }, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t, timelineComments]); + }, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveSurface, status?.repo, t, timelineComments]); const sendSingleCommentToChat = React.useCallback(async (comment: TimelineCommentItem) => { const target = resolveDraftTarget(); @@ -1037,8 +1037,8 @@ export const PullRequestSection: React.FC<{ } attachCommentDraft(target, comment); - setActiveMainTab('chat'); - }, [attachCommentDraft, resolveDraftTarget, setActiveMainTab]); + setActiveSurface('chat'); + }, [attachCommentDraft, resolveDraftTarget, setActiveSurface]); const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => { await refreshPrStatus(prStatusKey, options); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 114bc0b4..f66b66ff 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -59,7 +59,7 @@ export const useKeyboardShortcuts = () => { }, [currentShortcutDirectory]); const isMobile = useUIStore((s) => s.isMobile); const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); - const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setActiveSurface = useUIStore((s) => s.setActiveSurface); const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen); const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen); @@ -154,7 +154,7 @@ export const useKeyboardShortcuts = () => { isAboutDialogOpen, isMultiRunLauncherOpen, isImagePreviewOpen, - activeMainTab, + activeSurface, isPromptNavigatorPanelOpen, } = useUIStore.getState(); @@ -183,7 +183,7 @@ export const useKeyboardShortcuts = () => { } const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen; - const isChatActive = activeMainTab === 'chat'; + const isChatActive = activeSurface === 'chat'; if (hasOverlay || !isChatActive) { resetAbortPriming(); @@ -245,7 +245,7 @@ export const useKeyboardShortcuts = () => { if (eventMatchesShortcut(e, combo('toggle_prompt_navigator'))) { const { - activeMainTab, + activeSurface, promptNavigatorEnabled, isSettingsDialogOpen, isCommandPaletteOpen, @@ -257,7 +257,7 @@ export const useKeyboardShortcuts = () => { isImagePreviewOpen, } = useUIStore.getState(); - if (!promptNavigatorEnabled || isMobile || isVSCodeRuntime() || activeMainTab !== 'chat') { + if (!promptNavigatorEnabled || isMobile || isVSCodeRuntime() || activeSurface !== 'chat') { return; } @@ -308,7 +308,7 @@ export const useKeyboardShortcuts = () => { if (matchedNewSessionShortcut || matchedWorktreeShortcut) { e.preventDefault(); - setActiveMainTab('chat'); + setActiveSurface('chat'); setSessionSwitcherOpen(false); if (!isVSCodeRuntime() && matchedWorktreeShortcut) { @@ -394,11 +394,11 @@ export const useKeyboardShortcuts = () => { isHelpDialogOpen, isSessionSwitcherOpen, isAboutDialogOpen, - activeMainTab, + activeSurface, } = useUIStore.getState(); const hasOverlay = isSettingsDialogOpen || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - if (hasOverlay || activeMainTab !== 'chat' || !isChatInputTarget(e.target)) { + if (hasOverlay || activeSurface !== 'chat' || !isChatInputTarget(e.target)) { return; } @@ -525,7 +525,7 @@ export const useKeyboardShortcuts = () => { isHelpDialogOpen, isSessionSwitcherOpen, isAboutDialogOpen, - activeMainTab, + activeSurface, isModelSelectorOpen, } = useUIStore.getState(); @@ -536,7 +536,7 @@ export const useKeyboardShortcuts = () => { // Skip if any overlay open or not on chat tab const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; + const isChatActive = activeSurface === 'chat'; if (hasOverlay || !isChatActive) { return; @@ -555,7 +555,7 @@ export const useKeyboardShortcuts = () => { isHelpDialogOpen, isSessionSwitcherOpen, isAboutDialogOpen, - activeMainTab, + activeSurface, } = useUIStore.getState(); if (isSettingsDialogOpen) { @@ -563,7 +563,7 @@ export const useKeyboardShortcuts = () => { } const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; + const isChatActive = activeSurface === 'chat'; if (hasOverlay || !isChatActive) { return; @@ -602,7 +602,7 @@ export const useKeyboardShortcuts = () => { isHelpDialogOpen, isSessionSwitcherOpen, isAboutDialogOpen, - activeMainTab, + activeSurface, favoriteModels, addRecentModel, } = useUIStore.getState(); @@ -612,7 +612,7 @@ export const useKeyboardShortcuts = () => { } const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; + const isChatActive = activeSurface === 'chat'; if (hasOverlay || !isChatActive || favoriteModels.length === 0) { return; @@ -644,8 +644,8 @@ export const useKeyboardShortcuts = () => { } if (eventMatchesShortcut(e, combo('toggle_dictation'))) { - const { activeMainTab, isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, isSettingsDialogOpen } = useUIStore.getState(); - if (activeMainTab !== 'chat' || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSettingsDialogOpen) { + const { activeSurface, isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, isSettingsDialogOpen } = useUIStore.getState(); + if (activeSurface !== 'chat' || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSettingsDialogOpen) { return; } e.preventDefault(); @@ -695,7 +695,7 @@ export const useKeyboardShortcuts = () => { toggleTerminalSurfaceExpanded, isMobile, setSessionSwitcherOpen, - setActiveMainTab, + setActiveSurface, setSettingsDialogOpen, setModelSelectorOpen, setTimelineDialogOpen, diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 48b4093b..03f16abf 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -102,7 +102,7 @@ export const useMenuActions = ( const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); const toggleSidebar = useUIStore((s) => s.toggleSidebar); const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); - const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setActiveSurface = useUIStore((s) => s.setActiveSurface); const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); @@ -151,10 +151,10 @@ export const useMenuActions = ( const nextSession = sessions[nextIndex]; if (!nextSession) return; - setActiveMainTab('chat'); + setActiveSurface('chat'); setSessionSwitcherOpen(false); useSessionUIStore.getState().setCurrentSession(nextSession.id); - }, [setActiveMainTab, setSessionSwitcherOpen]); + }, [setActiveSurface, setSessionSwitcherOpen]); const navigateProject = React.useCallback((direction: -1 | 1) => { const { activeProjectId, projects, setActiveProject } = useProjectsStore.getState(); @@ -191,7 +191,7 @@ export const useMenuActions = ( break; case 'new-session': - setActiveMainTab('chat'); + setActiveSurface('chat'); setSessionSwitcherOpen(false); { const sessionState = useSessionUIStore.getState(); @@ -203,7 +203,7 @@ export const useMenuActions = ( break; case 'new-worktree-session': - setActiveMainTab('chat'); + setActiveSurface('chat'); setSessionSwitcherOpen(false); createWorktreeSession(); break; @@ -341,7 +341,7 @@ export const useMenuActions = ( onToggleMemoryDebug, openNewSessionDraft, setAboutDialogOpen, - setActiveMainTab, + setActiveSurface, setSessionSwitcherOpen, setCommandPaletteOpen, setSettingsDialogOpen, diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index 1e34e765..a1d3d5ca 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -1,11 +1,12 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useUIStore } from '@/stores/useUIStore'; +import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore'; import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; import type { RouteState, AppRouteState } from '@/lib/router'; import type { WorkspaceSurface } from '@/stores/useUIStore'; import { resolveSettingsSlug } from '@/lib/settings/metadata'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; /** * Check if running in VS Code webview context. @@ -88,9 +89,16 @@ export function useRouter(): void { setSettingsDialogOpen(false); } - // 3. Apply the view selected by the legacy URL parameter. - if (route.tab) { - setActiveSurface(route.tab); + // 3. Apply the view selected by the legacy URL parameter. Desktop + // surfaces live in the context panel, so a non-chat tab deep link + // opens the matching panel surface; activeSurface itself stays 'chat' + // (nothing renders non-chat surfaces in the main area). + if (route.tab && route.tab !== 'chat') { + const directory = useDirectoryStore.getState().currentDirectory; + if (directory) { + const mode: ContextPanelMode = route.tab === 'files' ? 'file' : route.tab; + useUIStore.getState().openContextSurface(directory, mode); + } } // 4. Apply diff file (only if going to diff tab) diff --git a/packages/ui/src/lib/addSelectionToChat.test.ts b/packages/ui/src/lib/addSelectionToChat.test.ts index 6acc225a..65a50d33 100644 --- a/packages/ui/src/lib/addSelectionToChat.test.ts +++ b/packages/ui/src/lib/addSelectionToChat.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; const focusChatInputCalls: number[] = []; const pendingInputCalls: Array<{ text: string | null; mode?: string }> = []; -const activeMainTabCalls: string[] = []; +const activeSurfaceCalls: string[] = []; const sessionSwitcherCalls: boolean[] = []; const codeMirrorDispatches: Array<{ selection: { anchor: number } }> = []; @@ -41,8 +41,8 @@ mock.module('@/sync/input-store', () => ({ mock.module('@/stores/useUIStore', () => ({ useUIStore: { getState: () => ({ - setActiveMainTab: (tab: string) => { - activeMainTabCalls.push(tab); + setActiveSurface: (tab: string) => { + activeSurfaceCalls.push(tab); }, setSessionSwitcherOpen: (open: boolean) => { sessionSwitcherCalls.push(open); @@ -86,7 +86,7 @@ const installSelectionEnvironment = (options: { const clearCalls = () => { focusChatInputCalls.length = 0; pendingInputCalls.length = 0; - activeMainTabCalls.length = 0; + activeSurfaceCalls.length = 0; sessionSwitcherCalls.length = 0; codeMirrorDispatches.length = 0; codeMirrorView = null; @@ -262,7 +262,7 @@ describe('addSelectionToChat', () => { installSelectionEnvironment({ activeElement: textarea }); expect(addSelectionToChat()).toBe(true); - expect(activeMainTabCalls).toEqual(['chat']); + expect(activeSurfaceCalls).toEqual(['chat']); expect(sessionSwitcherCalls).toEqual([false]); expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]); @@ -290,7 +290,7 @@ describe('addSelectionToChat', () => { expect(addSelectionToChat()).toBe(false); expect(pendingInputCalls).toEqual([]); - expect(activeMainTabCalls).toEqual(['chat']); + expect(activeSurfaceCalls).toEqual(['chat']); await Promise.resolve(); expect(focusChatInputCalls.length).toBe(1); diff --git a/packages/ui/src/lib/addSelectionToChat.ts b/packages/ui/src/lib/addSelectionToChat.ts index ce296569..a35e892b 100644 --- a/packages/ui/src/lib/addSelectionToChat.ts +++ b/packages/ui/src/lib/addSelectionToChat.ts @@ -151,7 +151,7 @@ export const captureSelectionMarkdownForChat = (): string | null => { export const addSelectionToChat = (): boolean => { const markdown = captureSelectionMarkdownForChat(); - useUIStore.getState().setActiveMainTab('chat'); + useUIStore.getState().setActiveSurface('chat'); useUIStore.getState().setSessionSwitcherOpen(false); if (markdown) { diff --git a/packages/ui/src/lib/router/types.ts b/packages/ui/src/lib/router/types.ts index 9c5801e2..25c838a6 100644 --- a/packages/ui/src/lib/router/types.ts +++ b/packages/ui/src/lib/router/types.ts @@ -19,7 +19,7 @@ export interface RouteState { /** * Valid values for the legacy `tab` URL parameter. */ -export const VALID_TABS: readonly WorkspaceSurface[] = ['chat', 'git', 'diff', 'terminal', 'files', 'diagram'] as const; +export const VALID_TABS: readonly WorkspaceSurface[] = ['chat', 'git', 'diff', 'terminal', 'files'] as const; /** * Valid settings section values for URL routing. diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 85f4ad02..2a1ca610 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -17,9 +17,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; * The primary view on mobile and the desktop's promoted full-screen view. * Desktop context-panel content is not represented here. */ -export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; -/** @deprecated Use WorkspaceSurface. */ -export type MainTab = WorkspaceSurface; +export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context'; export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; @@ -84,8 +82,6 @@ type PendingFileNavigation = { }; export type WorkspaceSurfaceGuard = (nextSurface: WorkspaceSurface) => boolean; -/** @deprecated Use WorkspaceSurfaceGuard. */ -export type MainTabGuard = WorkspaceSurfaceGuard; export type EventStreamStatus = | 'idle' | 'connecting' @@ -658,15 +654,10 @@ interface UIStore { isSessionDropdownOpen: boolean; activeSurface: WorkspaceSurface; surfaceGuard: WorkspaceSurfaceGuard | null; - /** @deprecated Use activeSurface. */ - activeMainTab: WorkspaceSurface; - /** @deprecated Use surfaceGuard. */ - mainTabGuard: WorkspaceSurfaceGuard | null; sidebarOpenBeforeFullscreenTab: boolean | null; pendingDiffFile: string | null; pendingDiffStaged: boolean; pendingDiffScope: PendingDiffScope | null; - pendingDiagramFile: string | null; pendingFileNavigation: PendingFileNavigation | null; pendingFileFocusPath: string | null; isMobile: boolean; @@ -854,21 +845,14 @@ interface UIStore { setSessionSwitcherOpen: (open: boolean) => void; setSessionDropdownOpen: (open: boolean) => void; setActiveSurface: (surface: WorkspaceSurface) => void; - /** @deprecated Use setActiveSurface. */ - setActiveMainTab: (surface: WorkspaceSurface) => void; prepareForRuntimeSwitch: (runtimeKey?: string | null) => void; restoreForRuntimeSwitch: (runtimeKey?: string | null) => void; setSurfaceGuard: (guard: WorkspaceSurfaceGuard | null) => void; - /** @deprecated Use setSurfaceGuard. */ - setMainTabGuard: (guard: WorkspaceSurfaceGuard | null) => void; setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void; - setPendingDiagramFile: (filePath: string | null) => void; setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void; setPendingFileFocusPath: (path: string | null) => void; navigateToDiff: (filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void; consumePendingDiffFile: () => string | null; - navigateToDiagram: (filePath: string) => void; - consumePendingDiagramFile: () => string | null; setIsMobile: (isMobile: boolean) => void; toggleCommandPalette: () => void; setCommandPaletteOpen: (open: boolean) => void; @@ -1037,13 +1021,10 @@ export const useUIStore = create()( isSessionDropdownOpen: false, activeSurface: 'chat', surfaceGuard: null, - activeMainTab: 'chat', - mainTabGuard: null, sidebarOpenBeforeFullscreenTab: null, pendingDiffFile: null, pendingDiffStaged: false, pendingDiffScope: null, - pendingDiagramFile: null, pendingFileNavigation: null, pendingFileFocusPath: null, isMobile: false, @@ -1656,29 +1637,25 @@ export const useUIStore = create()( if (get().surfaceGuard === guard) { return; } - set({ surfaceGuard: guard, mainTabGuard: guard }); + set({ surfaceGuard: guard }); }, - setMainTabGuard: (guard) => get().setSurfaceGuard(guard), - setActiveSurface: (surface) => { const guard = get().surfaceGuard; if (guard && !guard(surface)) { return; } activeSurfaceByRuntime.set(runtimeMemoryKey(), surface); - set({ activeSurface: surface, activeMainTab: surface }); + set({ activeSurface: surface }); }, - setActiveMainTab: (surface) => get().setActiveSurface(surface), - prepareForRuntimeSwitch: (runtimeKey?: string | null) => { activeSurfaceByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeSurface); }, restoreForRuntimeSwitch: (runtimeKey?: string | null) => { const restored = activeSurfaceByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat'; - set({ activeSurface: restored, activeMainTab: restored }); + set({ activeSurface: restored }); }, setPendingDiffFile: (filePath, staged = false, scope = null) => { @@ -1689,10 +1666,6 @@ export const useUIStore = create()( }); }, - setPendingDiagramFile: (filePath) => { - set({ pendingDiagramFile: filePath }); - }, - setPendingFileNavigation: (navigation) => { set({ pendingFileNavigation: navigation }); }, @@ -1706,7 +1679,7 @@ export const useUIStore = create()( if (guard && !guard('diff')) { return; } - set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeSurface: 'diff', activeMainTab: 'diff' }); + set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeSurface: 'diff' }); }, consumePendingDiffFile: () => { @@ -1717,22 +1690,6 @@ export const useUIStore = create()( return pendingDiffFile; }, - navigateToDiagram: (filePath) => { - const guard = get().surfaceGuard; - if (guard && !guard('diagram')) { - return; - } - set({ pendingDiagramFile: filePath, activeSurface: 'diagram', activeMainTab: 'diagram' }); - }, - - consumePendingDiagramFile: () => { - const { pendingDiagramFile } = get(); - if (pendingDiagramFile) { - set({ pendingDiagramFile: null }); - } - return pendingDiagramFile; - }, - setIsMobile: (isMobile) => { set({ isMobile }); }, @@ -2498,18 +2455,20 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 15, + version: 16, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; - // v14 -> v15: rename the historic main-tab field. The selected - // mobile or promoted desktop view remains unchanged. - if (version < 15) { - state.activeSurface = state.activeMainTab; - state.activeMainTab = state.activeSurface; + // v15 -> v16: the main-area surface concept is gone from persistence + // (the chat always owns the desktop main area; panel surfaces have + // their own state). Drop the historic fields so a stored non-chat + // value cannot rehydrate into a blank main area. + if (version < 16) { + delete state.activeMainTab; + delete state.activeSurface; } // v13 -> v14: the separate 'preview' surface merged into 'browser'. @@ -2717,9 +2676,6 @@ export const useUIStore = create()( workStatusPanelEnabled: state.workStatusPanelEnabled, workStatusHiddenSections: state.workStatusHiddenSections, isSessionSwitcherOpen: state.isSessionSwitcherOpen, - activeSurface: state.activeSurface, - // Keep the deprecated mirror synchronized while consumers migrate. - activeMainTab: state.activeSurface, sidebarSection: state.sidebarSection, settingsPage: state.settingsPage, settingsHasOpenedOnce: state.settingsHasOpenedOnce, From c6e39f15fef09074c7a26a7a267f57b9f40b9e6b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 16:21:13 +0300 Subject: [PATCH 14/66] docs: changelog entry for the app-shell viewport switch --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e024f7b2..f204ff00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name like "solo-is-a" finds the file inside it. - **Search in dropdowns:** every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). The git branch and gitmoji pickers also stopped silently dropping rows that a second, built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way now. - Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. +- Mobile: narrowing a browser window past phone size now switches into the mobile app layout (and back when widened) instead of squeezing the desktop layout. The old/new mobile layout setting is gone — phones always get the mobile layout. - Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache. - Chat: OpenCode notices now share one style. - UI: draft target menus stay inside the chat area instead of overlapping the header. From c82f188fc8c43c547c1787dddbf92fcbc9f02b45 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 16:36:41 +0300 Subject: [PATCH 15/66] refactor(surface): remove the main-area surface concept entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activeSurface was permanently 'chat' after the legacy mobile layout removal, so the whole concept is gone: the store field, surfaceGuard, setActiveSurface/setSurfaceGuard, the per-runtime surface memory in prepare/restoreForRuntimeSwitch, and WorkspaceSurface itself. All ~30 setActiveSurface('chat') call sites were no-ops and are deleted; always-true 'is the chat active' checks in keyboard shortcuts, Header and ChatContainer are unconditional now. FilesView's dirty-file guard kept its file-switch and close protection but drops the surface-switch branch nothing could trigger. TerminalView visibility comes only from its callers. The router keeps parsing legacy ?tab= links (they open the matching context-panel surface) via its own RouteTab type and no longer serializes a tab or diff file into URLs — desktop URLs never carried them anyway. --- packages/ui/src/App.tsx | 2 - packages/ui/src/apps/runtimeEndpointReset.ts | 3 -- .../ui/src/components/chat/ChatContainer.tsx | 3 +- packages/ui/src/components/layout/Header.tsx | 12 +---- .../ui/src/components/layout/MainLayout.tsx | 11 +--- .../session/DirectoryExplorerDialog.tsx | 4 +- .../session/ScheduledTasksDialog.tsx | 11 ++-- .../src/components/session/SessionSidebar.tsx | 17 ++---- .../session/SessionSwitcherDropdown.tsx | 4 +- .../project-context/useProjectTodoSend.ts | 4 +- .../session/sidebar/SessionGroupSection.tsx | 8 +-- .../session/sidebar/SidebarProjectsList.tsx | 4 -- .../hooks/useProjectSessionSelection.ts | 5 -- .../sidebar/hooks/useSessionActions.ts | 3 -- .../ui/src/components/ui/CommandPalette.tsx | 5 +- .../ui/src/components/views/ArchiveView.tsx | 4 +- .../ui/src/components/views/FilesView.tsx | 47 ++-------------- packages/ui/src/components/views/PlanView.tsx | 4 +- .../ui/src/components/views/TerminalView.tsx | 4 +- .../components/views/git/ConflictDialog.tsx | 4 -- .../views/git/IntegrateCommitsSection.tsx | 7 +-- .../views/git/PullRequestSection.tsx | 10 ++-- packages/ui/src/hooks/useKeyboardShortcuts.ts | 25 +++------ packages/ui/src/hooks/useMenuActions.ts | 11 ++-- packages/ui/src/hooks/useRouter.ts | 24 ++------- packages/ui/src/lib/addSelectionToChat.ts | 1 - packages/ui/src/lib/router/parseRoute.ts | 6 +-- .../ui/src/lib/router/serializeRoute.test.ts | 2 - packages/ui/src/lib/router/serializeRoute.ts | 17 ------ packages/ui/src/lib/router/types.ts | 9 ++-- packages/ui/src/stores/useUIStore.ts | 54 +------------------ 31 files changed, 49 insertions(+), 276 deletions(-) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index c406536a..f213c11f 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -626,7 +626,6 @@ function App({ apis }: AppProps) { const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null; - useUIStore.getState().setActiveSurface('chat'); void useSessionUIStore.getState().setCurrentSession(sessionId, directory); }; @@ -675,7 +674,6 @@ function App({ apis }: AppProps) { ? detail.projectId.trim() : null; const hasProjectTarget = Boolean(directory || projectId); - useUIStore.getState().setActiveSurface('chat'); useUIStore.getState().setSessionSwitcherOpen(false); useSessionUIStore.getState().openNewSessionDraft({ target: hasProjectTarget ? 'project' : 'chat', diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index 39c325fd..add84eb3 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -6,7 +6,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; -import { useUIStore } from '@/stores/useUIStore'; import { usePermissionStore } from '@/stores/permissionStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useGitStore } from '@/stores/useGitStore'; @@ -37,7 +36,6 @@ export const reconnectAppForTransportSwitch = (): void => { export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => { useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); if (detail.previousRuntimeKey) { useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); } @@ -71,7 +69,6 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); resetStreamingState(); queueMicrotask(() => void syncDesktopSettings()); }; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index e8c46c4b..a6384531 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1006,8 +1006,7 @@ export const ChatContainer: React.FC = ({ return; } - const { activeSurface } = useUIStore.getState(); - if (activeSurface !== 'chat' || hasBlockingChatOverlay()) { + if (hasBlockingChatOverlay()) { return; } diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 067bcd38..3d99604b 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -434,7 +434,6 @@ export const Header: React.FC = () => { const openContextOverview = useUIStore((state) => state.openContextOverview); const openContextPlan = useUIStore((state) => state.openContextPlan); const closeContextPanel = useUIStore((state) => state.closeContextPanel); - const activeSurface = useUIStore((state) => state.activeSurface); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); @@ -612,7 +611,6 @@ export const Header: React.FC = () => { }, [setWorkStatusOverlayOpen, setWorkStatusPanelEnabled, workStatusOverlayOpen, workStatusPanelEnabled, workStatusPanelFits]); const showDesktopHeaderContextUsage = !isVSCode && !workStatusPanelVisible - && activeSurface === 'chat' && !!stableDesktopContextUsage && stableDesktopContextUsage.totalTokens > 0; const desktopHeaderDisplayPercentage = stableDesktopContextUsage && stableDesktopContextUsage.contextLimit > 0 @@ -1153,9 +1151,6 @@ export const Header: React.FC = () => { // Reset plan tab availability when session changes React.useEffect(() => { if (!planModeEnabled) { - if (useUIStore.getState().activeSurface === 'plan') { - useUIStore.getState().setActiveSurface('chat'); - } return; } @@ -1165,11 +1160,6 @@ export const Header: React.FC = () => { if (lastPlanSessionKeyRef.current !== sessionKey) { lastPlanSessionKeyRef.current = sessionKey; } - - // If plan is not available but user is on plan tab, switch them back to chat - if (!planTabAvailable && useUIStore.getState().activeSurface === 'plan') { - useUIStore.getState().setActiveSurface('chat'); - } }, [ planModeEnabled, planTabAvailable, @@ -1759,7 +1749,7 @@ export const Header: React.FC = () => { className={cn(desktopHeaderIconButtonClass, 'mr-1')} Icon={'picture-in-picture-2'} /> - {activeSurface === 'chat' && !isVSCode ? ( + {!isVSCode ? ( ) : null} +
+ {isRenamingHeaderSession ? ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + void saveHeaderSessionRename(); + }} + > + setHeaderSessionTitleDraft(event.target.value)} + autoFocus + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === 'Escape') { + setIsRenamingHeaderSession(false); + } + }} + placeholder={t('sessions.sidebar.session.menu.rename')} + className="min-w-0 flex-1 bg-transparent typography-ui-label text-[14px] font-normal leading-tight outline-none placeholder:text-muted-foreground" + /> + + +
+ ) : ( + + {isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle} + + )} + {showHeaderMetaRow ? ( + + {activeProjectLabel ? {activeProjectLabel} : null} + {currentBranchLabel ? ( + + + {currentBranchLabel} + + ) : null} + {!isNewSessionDraftOpen && worktreeBadgeKind ? ( + + + {worktreeBadge} + + ) : null} + + ) : null} +
+
+ {currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? ( + { + if (!open && pendingHeaderRenameRef.current) { + pendingHeaderRenameRef.current = false; + beginHeaderSessionRename(); + } + }} + > + + + + + { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} + {t('sessions.sidebar.session.menu.copyId')} + + {currentSession?.shareUrl ? ( + <> + {t('sessions.sidebar.session.menu.copyLink')} + void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} + + ) : ( + void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} + )} + void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} + {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( + + + + + + {t('sessions.sidebar.session.menu.moveToWorktree')} + + + + + {isCurrentSessionMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isCurrentSessionActive + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltip')} + + + ) : null} + + setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} + setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} + + + ) : null} +
+
+ ) : ( +
+ {!isSidebarOpen ? ( + + + + ) : null} +
{isRenamingHeaderSession ? (
{ ) : null}
+
)} diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx new file mode 100644 index 00000000..18e7528f --- /dev/null +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -0,0 +1,326 @@ +import React from 'react'; +import { + DndContext, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, + type Modifier, +} from '@dnd-kit/core'; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS as DndCSS } from '@dnd-kit/utilities'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Icon } from '@/components/icon/Icon'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { copyTextToClipboard } from '@/lib/clipboard'; +import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; +import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); + +/** + * Sortable shell for the active tab: the pill itself drags, while the + * interactive content inside (rename form, menu) stops pointer-down so a text + * selection or menu click never starts a drag. + */ +const ActiveTabShell: React.FC<{ id: string; children: React.ReactNode }> = ({ id, children }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + return ( +
+
+ {children} +
+
+ ); +}; + +type SessionTab = { id: string; session: Session }; + +/** + * One inactive tab: a soft pill with the session title. The "..." menu trigger + * has no reserved footprint — it appears at the tab's end on hover (or while + * its menu is open), nudging the title, mirroring the sidebar row mechanic. + * The reveal itself is opacity-only; the layout change is instant. + */ +const InactiveSessionTab: React.FC<{ + tab: SessionTab; + onSelect: (tab: SessionTab) => void; + onClose: (id: string) => void; + onCloseOthers: (id: string) => void; +}> = ({ tab, onSelect, onClose, onCloseOthers }) => { + const { t } = useI18n(); + const [menuOpen, setMenuOpen] = React.useState(false); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); + + const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); + + return ( +
+
onSelect(tab)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(tab); + } + }} + onAuxClick={(event) => { + if (event.button === 1) { + event.preventDefault(); + onClose(tab.id); + } + }} + className={cn( + 'group/session-tab flex h-8 max-w-[200px] cursor-pointer touch-none select-none items-center rounded-[10px] px-3', + 'text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover/40 hover:text-foreground', + menuOpen && 'bg-interactive-hover/40 text-foreground', + )} + title={title} + > + + {title} + + + + + + + onClose(tab.id)}> + + {t('header.sessionTabs.closeTab')} + + onCloseOthers(tab.id)}> + + {t('header.sessionTabs.closeOtherTabs')} + + + void copyTextToClipboard(tab.id)}> + + {t('sessions.sidebar.session.menu.copyId')} + + + +
+
+ ); +}; + +/** + * The header's horizontal working set of sessions (web/desktop only). + * + * Every session the user opens joins the strip once; the tab whose session is + * current renders `children` — the header's existing title block with rename, + * meta row and the full session menu — inside a softly selected pill. Closing + * a tab only removes it from the strip; closing the active one activates its + * neighbour. Ids whose session has not loaded (or was archived/deleted) stay + * in the store but do not render, so a partial session list never destroys + * the working set. + */ +export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { t } = useI18n(); + const tabIds = useSessionTabsStore((state) => state.tabIds); + const ensureTab = useSessionTabsStore((state) => state.ensureTab); + const closeTab = useSessionTabsStore((state) => state.closeTab); + const closeOtherTabs = useSessionTabsStore((state) => state.closeOtherTabs); + const reorderTabs = useSessionTabsStore((state) => state.reorderTabs); + + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); + + // Opening a session anywhere (sidebar, palette, deep link) adds its tab. + React.useEffect(() => { + if (currentSessionId) ensureTab(currentSessionId); + }, [currentSessionId, ensureTab]); + + const sessionsById = React.useMemo(() => { + const map = new Map(); + for (const session of activeSessions) map.set(session.id, session); + return map; + }, [activeSessions]); + + // Only tabs with a known live session render; unknown ids stay stored. + const tabs = React.useMemo(() => { + const list: SessionTab[] = []; + for (const id of tabIds) { + const session = sessionsById.get(id); + if (session) list.push({ id, session }); + } + return list; + }, [tabIds, sessionsById]); + + const handleSelect = React.useCallback((tab: SessionTab) => { + setCurrentSession(tab.id, resolveGlobalSessionDirectory(tab.session)); + }, [setCurrentSession]); + + const activateNeighbour = React.useCallback((closedId: string) => { + const index = tabs.findIndex((tab) => tab.id === closedId); + const neighbour = tabs[index + 1] ?? tabs[index - 1] ?? null; + if (neighbour) { + handleSelect(neighbour); + } else { + openNewSessionDraft(); + } + }, [tabs, handleSelect, openNewSessionDraft]); + + const handleClose = React.useCallback((id: string) => { + if (id === currentSessionId) activateNeighbour(id); + closeTab(id); + }, [activateNeighbour, closeTab, currentSessionId]); + + const handleCloseOthers = React.useCallback((id: string) => { + closeOtherTabs(id); + if (currentSessionId && currentSessionId !== id) { + const kept = tabs.find((tab) => tab.id === id); + if (kept) handleSelect(kept); + } + }, [closeOtherTabs, currentSessionId, handleSelect, tabs]); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }), + ); + + const handleDragEnd = React.useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + reorderTabs(String(active.id), String(over.id)); + } + }, [reorderTabs]); + + // Soft fade at the edges while more tabs hide behind them. + const scrollRef = React.useRef(null); + const [edges, setEdges] = React.useState({ left: false, right: false }); + const updateEdges = React.useCallback(() => { + const node = scrollRef.current; + if (!node) return; + const left = node.scrollLeft > 2; + const right = node.scrollLeft + node.clientWidth < node.scrollWidth - 2; + setEdges((prev) => (prev.left === left && prev.right === right ? prev : { left, right })); + }, []); + React.useEffect(() => { + updateEdges(); + const node = scrollRef.current; + if (!node || !globalThis.ResizeObserver) return; + const observer = new ResizeObserver(updateEdges); + observer.observe(node); + return () => observer.disconnect(); + }, [updateEdges, tabs.length]); + + // Keep the active tab in view when it changes. + React.useEffect(() => { + scrollRef.current + ?.querySelector('[data-active-session-tab]') + ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }, [currentSessionId]); + + const maskImage = edges.left && edges.right + ? 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)' + : edges.left + ? 'linear-gradient(to right, transparent, black 24px)' + : edges.right + ? 'linear-gradient(to right, black calc(100% - 24px), transparent)' + : undefined; + + const tabIdsInOrder = React.useMemo(() => tabs.map((tab) => tab.id), [tabs]); + + const renderTab = (tab: SessionTab) => { + if (tab.id === currentSessionId) { + return {children}; + } + return ( + + ); + }; + + // A brand-new draft (no session yet) shows as a transient active pill after + // the tabs; it becomes a real tab once the first message creates the session. + const showDraftPill = !currentSessionId || !tabs.some((tab) => tab.id === currentSessionId); + + return ( +
+
+ + + {tabs.map(renderTab)} + + + {showDraftPill ? ( +
+ {children} +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4f0f3bc2..44b048d4 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -419,6 +419,10 @@ export const dict = { 'sessions.sidebar.activity.chatsEmpty': 'Noch keine Chats.', 'chat.chatInput.chooseProject': 'Projekt auswählen', 'sessions.switcher.openAria': 'Sitzungswechsler öffnen', + 'header.sessionTabs.stripAria': 'Offene Sitzungen', + 'header.sessionTabs.tabMenuAria': 'Aktionen für den Sitzungs-Tab', + 'header.sessionTabs.closeTab': 'Tab schließen', + 'header.sessionTabs.closeOtherTabs': 'Andere Tabs schließen', 'sessions.switcher.empty': 'Keine kürzlichen Sitzungen', 'sessions.switcher.draftTitle': 'Neue Sitzung', 'sessions.sidebar.updateCheck.errorTitle': 'Fehler beim Prüfen auf Aktualisierungen', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index fc9a2717..a4840295 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -481,6 +481,10 @@ export const dict = { 'sessions.archivePage.deleteSessionAria': 'Delete {title}', 'sessions.archivePage.restoreSessionAria': 'Restore {title}', 'sessions.switcher.openAria': 'Open session switcher', + 'header.sessionTabs.stripAria': 'Open sessions', + 'header.sessionTabs.tabMenuAria': 'Session tab actions', + 'header.sessionTabs.closeTab': 'Close tab', + 'header.sessionTabs.closeOtherTabs': 'Close other tabs', 'sessions.switcher.empty': 'No recent sessions', 'sessions.switcher.draftTitle': 'New session', 'sessions.sidebar.updateCheck.errorTitle': 'Failed to check for updates', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 17fec35e..1f6b829e 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -482,6 +482,10 @@ export const dict: Record = { "sessions.archivePage.deleteSessionAria": "Eliminar {title}", "sessions.archivePage.restoreSessionAria": "Restaurar {title}", "sessions.switcher.openAria": "Abrir selector de sesiones", + "header.sessionTabs.stripAria": "Sesiones abiertas", + "header.sessionTabs.tabMenuAria": "Acciones de la pestaña de sesión", + "header.sessionTabs.closeTab": "Cerrar pestaña", + "header.sessionTabs.closeOtherTabs": "Cerrar las demás pestañas", "sessions.switcher.empty": "No hay sesiones recientes", "sessions.switcher.draftTitle": "Nueva sesión", "sessions.sidebar.updateCheck.errorTitle": "No se pudo comprobar actualizaciones", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 0cf10e61..1b8ccd3a 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -312,6 +312,10 @@ export const dict = { 'sessions.archivePage.deleteSessionAria': 'Supprimer {title}', 'sessions.archivePage.restoreSessionAria': 'Restaurer {title}', 'sessions.switcher.openAria': 'Sélecteur de session ouvert', + 'header.sessionTabs.stripAria': 'Sessions ouvertes', + 'header.sessionTabs.tabMenuAria': 'Actions de l\'onglet de session', + 'header.sessionTabs.closeTab': 'Fermer l\'onglet', + 'header.sessionTabs.closeOtherTabs': 'Fermer les autres onglets', 'sessions.switcher.empty': 'Aucune session récente', 'sessions.switcher.draftTitle': 'Nouvelle session', 'sessions.sidebar.updateCheck.errorTitle': 'Échec de la vérification des mises à jour', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 5796a5b0..fe7f1868 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -482,6 +482,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '{title} を削除', 'sessions.archivePage.restoreSessionAria': '{title} を復元', 'sessions.switcher.openAria': 'セッションスイッチャーを開く', + 'header.sessionTabs.stripAria': '開いているセッション', + 'header.sessionTabs.tabMenuAria': 'セッションタブの操作', + 'header.sessionTabs.closeTab': 'タブを閉じる', + 'header.sessionTabs.closeOtherTabs': '他のタブを閉じる', 'sessions.switcher.empty': '最近のセッションはありません', 'sessions.switcher.draftTitle': '新しいセッション', 'sessions.sidebar.updateCheck.errorTitle': '更新の確認に失敗しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 80d4f60e..a2b73918 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -482,6 +482,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '{title} 삭제', 'sessions.archivePage.restoreSessionAria': '{title} 복원', 'sessions.switcher.openAria': '세션 전환기 열기', + 'header.sessionTabs.stripAria': '열린 세션', + 'header.sessionTabs.tabMenuAria': '세션 탭 작업', + 'header.sessionTabs.closeTab': '탭 닫기', + 'header.sessionTabs.closeOtherTabs': '다른 탭 닫기', 'sessions.switcher.empty': '최근 세션 없음', 'sessions.switcher.draftTitle': '새 세션', 'sessions.sidebar.updateCheck.errorTitle': '업데이트 확인 실패', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c29a1dfa..2f3ef7b6 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -293,6 +293,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': 'Usuń {title}', 'sessions.archivePage.restoreSessionAria': 'Przywróć {title}', 'sessions.switcher.openAria': 'Otwórz przełącznik sesji', + 'header.sessionTabs.stripAria': 'Otwarte sesje', + 'header.sessionTabs.tabMenuAria': 'Akcje karty sesji', + 'header.sessionTabs.closeTab': 'Zamknij kartę', + 'header.sessionTabs.closeOtherTabs': 'Zamknij pozostałe karty', 'sessions.switcher.empty': 'Brak ostatnich sesji', 'sessions.switcher.draftTitle': 'Nowa sesja', 'sessions.sidebar.updateCheck.errorTitle': 'Nie udało się sprawdzić aktualizacji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 5e988ce3..2723fd6e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -482,6 +482,10 @@ export const dict: Record = { "sessions.archivePage.deleteSessionAria": "Excluir {title}", "sessions.archivePage.restoreSessionAria": "Restaurar {title}", "sessions.switcher.openAria": "Abrir seletor de sessões", + "header.sessionTabs.stripAria": "Sessões abertas", + "header.sessionTabs.tabMenuAria": "Ações da aba de sessão", + "header.sessionTabs.closeTab": "Fechar aba", + "header.sessionTabs.closeOtherTabs": "Fechar outras abas", "sessions.switcher.empty": "Nenhuma sessão recente", "sessions.switcher.draftTitle": "Nova sessão", "sessions.sidebar.updateCheck.errorTitle": "Não foi possível verificar atualizações", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1f10374f..90c85f21 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -482,6 +482,10 @@ export const dict: Record = { "sessions.archivePage.deleteSessionAria": "Видалити {title}", "sessions.archivePage.restoreSessionAria": "Відновити {title}", "sessions.switcher.openAria": "Відкрити перемикач сесій", + "header.sessionTabs.stripAria": "Відкриті сесії", + "header.sessionTabs.tabMenuAria": "Дії вкладки сесії", + "header.sessionTabs.closeTab": "Закрити вкладку", + "header.sessionTabs.closeOtherTabs": "Закрити інші вкладки", "sessions.switcher.empty": "Немає недавніх сесій", "sessions.switcher.draftTitle": "Нова сесія", "sessions.sidebar.updateCheck.errorTitle": "Не вдалося перейти на наявність оновлень", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2b0c7263..007a243b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -482,6 +482,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '删除 {title}', 'sessions.archivePage.restoreSessionAria': '还原 {title}', 'sessions.switcher.openAria': '打开会话切换器', + 'header.sessionTabs.stripAria': '打开的会话', + 'header.sessionTabs.tabMenuAria': '会话标签页操作', + 'header.sessionTabs.closeTab': '关闭标签页', + 'header.sessionTabs.closeOtherTabs': '关闭其他标签页', 'sessions.switcher.empty': '没有最近会话', 'sessions.switcher.draftTitle': '新会话', 'sessions.sidebar.updateCheck.errorTitle': '检查更新失败', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 842450c1..1e4750e3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -495,6 +495,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '刪除 {title}', 'sessions.archivePage.restoreSessionAria': '還原 {title}', 'sessions.switcher.openAria': '開啟會話切換器', + 'header.sessionTabs.stripAria': '開啟的會話', + 'header.sessionTabs.tabMenuAria': '工作階段分頁動作', + 'header.sessionTabs.closeTab': '關閉分頁', + 'header.sessionTabs.closeOtherTabs': '關閉其他分頁', 'sessions.switcher.empty': '沒有最近會話', 'sessions.switcher.draftTitle': '新會話', 'sessions.sidebar.updateCheck.errorTitle': '檢查更新失敗', diff --git a/packages/ui/src/stores/useSessionTabsStore.test.ts b/packages/ui/src/stores/useSessionTabsStore.test.ts new file mode 100644 index 00000000..5e9d9547 --- /dev/null +++ b/packages/ui/src/stores/useSessionTabsStore.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useSessionTabsStore } from './useSessionTabsStore'; + +describe('useSessionTabsStore', () => { + beforeEach(() => { + useSessionTabsStore.setState({ tabIds: [] }); + }); + + test('ensureTab appends once and preserves order', () => { + const store = useSessionTabsStore.getState(); + store.ensureTab('a'); + store.ensureTab('b'); + store.ensureTab('a'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'b']); + }); + + test('closeTab removes only the given id; closeOtherTabs keeps only it', () => { + useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] }); + useSessionTabsStore.getState().closeTab('b'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'c']); + useSessionTabsStore.getState().closeOtherTabs('c'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['c']); + }); + + test('reorderTabs moves by id and ignores unknown ids', () => { + useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] }); + useSessionTabsStore.getState().reorderTabs('c', 'a'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['c', 'a', 'b']); + const before = useSessionTabsStore.getState().tabIds; + useSessionTabsStore.getState().reorderTabs('x', 'a'); + expect(useSessionTabsStore.getState().tabIds).toBe(before); + }); + + test('removeTabs drops only confirmed-gone ids and no-ops otherwise', () => { + useSessionTabsStore.setState({ tabIds: ['a', 'b'] }); + const before = useSessionTabsStore.getState().tabIds; + useSessionTabsStore.getState().removeTabs(['x']); + expect(useSessionTabsStore.getState().tabIds).toBe(before); + useSessionTabsStore.getState().removeTabs(['a']); + expect(useSessionTabsStore.getState().tabIds).toEqual(['b']); + }); +}); diff --git a/packages/ui/src/stores/useSessionTabsStore.ts b/packages/ui/src/stores/useSessionTabsStore.ts new file mode 100644 index 00000000..b2c6b3d2 --- /dev/null +++ b/packages/ui/src/stores/useSessionTabsStore.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; + +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; + +/** + * The header's working set of sessions, shown as tabs on web/desktop. + * + * Only session ids and their order are owned here — titles, directories and + * liveness come from the session stores at render time. Tabs are a per-client + * projection: opening a session anywhere adds it once, closing a tab only + * removes it from the strip and never touches the session itself. Ids whose + * session is unknown are kept (a partially loaded global list must not + * destroy the working set) and simply do not render until the session loads. + */ +interface SessionTabsStore { + tabIds: string[]; + + ensureTab: (sessionId: string) => void; + closeTab: (sessionId: string) => void; + closeOtherTabs: (sessionId: string) => void; + reorderTabs: (activeId: string, overId: string) => void; + /** Drop ids the caller has authoritatively confirmed no longer exist. */ + removeTabs: (sessionIds: readonly string[]) => void; +} + +type PersistedSessionTabs = { tabIds: string[] }; + +export const useSessionTabsStore = create()( + devtools( + persist( + (set, get) => ({ + tabIds: [], + + ensureTab: (sessionId) => { + if (!sessionId) return; + const { tabIds } = get(); + if (tabIds.includes(sessionId)) return; + set({ tabIds: [...tabIds, sessionId] }); + }, + + closeTab: (sessionId) => { + const { tabIds } = get(); + if (!tabIds.includes(sessionId)) return; + set({ tabIds: tabIds.filter((id) => id !== sessionId) }); + }, + + closeOtherTabs: (sessionId) => { + const { tabIds } = get(); + if (!tabIds.includes(sessionId)) return; + if (tabIds.length === 1) return; + set({ tabIds: [sessionId] }); + }, + + reorderTabs: (activeId, overId) => { + const { tabIds } = get(); + const from = tabIds.indexOf(activeId); + const to = tabIds.indexOf(overId); + if (from < 0 || to < 0 || from === to) return; + const next = [...tabIds]; + next.splice(to, 0, ...next.splice(from, 1)); + set({ tabIds: next }); + }, + + removeTabs: (sessionIds) => { + if (sessionIds.length === 0) return; + const gone = new Set(sessionIds); + const { tabIds } = get(); + const next = tabIds.filter((id) => !gone.has(id)); + if (next.length === tabIds.length) return; + set({ tabIds: next }); + }, + }), + { + name: 'session-tabs-store', + storage: createDeferredSafeJSONStorage(), + partialize: (state) => ({ tabIds: state.tabIds }), + }, + ), + ), +); From 447e43eac58155f5ddc4628731814b8f36a0fba0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 17:41:34 +0300 Subject: [PATCH 18/66] style(header): session tabs follow the titlebar tab design language Uniform compact tabs (h-7, 6px radius, 13px medium) that share the strip width and shrink before scrolling, thin separators between inactive neighbours that disappear around the active or hovered tab, a hover-revealed menu button anchored to the tab's end, a soft selection pill for the active tab, and hidden scrollbars with fade edges. The active tab is single-line: the project/branch meta row stays only in the VS Code header, which keeps the plain title. --- packages/ui/src/components/layout/Header.tsx | 27 +++------------ .../components/layout/SessionTabsStrip.tsx | 33 ++++++++++++------- packages/ui/src/index.css | 22 +++++++++++++ 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 82f3b5c1..39245efd 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1739,6 +1739,7 @@ export const Header: React.FC = () => { event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); void saveHeaderSessionRename(); @@ -1755,7 +1756,7 @@ export const Header: React.FC = () => { } }} placeholder={t('sessions.sidebar.session.menu.rename')} - className="min-w-0 flex-1 bg-transparent typography-ui-label text-[14px] font-normal leading-tight outline-none placeholder:text-muted-foreground" + className="min-w-0 flex-1 bg-transparent text-[13px] font-medium leading-4 outline-none placeholder:text-muted-foreground" /> ) : ( - + {isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle} )} - {showHeaderMetaRow ? ( - - {activeProjectLabel ? {activeProjectLabel} : null} - {currentBranchLabel ? ( - - - {currentBranchLabel} - - ) : null} - {!isNewSessionDraftOpen && worktreeBadgeKind ? ( - - - {worktreeBadge} - - ) : null} - - ) : null}
{currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? ( = ({ i
{children}
@@ -85,7 +86,8 @@ const InactiveSessionTab: React.FC<{
@@ -107,13 +109,19 @@ const InactiveSessionTab: React.FC<{ } }} className={cn( - 'group/session-tab flex h-8 max-w-[200px] cursor-pointer touch-none select-none items-center rounded-[10px] px-3', - 'text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover/40 hover:text-foreground', - menuOpen && 'bg-interactive-hover/40 text-foreground', + 'group/session-tab relative flex h-7 w-full min-w-0 cursor-pointer touch-none select-none items-center rounded-md px-2', + 'text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', + menuOpen && 'bg-interactive-hover text-foreground', )} title={title} > - + {title} @@ -124,10 +132,10 @@ const InactiveSessionTab: React.FC<{ onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} className={cn( - 'ml-0 hidden w-0 shrink-0 items-center justify-center overflow-hidden rounded-md text-muted-foreground', + 'absolute right-1 top-1/2 hidden size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground', 'opacity-0 transition-opacity duration-150 hover:text-foreground', - 'group-hover/session-tab:ml-1.5 group-hover/session-tab:flex group-hover/session-tab:h-5 group-hover/session-tab:w-5 group-hover/session-tab:opacity-100', - menuOpen && 'ml-1.5 flex h-5 w-5 opacity-100', + 'group-hover/session-tab:flex group-hover/session-tab:opacity-100', + menuOpen && 'flex opacity-100', )} > @@ -298,7 +306,7 @@ export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ chil
= ({ chil
{children}
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index c487cd09..d37798cc 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1781,3 +1781,25 @@ html.desktop-runtime [class*="cursor-pointer"] { html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] { cursor: default; } + +/* Header session tabs: thin separators between inactive neighbours, hidden + around the active or hovered tab (mirrors the titlebar tab language). */ +.session-tab-slot { + position: relative; +} +.session-tab-slot:not(:first-child):not([data-active='true'])::before { + content: ''; + position: absolute; + top: 8px; + inset-inline-start: -2.75px; + width: 1.5px; + height: 12px; + border-radius: 9999px; + background: var(--border); + opacity: 0.6; +} +.session-tab-slot[data-active='true'] + .session-tab-slot::before, +.session-tab-slot:not([data-active='true']):hover::before, +.session-tab-slot:not([data-active='true']):hover + .session-tab-slot::before { + display: none; +} From 97ddab73a188ceceefb332483bb1413236a1d8e4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 19:08:33 +0300 Subject: [PATCH 19/66] fix(header): session tabs span to the right controls, close buttons, even padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old flex spacer split the header in half, boxing the strip into the middle while tabs shrank to slivers; the spacer now renders only for the VS Code and surface-title layouts, tabs keep a fixed width (w-44) and the strip scrolls behind the right-side buttons. Every tab gains a hover-revealed close button next to the menu; the active tab's menu is hover-revealed too (it was always visible) — the header passes it into the strip so both overlays behave identically. The active tab's inner padding matches inactive tabs. --- packages/ui/src/components/layout/Header.tsx | 131 +++++++++--------- .../components/layout/SessionTabsStrip.tsx | 99 ++++++++++--- 2 files changed, 142 insertions(+), 88 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 39245efd..526f6ee3 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1733,8 +1733,68 @@ export const Header: React.FC = () => { ) : null} - -
+ { + if (!open && pendingHeaderRenameRef.current) { + pendingHeaderRenameRef.current = false; + beginHeaderSessionRename(); + } + }} + > + + + + + { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} + {t('sessions.sidebar.session.menu.copyId')} + + {currentSession?.shareUrl ? ( + <> + {t('sessions.sidebar.session.menu.copyLink')} + void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} + + ) : ( + void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} + )} + void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} + {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( + + + + + + {t('sessions.sidebar.session.menu.moveToWorktree')} + + + + + {isCurrentSessionMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isCurrentSessionActive + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltip')} + + + ) : null} + + setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} + setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} + + + ) : null} + > +
{isRenamingHeaderSession ? (
{ )}
-
- {currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? ( - { - if (!open && pendingHeaderRenameRef.current) { - pendingHeaderRenameRef.current = false; - beginHeaderSessionRename(); - } - }} - > - - - - - { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} - {t('sessions.sidebar.session.menu.copyId')} - - {currentSession?.shareUrl ? ( - <> - {t('sessions.sidebar.session.menu.copyLink')} - void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} - - ) : ( - void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} - )} - void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} - {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( - - - - - - {t('sessions.sidebar.session.menu.moveToWorktree')} - - - - - {isCurrentSessionMovingToWorktree - ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') - : isCurrentSessionActive - ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') - : t('sessions.sidebar.session.moveToWorktree.tooltip')} - - - ) : null} - - setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} - setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} - - - ) : null} -
)} -
+ {activeSurfaceHeader || isVSCode ?
: null}
{showDesktopHeaderContextUsage && stableDesktopContextUsage ? ( diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index 01b77eca..51df891e 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -37,15 +37,24 @@ const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); /** * Sortable shell for the active tab: the pill itself drags, while the * interactive content inside (rename form, menu) stops pointer-down so a text - * selection or menu click never starts a drag. + * selection or menu click never starts a drag. The close button and the + * session menu (passed down from the header) reveal on hover, exactly like on + * inactive tabs. */ -const ActiveTabShell: React.FC<{ id: string; children: React.ReactNode }> = ({ id, children }) => { +const ActiveTabShell: React.FC<{ + id: string; + menu: React.ReactNode; + menuOpen: boolean; + onClose: () => void; + closeLabel: string; + children: React.ReactNode; +}> = ({ id, menu, menuOpen, onClose, closeLabel, children }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); return (
= ({ i
- {children} +
+ {children} +
+
event.stopPropagation()} + className={cn( + 'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5', + 'opacity-0 transition-opacity duration-150', + 'group-hover/session-tab:flex group-hover/session-tab:opacity-100', + menuOpen && 'flex opacity-100', + )} + > + + {menu} +
); @@ -86,7 +116,7 @@ const InactiveSessionTab: React.FC<{
{title} +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + className={cn( + 'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5', + 'opacity-0 transition-opacity duration-150', + 'group-hover/session-tab:flex group-hover/session-tab:opacity-100', + menuOpen && 'flex opacity-100', + )} + > + @@ -157,6 +198,7 @@ const InactiveSessionTab: React.FC<{ +
); @@ -173,7 +215,13 @@ const InactiveSessionTab: React.FC<{ * in the store but do not render, so a partial session list never destroys * the working set. */ -export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const SessionTabsStrip: React.FC<{ + /** The header's session menu for the active tab (already a DropdownMenu). */ + menu?: React.ReactNode; + /** Whether that menu is open, so the hover overlay stays visible. */ + menuOpen?: boolean; + children: React.ReactNode; +}> = ({ menu = null, menuOpen = false, children }) => { const { t } = useI18n(); const tabIds = useSessionTabsStore((state) => state.tabIds); const ensureTab = useSessionTabsStore((state) => state.ensureTab); @@ -284,7 +332,18 @@ export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ chil const renderTab = (tab: SessionTab) => { if (tab.id === currentSessionId) { - return {children}; + return ( + handleClose(tab.id)} + closeLabel={t('header.sessionTabs.closeTab')} + > + {children} + + ); } return ( = ({ chil
- {children} +
{children}
) : null}
From 338dc73e962e893e7ca128897c908c7f762c5804 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 19:24:17 +0300 Subject: [PATCH 20/66] fix(header): one session-tab menu, hidden scrollbar, right-click, control order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip now owns a single dropdown per tab, fed by the header with items bound to that tab's session — rename (activates the tab first), copy id, share/copy link/unshare, export and move-to-worktree (active tab only, they need the loaded directory), close other tabs, archive and delete with the confirm dialog targeting the right session. The separate inactive-tab menu is gone, and there is no Close item — the tab's close button covers it, now placed after the menu button. Right-click opens that menu without activating the tab. The menu's anchor overlay stays mounted until the close animation finishes, which removes the popup flashing in the top-left corner on close. The scroller hides its scrollbar via a dedicated CSS class (the bar was shifting the header content vertically). --- packages/ui/src/components/layout/Header.tsx | 194 ++++++------ .../components/layout/SessionTabsStrip.tsx | 280 ++++++++---------- packages/ui/src/index.css | 12 + 3 files changed, 235 insertions(+), 251 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 526f6ee3..97259ea1 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -49,7 +49,7 @@ import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; -import { SessionTabsStrip } from './SessionTabsStrip'; +import { SessionTabsStrip, type SessionTabMenuArgs } from './SessionTabsStrip'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop'; import { desktopHostsGet, redactSensitiveUrl } from '@/lib/desktopHosts'; import { @@ -928,7 +928,7 @@ export const Header: React.FC = () => { const [isHeaderSessionMenuOpen, setIsHeaderSessionMenuOpen] = React.useState(false); const pendingHeaderRenameRef = React.useRef(false); const [headerSessionTitleDraft, setHeaderSessionTitleDraft] = React.useState(''); - const [pendingHeaderRetentionAction, setPendingHeaderRetentionAction] = React.useState<'archive' | 'delete' | null>(null); + const [pendingHeaderRetentionAction, setPendingHeaderRetentionAction] = React.useState<{ action: 'archive' | 'delete'; sessionId: string } | null>(null); const headerRenameFormRef = React.useRef(null); React.useEffect(() => { @@ -966,18 +966,18 @@ export const Header: React.FC = () => { return () => document.removeEventListener('mousedown', handleDocumentMouseDown); }, [isRenamingHeaderSession, saveHeaderSessionRename]); - const copyCurrentSessionId = React.useCallback(() => { - if (!currentSessionId) return; - void copyTextToClipboard(currentSessionId).then((result) => { + const copySessionIdFor = React.useCallback((sessionId: string) => { + if (!sessionId) return; + void copyTextToClipboard(sessionId).then((result) => { toast[result.ok ? 'success' : 'error'](t(result.ok ? 'sessions.sidebar.session.copyId.success' : 'sessions.sidebar.session.copyId.error')); }).catch(() => toast.error(t('sessions.sidebar.session.copyId.error'))); - }, [currentSessionId, t]); + }, [t]); - const shareCurrentSession = React.useCallback(async () => { - if (!currentSessionId) return; - const result = await shareSession(currentSessionId); + const shareSessionFor = React.useCallback(async (sessionId: string) => { + if (!sessionId) return; + const result = await shareSession(sessionId); if (result?.share?.url) { const copied = await copyTextToClipboard(result.share.url); toast[copied.ok ? 'success' : 'warning'](t('sessions.sidebar.session.share.successTitle'), { @@ -988,25 +988,24 @@ export const Header: React.FC = () => { return; } toast.error(t('sessions.sidebar.session.share.error')); - }, [currentSessionId, shareSession, t]); + }, [shareSession, t]); - const copyCurrentSessionShareUrl = React.useCallback(() => { - const shareUrl = currentSession?.shareUrl; + const copySessionShareUrl = React.useCallback((shareUrl: string | null | undefined) => { if (!shareUrl) return; void copyTextToClipboard(shareUrl).then((result) => { toast[result.ok ? 'success' : 'error'](t(result.ok ? 'sessions.sidebar.session.menu.copied' : 'sessions.sidebar.session.share.copyUrlError')); }).catch(() => toast.error(t('sessions.sidebar.session.share.copyUrlError'))); - }, [currentSession?.shareUrl, t]); + }, [t]); - const unshareCurrentSession = React.useCallback(async () => { - if (!currentSessionId) return; - const result = await unshareSession(currentSessionId); + const unshareSessionFor = React.useCallback(async (sessionId: string) => { + if (!sessionId) return; + const result = await unshareSession(sessionId); toast[result ? 'success' : 'error'](t(result ? 'sessions.sidebar.session.unshare.success' : 'sessions.sidebar.session.unshare.error')); - }, [currentSessionId, t, unshareSession]); + }, [t, unshareSession]); const exportCurrentSession = React.useCallback(async () => { if (!currentSessionId || !openDirectory) { @@ -1059,9 +1058,9 @@ export const Header: React.FC = () => { }, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]); const confirmHeaderRetentionAction = React.useCallback(async () => { - if (!currentSessionId || !pendingHeaderRetentionAction) return; + if (!pendingHeaderRetentionAction) return; const sessions = useGlobalSessionsStore.getState().activeSessions; - const ids = [currentSessionId]; + const ids = [pendingHeaderRetentionAction.sessionId]; for (let index = 0; index < ids.length; index += 1) { const parentId = ids[index]; for (const session of sessions) { @@ -1070,7 +1069,7 @@ export const Header: React.FC = () => { } } } - const action = pendingHeaderRetentionAction; + const action = pendingHeaderRetentionAction.action; setPendingHeaderRetentionAction(null); const result = action === 'archive' ? await archiveSessions(ids) : await deleteSessions(ids); const failedIds = result.failedIds; @@ -1083,7 +1082,7 @@ export const Header: React.FC = () => { toast.success(t(action === 'archive' ? 'sessions.sidebar.session.archive.success' : 'sessions.sidebar.session.delete.success')); - }, [archiveSessions, currentSessionId, deleteSessions, pendingHeaderRetentionAction, t]); + }, [archiveSessions, deleteSessions, pendingHeaderRetentionAction, t]); // Full-page surfaces (Scheduled, Archive, Worktrees, Multi-run) replace the // chat area; while one is open the header shows the surface identity @@ -1531,6 +1530,75 @@ export const Header: React.FC = () => { const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId)); + const renderSessionTabMenu = React.useCallback(({ session, isActive, select, closeOtherTabs }: SessionTabMenuArgs) => { + const shareUrl = session.share?.url ?? null; + const canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId; + return ( + <> + { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> + {t('sessions.sidebar.session.menu.rename')} + + copySessionIdFor(session.id)}> + {t('sessions.sidebar.session.menu.copyId')} + + + {shareUrl ? ( + <> + copySessionShareUrl(shareUrl)}> + {t('sessions.sidebar.session.menu.copyLink')} + + void unshareSessionFor(session.id)}> + {t('sessions.sidebar.session.menu.unshare')} + + + ) : ( + void shareSessionFor(session.id)}> + {t('sessions.sidebar.session.menu.share')} + + )} + {isActive ? ( + void exportCurrentSession()}> + {t('sessions.sidebar.session.menu.exportMarkdown')} + + ) : null} + {canMoveToWorktree ? ( + + + + + + {t('sessions.sidebar.session.menu.moveToWorktree')} + + + + + {isCurrentSessionMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isCurrentSessionActive + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltip')} + + + ) : null} + + + {t('header.sessionTabs.closeOtherTabs')} + + + setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}> + {t('sessions.sidebar.bulkActions.archive')} + + setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}> + {t('sessions.sidebar.bulkActions.delete')} + + + ); + }, [copySessionIdFor, copySessionShareUrl, currentSession, exportCurrentSession, isChatContext, isCurrentSessionActive, isCurrentSessionMovingToWorktree, isVSCode, moveCurrentSessionToWorktree, sessionDirectory, shareSessionFor, t, unshareSessionFor]); + const renderDesktop = () => (
{ { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} - {t('sessions.sidebar.session.menu.copyId')} + currentSessionId && copySessionIdFor(currentSessionId)}>{t('sessions.sidebar.session.menu.copyId')} {currentSession?.shareUrl ? ( <> - {t('sessions.sidebar.session.menu.copyLink')} - void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} + copySessionShareUrl(currentSession?.shareUrl)}>{t('sessions.sidebar.session.menu.copyLink')} + { if (currentSessionId) void unshareSessionFor(currentSessionId); }}>{t('sessions.sidebar.session.menu.unshare')} ) : ( - void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} + { if (currentSessionId) void shareSessionFor(currentSessionId); }}>{t('sessions.sidebar.session.menu.share')} )} void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( @@ -1713,8 +1781,8 @@ export const Header: React.FC = () => { ) : null} - setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} - setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} + { if (currentSessionId) setPendingHeaderRetentionAction({ action: 'archive', sessionId: currentSessionId }); }}>{t('sessions.sidebar.bulkActions.archive')} + { if (currentSessionId) setPendingHeaderRetentionAction({ action: 'delete', sessionId: currentSessionId }); }}>{t('sessions.sidebar.bulkActions.delete')} ) : null} @@ -1734,65 +1802,13 @@ export const Header: React.FC = () => { ) : null} { - if (!open && pendingHeaderRenameRef.current) { - pendingHeaderRenameRef.current = false; - beginHeaderSessionRename(); - } - }} - > - - - - - { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} - {t('sessions.sidebar.session.menu.copyId')} - - {currentSession?.shareUrl ? ( - <> - {t('sessions.sidebar.session.menu.copyLink')} - void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} - - ) : ( - void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} - )} - void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} - {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( - - - - - - {t('sessions.sidebar.session.menu.moveToWorktree')} - - - - - {isCurrentSessionMovingToWorktree - ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') - : isCurrentSessionActive - ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') - : t('sessions.sidebar.session.moveToWorktree.tooltip')} - - - ) : null} - - setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} - setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} - - - ) : null} + renderMenu={renderSessionTabMenu} + onMenuOpenChangeComplete={(open) => { + if (!open && pendingHeaderRenameRef.current) { + pendingHeaderRenameRef.current = false; + beginHeaderSessionRename(); + } + }} >
{isRenamingHeaderSession ? ( @@ -1933,10 +1949,10 @@ export const Header: React.FC = () => { { if (!open) setPendingHeaderRetentionAction(null); }}> - {pendingHeaderRetentionAction === 'delete' + {pendingHeaderRetentionAction?.action === 'delete' ? t('sessions.sidebar.dialogs.deleteSession.title') : t('sessions.sidebar.dialogs.archiveSession.title')} - {pendingHeaderRetentionAction === 'delete' + {pendingHeaderRetentionAction?.action === 'delete' ? t('sessions.sidebar.dialogs.deleteSession.single', { sessionTitle: currentSessionTitle }) : t('sessions.sidebar.dialogs.archiveSession.single', { sessionTitle: currentSessionTitle })} @@ -1945,7 +1961,7 @@ export const Header: React.FC = () => { {t('sessions.sidebar.dialogs.cancel')} diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index 51df891e..e75f0dc4 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -20,113 +20,75 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; -import { copyTextToClipboard } from '@/lib/clipboard'; import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); +type SessionTab = { id: string; session: Session }; + +export type SessionTabMenuArgs = { + session: Session; + isActive: boolean; + select: () => void; + closeOtherTabs: () => void; +}; + /** - * Sortable shell for the active tab: the pill itself drags, while the - * interactive content inside (rename form, menu) stops pointer-down so a text - * selection or menu click never starts a drag. The close button and the - * session menu (passed down from the header) reveal on hover, exactly like on - * inactive tabs. + * One tab, active or not. The tab drags to reorder; the menu and close + * controls sit in a hover-revealed overlay at the tab's end (menu first, + * close after it). The single session menu is supplied by the header via + * `renderMenu`, bound to this tab's session; right-click opens it without + * changing which tab is active. The overlay stays visible until the menu's + * close animation completes, so the popup never loses its anchor mid-flight + * (that was the top-left corner flash). */ -const ActiveTabShell: React.FC<{ - id: string; - menu: React.ReactNode; - menuOpen: boolean; - onClose: () => void; - closeLabel: string; - children: React.ReactNode; -}> = ({ id, menu, menuOpen, onClose, closeLabel, children }) => { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); +const SessionTabItem: React.FC<{ + tab: SessionTab; + isActive: boolean; + onSelect: (tab: SessionTab) => void; + onClose: (id: string) => void; + renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; + closeOtherTabs: (id: string) => void; + onMenuOpenChangeComplete?: (open: boolean) => void; + children?: React.ReactNode; +}> = ({ tab, isActive, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => { + const { t } = useI18n(); + const [menuOpen, setMenuOpen] = React.useState(false); + // Keeps the overlay (the menu's anchor) mounted through the close animation. + const [menuVisible, setMenuVisible] = React.useState(false); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); + + const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); + const overlayVisible = menuOpen || menuVisible; + + const openMenu = React.useCallback(() => { + setMenuVisible(true); + setMenuOpen(true); + }, []); + return (
-
- {children} -
-
event.stopPropagation()} - className={cn( - 'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5', - 'opacity-0 transition-opacity duration-150', - 'group-hover/session-tab:flex group-hover/session-tab:opacity-100', - menuOpen && 'flex opacity-100', - )} - > - - {menu} -
-
-
- ); -}; - -type SessionTab = { id: string; session: Session }; - -/** - * One inactive tab: a soft pill with the session title. The "..." menu trigger - * has no reserved footprint — it appears at the tab's end on hover (or while - * its menu is open), nudging the title, mirroring the sidebar row mechanic. - * The reveal itself is opacity-only; the layout change is instant. - */ -const InactiveSessionTab: React.FC<{ - tab: SessionTab; - onSelect: (tab: SessionTab) => void; - onClose: (id: string) => void; - onCloseOthers: (id: string) => void; -}> = ({ tab, onSelect, onClose, onCloseOthers }) => { - const { t } = useI18n(); - const [menuOpen, setMenuOpen] = React.useState(false); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); - - const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); - - return ( -
-
onSelect(tab)} - onKeyDown={(event) => { + aria-selected={isActive} + tabIndex={isActive ? undefined : 0} + onClick={isActive ? undefined : () => onSelect(tab)} + onKeyDown={isActive ? undefined : (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(tab); @@ -138,22 +100,27 @@ const InactiveSessionTab: React.FC<{ onClose(tab.id); } }} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + openMenu(); + }} className={cn( - 'group/session-tab relative flex h-7 w-full min-w-0 cursor-pointer touch-none select-none items-center rounded-md px-2', - 'text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', - menuOpen && 'bg-interactive-hover text-foreground', + 'group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', + isActive + ? 'bg-interactive-selection' + : cn( + 'cursor-pointer text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', + overlayVisible && 'bg-interactive-hover text-foreground', + ), )} - title={title} + title={isActive ? undefined : title} > - + {isActive ? children : ( + {title} )} - > - {title} - +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} @@ -161,9 +128,38 @@ const InactiveSessionTab: React.FC<{ 'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5', 'opacity-0 transition-opacity duration-150', 'group-hover/session-tab:flex group-hover/session-tab:opacity-100', - menuOpen && 'flex opacity-100', + overlayVisible && 'flex opacity-100', )} > + { + setMenuOpen(open); + if (open) setMenuVisible(true); + }} + onOpenChangeComplete={(open) => { + if (!open) setMenuVisible(false); + onMenuOpenChangeComplete?.(open); + }} + > + + + + + {renderMenu({ + session: tab.session, + isActive, + select: () => onSelect(tab), + closeOtherTabs: () => closeOtherTabs(tab.id), + })} + + - - - - - - onClose(tab.id)}> - - {t('header.sessionTabs.closeTab')} - - onCloseOthers(tab.id)}> - - {t('header.sessionTabs.closeOtherTabs')} - - - void copyTextToClipboard(tab.id)}> - - {t('sessions.sidebar.session.menu.copyId')} - - -
@@ -208,20 +178,19 @@ const InactiveSessionTab: React.FC<{ * The header's horizontal working set of sessions (web/desktop only). * * Every session the user opens joins the strip once; the tab whose session is - * current renders `children` — the header's existing title block with rename, - * meta row and the full session menu — inside a softly selected pill. Closing - * a tab only removes it from the strip; closing the active one activates its - * neighbour. Ids whose session has not loaded (or was archived/deleted) stay - * in the store but do not render, so a partial session list never destroys - * the working set. + * current renders `children` — the header's title/rename block — inside a + * selected pill. Closing a tab only removes it from the strip; closing the + * active one activates its neighbour. Ids whose session has not loaded (or + * was archived/deleted) stay in the store but do not render, so a partial + * session list never destroys the working set. */ export const SessionTabsStrip: React.FC<{ - /** The header's session menu for the active tab (already a DropdownMenu). */ - menu?: React.ReactNode; - /** Whether that menu is open, so the hover overlay stays visible. */ - menuOpen?: boolean; + /** Menu items for one tab's session, supplied by the header. */ + renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; + /** Fires when a tab menu finishes opening/closing (deferred rename hook). */ + onMenuOpenChangeComplete?: (open: boolean) => void; children: React.ReactNode; -}> = ({ menu = null, menuOpen = false, children }) => { +}> = ({ renderMenu, onMenuOpenChangeComplete, children }) => { const { t } = useI18n(); const tabIds = useSessionTabsStore((state) => state.tabIds); const ensureTab = useSessionTabsStore((state) => state.ensureTab); @@ -330,32 +299,6 @@ export const SessionTabsStrip: React.FC<{ const tabIdsInOrder = React.useMemo(() => tabs.map((tab) => tab.id), [tabs]); - const renderTab = (tab: SessionTab) => { - if (tab.id === currentSessionId) { - return ( - handleClose(tab.id)} - closeLabel={t('header.sessionTabs.closeTab')} - > - {children} - - ); - } - return ( - - ); - }; - // A brand-new draft (no session yet) shows as a transient active pill after // the tabs; it becomes a real tab once the first message creates the session. const showDraftPill = !currentSessionId || !tabs.some((tab) => tab.id === currentSessionId); @@ -365,7 +308,7 @@ export const SessionTabsStrip: React.FC<{
- {tabs.map(renderTab)} + {tabs.map((tab) => ( + + {tab.id === currentSessionId ? children : null} + + ))} {showDraftPill ? ( diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index d37798cc..1cec5c79 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1803,3 +1803,15 @@ html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] { .session-tab-slot:not([data-active='true']):hover + .session-tab-slot::before { display: none; } + +/* Header session tabs scroller: never show a scrollbar (it shifts the header + content vertically); overflow is communicated by the edge fades. */ +.session-tabs-scroll { + scrollbar-width: none; + -ms-overflow-style: none; +} +.session-tabs-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} From c47c7190f45b5ab2a802d15ee00c2f589888ef69 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:02:59 +0300 Subject: [PATCH 21/66] feat(header): session tab tooltips, status dots, cursor context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click now opens the session menu under the cursor (the sidebar's context-menu pattern; the same header-supplied items back both the "..." dropdown and the context menu via injected menu primitives) and still never changes the active tab. Hovering a tab shows the sidebar's session tooltip — title, last-activity time, project, branch and PR status — after a delay, suppressed while a menu is open or a drag is in flight. Each tab carries the sidebar's status dot at its end (accent while the session runs, info-blue for unread), hidden while the hover controls overlay it. Tab titles fade out instead of ending in "...", and while the active tab is renaming its hover controls stay hidden so only the rename controls show. --- .../chat/markdown/markdown-worker.ts | 58 ++- packages/ui/src/components/layout/Header.tsx | 50 +-- .../components/layout/SessionTabsStrip.tsx | 379 +++++++++++++----- packages/ui/src/index.css | 16 + packages/vscode/src/DOCUMENTATION.md | 2 +- 5 files changed, 366 insertions(+), 139 deletions(-) diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 2feb270a..b93eb6dc 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,4 +1,5 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; +import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime'; import { contentFingerprint, estimateTokenRunsBytes, @@ -46,6 +47,8 @@ const resultCache = new HighlightResultCache({ const inflight = new Map>(); let worker: Worker | undefined; +let workerCreation: Promise | undefined; +let workerObjectUrl: string | undefined; let nextId = 0; const pending = new Map(); // Theme names whose full definition we've already shipped to the live worker, so @@ -71,31 +74,56 @@ const failAll = (): void => { inflight.clear(); worker?.terminate(); worker = undefined; + workerCreation = undefined; + if (workerObjectUrl) { + URL.revokeObjectURL(workerObjectUrl); + workerObjectUrl = undefined; + } }; -const getWorker = (): Worker | undefined => { - if (worker) return worker; +const createWorker = async (): Promise => { if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined; try { - worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' }); + let workerUrl = MarkdownShikiWorkerUrl; + if (isVSCodeRuntime(null)) { + const response = await fetch(workerUrl); + if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`); + workerObjectUrl = URL.createObjectURL(await response.blob()); + workerUrl = workerObjectUrl; + } + + const instance = new Worker(workerUrl, { type: 'module' }); + worker = instance; + instance.onmessage = (event: MessageEvent) => { + const resolve = pending.get(event.data.id); + if (!resolve) return; + pending.delete(event.data.id); + resolve(event.data); + }; + instance.onerror = failAll; + instance.onmessageerror = failAll; + instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest); + return instance; } catch (err) { + if (workerObjectUrl) { + URL.revokeObjectURL(workerObjectUrl); + workerObjectUrl = undefined; + } console.error('Failed to create Shiki worker:', err); return undefined; } - worker.onmessage = (event: MessageEvent) => { - const resolve = pending.get(event.data.id); - if (!resolve) return; - pending.delete(event.data.id); - resolve(event.data); - }; - worker.onerror = failAll; - worker.onmessageerror = failAll; - worker.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest); - return worker; }; -const request = (payload: (id: number) => MarkdownWorkerRequest): Promise => { - const instance = getWorker(); +const getWorker = async (): Promise => { + if (worker) return worker; + workerCreation ??= createWorker().finally(() => { + workerCreation = undefined; + }); + return workerCreation; +}; + +const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise => { + const instance = await getWorker(); if (!instance) return Promise.resolve(null); const id = ++nextId; return new Promise((resolve) => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 97259ea1..8ab2bc69 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1530,49 +1530,50 @@ export const Header: React.FC = () => { const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId)); - const renderSessionTabMenu = React.useCallback(({ session, isActive, select, closeOtherTabs }: SessionTabMenuArgs) => { + const renderSessionTabMenu = React.useCallback(({ session, isActive, select, closeOtherTabs, components }: SessionTabMenuArgs) => { + const { Item, Separator } = components; const shareUrl = session.share?.url ?? null; const canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId; return ( <> - { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> + { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> {t('sessions.sidebar.session.menu.rename')} - - copySessionIdFor(session.id)}> + + copySessionIdFor(session.id)}> {t('sessions.sidebar.session.menu.copyId')} - - + + {shareUrl ? ( <> - copySessionShareUrl(shareUrl)}> + copySessionShareUrl(shareUrl)}> {t('sessions.sidebar.session.menu.copyLink')} - - void unshareSessionFor(session.id)}> + + void unshareSessionFor(session.id)}> {t('sessions.sidebar.session.menu.unshare')} - + ) : ( - void shareSessionFor(session.id)}> + void shareSessionFor(session.id)}> {t('sessions.sidebar.session.menu.share')} - + )} {isActive ? ( - void exportCurrentSession()}> + void exportCurrentSession()}> {t('sessions.sidebar.session.menu.exportMarkdown')} - + ) : null} {canMoveToWorktree ? ( - {t('sessions.sidebar.session.menu.moveToWorktree')} - + @@ -1584,17 +1585,17 @@ export const Header: React.FC = () => { ) : null} - - + + {t('header.sessionTabs.closeOtherTabs')} - - - setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}> + + + setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}> {t('sessions.sidebar.bulkActions.archive')} - - setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}> + + setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}> {t('sessions.sidebar.bulkActions.delete')} - + ); }, [copySessionIdFor, copySessionShareUrl, currentSession, exportCurrentSession, isChatContext, isCurrentSessionActive, isCurrentSessionMovingToWorktree, isVSCode, moveCurrentSessionToWorktree, sessionDirectory, shareSessionFor, t, unshareSessionFor]); @@ -1803,6 +1804,7 @@ export const Header: React.FC = () => { ) : null} { if (!open && pendingHeaderRenameRef.current) { pendingHeaderRenameRef.current = false; diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index e75f0dc4..a701774c 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -15,63 +15,172 @@ import { useSortable, } from '@dnd-kit/sortable'; import { CSS as DndCSS } from '@dnd-kit/utilities'; +import { ContextMenu } from '@base-ui/react/context-menu'; import type { Session } from '@opencode-ai/sdk/v2'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass } from '@/components/ui/dropdown-menu.styles'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useGlobalSessionStatus } from '@/sync/sync-context'; +import { useSessionUnseenCount } from '@/sync/notification-store'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useGitAllBranches } from '@/stores/useGitStore'; +import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; +import { + formatProjectLabel, + formatSessionCompactDateLabel, + formatSessionDateLabel, + normalizePath, +} from '@/components/session/sidebar/utils'; const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); type SessionTab = { id: string; session: Session }; +export type SessionTabMenuComponents = { + Item: React.ComponentType<{ + className?: string; + disabled?: boolean; + onClick?: React.MouseEventHandler; + children?: React.ReactNode; + }>; + Separator: React.ComponentType<{ className?: string }>; +}; + export type SessionTabMenuArgs = { session: Session; isActive: boolean; select: () => void; closeOtherTabs: () => void; + /** Menu primitives for the surface the menu opens in (dropdown or context menu). */ + components: SessionTabMenuComponents; }; +const dropdownComponents: SessionTabMenuComponents = { + Item: DropdownMenuItem, + Separator: DropdownMenuSeparator, +}; + +const contextComponents: SessionTabMenuComponents = { + Item: ({ className, ...props }) => ( + + ), + Separator: ({ className, ...props }) => ( + + ), +}; + +/** Resolve the project a session directory belongs to, for the hover tooltip. */ +const useTabProjectLabel = (directory: string | null): string | null => + useProjectsStore(React.useCallback((state) => { + if (!directory) return null; + const dir = normalizePath(directory); + if (!dir) return null; + for (const project of state.projects) { + const path = normalizePath(project.path); + if (path && (dir === path || dir.startsWith(`${path}/`))) { + return formatProjectLabel(project.label?.trim() || path.split('/').pop() || path); + } + } + return null; + }, [directory])); + /** * One tab, active or not. The tab drags to reorder; the menu and close * controls sit in a hover-revealed overlay at the tab's end (menu first, - * close after it). The single session menu is supplied by the header via - * `renderMenu`, bound to this tab's session; right-click opens it without - * changing which tab is active. The overlay stays visible until the menu's - * close animation completes, so the popup never loses its anchor mid-flight - * (that was the top-left corner flash). + * close after it). One session menu — supplied by the header via + * `renderMenu` — backs both the "..." dropdown and the right-click context + * menu, which opens under the cursor without changing the active tab. The + * dropdown's anchor overlay stays mounted through the close animation so the + * popup never flashes detached. While the active tab is renaming, the + * overlay is suppressed entirely — only the rename controls show. */ const SessionTabItem: React.FC<{ tab: SessionTab; isActive: boolean; + suppressControls: boolean; onSelect: (tab: SessionTab) => void; onClose: (id: string) => void; renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; closeOtherTabs: (id: string) => void; onMenuOpenChangeComplete?: (open: boolean) => void; children?: React.ReactNode; -}> = ({ tab, isActive, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => { +}> = ({ tab, isActive, suppressControls, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => { const { t } = useI18n(); const [menuOpen, setMenuOpen] = React.useState(false); - // Keeps the overlay (the menu's anchor) mounted through the close animation. + // Keeps the overlay (the dropdown's anchor) mounted through the close animation. const [menuVisible, setMenuVisible] = React.useState(false); + const [contextMenuOpen, setContextMenuOpen] = React.useState(false); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); - const overlayVisible = menuOpen || menuVisible; + const overlayVisible = !suppressControls && (menuOpen || menuVisible); + const anyMenuOpen = menuOpen || contextMenuOpen; - const openMenu = React.useCallback(() => { - setMenuVisible(true); - setMenuOpen(true); - }, []); + // Session state for the dot and the hover tooltip. + const sessionStatus = useGlobalSessionStatus(tab.id); + const isStreaming = sessionStatus?.type === 'busy' || sessionStatus?.type === 'retry'; + const unseenCount = useSessionUnseenCount(tab.id); + const showUnread = unseenCount > 0 && !isActive && !isStreaming; + const showDot = isStreaming || showUnread; + const dotLabel = isStreaming + ? t('sessions.sidebar.session.status.active') + : t('sessions.sidebar.session.status.unread'); + + const directory = normalizePath(resolveGlobalSessionDirectory(tab.session) ?? null); + const projectLabel = useTabProjectLabel(directory); + const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); + const allBranches = useGitAllBranches(); + const branchLabel = React.useMemo(() => { + const meta = worktreeMetadata.get(tab.id); + if (meta?.branch?.trim()) return meta.branch.trim(); + if (directory) return allBranches.get(directory)?.trim() || null; + return null; + }, [worktreeMetadata, allBranches, tab.id, directory]); + const prSummary = usePrVisualSummary(directory && branchLabel ? getGitHubPrStatusKey(directory, branchLabel) : null); + const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; + const prStatusLabel = React.useMemo(() => { + if (!prSummary) return null; + switch (prSummary.visualState) { + case 'merged': + return t('sessions.sidebar.group.pr.status.merged'); + case 'open': + return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') + ? t('sessions.sidebar.group.pr.status.readyToMerge') + : t('sessions.sidebar.group.pr.status.open'); + case 'blocked': + return prSummary.mergeableState === 'dirty' + ? t('sessions.sidebar.group.pr.status.mergeConflicts') + : t('sessions.sidebar.group.pr.status.mergeBlocked'); + case 'draft': + return t('sessions.sidebar.group.pr.status.draft'); + case 'closed': + return t('sessions.sidebar.group.pr.status.closed'); + default: + return null; + } + }, [prSummary, t]); + const sessionTimestamp = tab.session.time?.updated || tab.session.time?.created || 0; + + const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({ + session: tab.session, + isActive, + select: () => onSelect(tab), + closeOtherTabs: () => closeOtherTabs(tab.id), + components, + }); return (
-
onSelect(tab)} - onKeyDown={isActive ? undefined : (event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onSelect(tab); - } - }} - onAuxClick={(event) => { - if (event.button === 1) { - event.preventDefault(); - onClose(tab.id); - } - }} - onContextMenu={(event) => { - event.preventDefault(); - event.stopPropagation(); - openMenu(); - }} - className={cn( - 'group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', - isActive - ? 'bg-interactive-selection' - : cn( - 'cursor-pointer text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', - overlayVisible && 'bg-interactive-hover text-foreground', - ), - )} - title={isActive ? undefined : title} + onMenuOpenChangeComplete?.(open)} > -
- {isActive ? children : ( - {title} - )} -
-
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - className={cn( - 'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5', - 'opacity-0 transition-opacity duration-150', - 'group-hover/session-tab:flex group-hover/session-tab:opacity-100', - overlayVisible && 'flex opacity-100', - )} - > - { - setMenuOpen(open); - if (open) setMenuVisible(true); - }} - onOpenChangeComplete={(open) => { - if (!open) setMenuVisible(false); - onMenuOpenChangeComplete?.(open); - }} - > - - - - - {renderMenu({ - session: tab.session, - isActive, - select: () => onSelect(tab), - closeOtherTabs: () => closeOtherTabs(tab.id), - })} - - - -
-
+ + + ( +
onSelect(tab)} + onKeyDown={isActive ? undefined : (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(tab); + } + }} + onAuxClick={(event) => { + if (event.button === 1) { + event.preventDefault(); + onClose(tab.id); + } + }} + className={cn( + 'group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', + isActive + ? 'bg-interactive-selection' + : cn( + 'cursor-pointer text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', + overlayVisible && 'bg-interactive-hover text-foreground', + ), + )} + > +
+
+ {isActive ? children : ( + {title} + )} +
+ {showDot ? ( + + ) : null} +
+ {!suppressControls ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + className={cn( + 'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5', + 'opacity-0 transition-opacity duration-150', + 'group-hover/session-tab:flex group-hover/session-tab:opacity-100', + overlayVisible && 'flex opacity-100', + )} + > + { + setMenuOpen(open); + if (open) setMenuVisible(true); + }} + onOpenChangeComplete={(open) => { + if (!open) setMenuVisible(false); + onMenuOpenChangeComplete?.(open); + }} + > + + + + + {renderMenu(menuArgsFor(dropdownComponents))} + + + +
+ ) : null} +
+ )} + /> +
+ {!anyMenuOpen && !isDragging ? ( + +
+
+ {title} + {sessionTimestamp ? ( + + {formatSessionCompactDateLabel(sessionTimestamp)} + + ) : null} +
+ {projectLabel ? ( +
+ + {projectLabel} +
+ ) : null} + {branchLabel ? ( +
+ + {branchLabel} +
+ ) : null} + {prSummary && prStatusLabel ? ( +
+ + + #{prSummary.number} · {prStatusLabel} + +
+ ) : null} +
+
+ ) : null} +
+ + + + {renderMenu(menuArgsFor(contextComponents))} + + + +
); }; @@ -189,8 +367,10 @@ export const SessionTabsStrip: React.FC<{ renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; /** Fires when a tab menu finishes opening/closing (deferred rename hook). */ onMenuOpenChangeComplete?: (open: boolean) => void; + /** While the active tab renames, its hover controls stay hidden. */ + suppressActiveTabControls?: boolean; children: React.ReactNode; -}> = ({ renderMenu, onMenuOpenChangeComplete, children }) => { +}> = ({ renderMenu, onMenuOpenChangeComplete, suppressActiveTabControls = false, children }) => { const { t } = useI18n(); const tabIds = useSessionTabsStore((state) => state.tabIds); const ensureTab = useSessionTabsStore((state) => state.ensureTab); @@ -323,6 +503,7 @@ export const SessionTabsStrip: React.FC<{ key={tab.id} tab={tab} isActive={tab.id === currentSessionId} + suppressControls={tab.id === currentSessionId && suppressActiveTabControls} onSelect={handleSelect} onClose={handleClose} renderMenu={renderMenu} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 1cec5c79..67f694ec 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1336,6 +1336,14 @@ html:not(.dark) .chat-scroll { background: transparent !important; } +/* The marked renderer uses its own code-body wrapper instead of Streamdown's. */ +.markdown-content [data-md-code-body], +.markdown-content [data-md-code-body] pre, +.markdown-content [data-md-code-body] code, +.markdown-content [data-md-code-body] [data-md-code-lines] { + background: transparent !important; +} + .markdown-content [data-md-code-lines] { display: block; min-width: 100%; @@ -1815,3 +1823,11 @@ html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] { width: 0; height: 0; } + +/* Session tab titles: fade out instead of "..." — the ellipsis reads as + clutter next to the tab's status dot and hover controls. Short titles + never reach the fade zone (the span spans the tab, not the text). */ +.session-tab-title { + -webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent); + mask-image: linear-gradient(to right, black calc(100% - 14px), transparent); +} diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index e0de1b91..ccf9e6bc 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -44,7 +44,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`. -The webview build emits each worker as one self-contained file. VS Code webviews cannot load module imports from inside a worker, so allowing worker URLs in the CSP is not enough when Rollup splits Shiki grammars into separate chunks. +The webview build emits each worker as one self-contained file. VS Code webviews cannot load workers directly from extension resource URLs or load module imports from inside a worker. The shared Shiki client therefore fetches the built worker, starts it from a `blob:` URL, and relies on the worker CSP allowance above. - `bridge-localfs-proxy-runtime.ts` - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. From aad9c251646e518641e2b9a7e6d6e30dfca2963c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:11:18 +0300 Subject: [PATCH 22/66] fix(vscode): adjust expanded layout threshold for showing sessions sidebar Keeps enough space for the chat panel when the sessions sidebar stays visible Bases the expanded layout breakpoint on the sidebar width instead of a fixed value --- packages/ui/src/components/layout/VSCodeLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 5ccf8a46..0b81613c 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -55,10 +55,10 @@ const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPr // Width threshold for mobile vs desktop layout in settings const MOBILE_WIDTH_THRESHOLD = 550; -// Width threshold for expanded layout (sidebar + chat side by side) -const EXPANDED_LAYOUT_THRESHOLD = 1400; // Sessions sidebar width in expanded layout const SESSIONS_SIDEBAR_WIDTH = 280; +// Keep enough room for the chat after adding the persistent sessions sidebar. +const EXPANDED_LAYOUT_THRESHOLD = SESSIONS_SIDEBAR_WIDTH + 520; const SESSIONS_SIDEBAR_MIN_WIDTH = Math.round(SESSIONS_SIDEBAR_WIDTH * 0.7); const SESSIONS_SIDEBAR_MAX_WIDTH = 520; From b1023b87e7d6aa7205f31d65b2ffce7e91392366 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:16:06 +0300 Subject: [PATCH 23/66] fix(header): tab title fade is resting-state only; clearer separators The title fade no longer sits on controls: it lifts while the tab is hovered or its controls/menu are open (hard clip instead) and is not applied at all while the active tab renames. The active tab clips its title instead of showing an ellipsis, matching inactive tabs. Tab separators drop the extra transparency that made them nearly invisible. --- packages/ui/src/components/layout/Header.tsx | 2 +- .../ui/src/components/layout/SessionTabsStrip.tsx | 9 +++++++-- packages/ui/src/index.css | 11 +++++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 8ab2bc69..c7000a9d 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1855,7 +1855,7 @@ export const Header: React.FC = () => { ) : ( - + {isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle} )} diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index a701774c..45018366 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -219,8 +219,9 @@ const SessionTabItem: React.FC<{ onClose(tab.id); } }} + data-controls-open={overlayVisible ? 'true' : 'false'} className={cn( - 'group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', + 'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', isActive ? 'bg-interactive-selection' : cn( @@ -235,7 +236,11 @@ const SessionTabItem: React.FC<{ overlayVisible && 'pr-10', )} > -
+
{isActive ? children : ( {title} )} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 67f694ec..ed6c021f 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1804,7 +1804,6 @@ html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] { height: 12px; border-radius: 9999px; background: var(--border); - opacity: 0.6; } .session-tab-slot[data-active='true'] + .session-tab-slot::before, .session-tab-slot:not([data-active='true']):hover::before, @@ -1826,8 +1825,16 @@ html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] { /* Session tab titles: fade out instead of "..." — the ellipsis reads as clutter next to the tab's status dot and hover controls. Short titles - never reach the fade zone (the span spans the tab, not the text). */ + never reach the fade zone (the span spans the tab, not the text). The + fade belongs to the resting state only: while the tab is hovered or its + controls are open, the title clips hard so the smear never sits next to + the menu/close/rename controls. */ .session-tab-title { -webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent); mask-image: linear-gradient(to right, black calc(100% - 14px), transparent); } +.session-tab:hover .session-tab-title, +.session-tab[data-controls-open='true'] .session-tab-title { + -webkit-mask-image: none; + mask-image: none; +} From 4b2ad01b4add8b08aca6c98e3d99e13e5c76cf27 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:18:45 +0300 Subject: [PATCH 24/66] fix(header): inactive-tab rename actually starts; snappier tab state change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename from another tab's menu stored only a boolean that the session-switch reset effect wiped before the menu finished closing, so the tab activated but rename never began. The pending rename now carries the target session id and begins exactly when that session becomes active (whether the menu closes before or after the switch), without the reset effect cancelling it — and without an in-flight rename being cancelled by unrelated re-renders. Tab background/text state changes animate at 75ms so activation reads immediate. --- packages/ui/src/components/layout/Header.tsx | 40 ++++++++++++------- .../components/layout/SessionTabsStrip.tsx | 3 +- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index c7000a9d..265f2354 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -926,18 +926,13 @@ export const Header: React.FC = () => { const deleteSessions = useSessionUIStore((state) => state.deleteSessions); const [isRenamingHeaderSession, setIsRenamingHeaderSession] = React.useState(false); const [isHeaderSessionMenuOpen, setIsHeaderSessionMenuOpen] = React.useState(false); - const pendingHeaderRenameRef = React.useRef(false); + /** Session id whose rename was requested from a tab menu; survives the + activation that a Rename on an inactive tab performs first. */ + const pendingHeaderRenameRef = React.useRef(null); const [headerSessionTitleDraft, setHeaderSessionTitleDraft] = React.useState(''); const [pendingHeaderRetentionAction, setPendingHeaderRetentionAction] = React.useState<{ action: 'archive' | 'delete'; sessionId: string } | null>(null); const headerRenameFormRef = React.useRef(null); - React.useEffect(() => { - pendingHeaderRenameRef.current = false; - setIsHeaderSessionMenuOpen(false); - setIsRenamingHeaderSession(false); - setHeaderSessionTitleDraft(''); - setPendingHeaderRetentionAction(null); - }, [currentSessionId]); const beginHeaderSessionRename = React.useCallback(() => { if (!currentSessionId) return; @@ -945,6 +940,23 @@ export const Header: React.FC = () => { setIsRenamingHeaderSession(true); }, [currentSession?.title, currentSessionId, currentSessionTitle]); + const beginHeaderSessionRenameRef = React.useRef(beginHeaderSessionRename); + beginHeaderSessionRenameRef.current = beginHeaderSessionRename; + + React.useEffect(() => { + setIsHeaderSessionMenuOpen(false); + setPendingHeaderRetentionAction(null); + if (currentSessionId && pendingHeaderRenameRef.current === currentSessionId) { + // Rename on an inactive tab activates it first; the switch itself is + // when the rename can begin (the menu may close before or after it). + pendingHeaderRenameRef.current = null; + beginHeaderSessionRenameRef.current(); + return; + } + setIsRenamingHeaderSession(false); + setHeaderSessionTitleDraft(''); + }, [currentSessionId]); + const saveHeaderSessionRename = React.useCallback(async () => { if (!currentSessionId) return; const title = headerSessionTitleDraft.trim(); @@ -1536,7 +1548,7 @@ export const Header: React.FC = () => { const canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId; return ( <> - { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> + { if (!isActive) select(); pendingHeaderRenameRef.current = session.id; }}> {t('sessions.sidebar.session.menu.rename')} copySessionIdFor(session.id)}> @@ -1734,8 +1746,8 @@ export const Header: React.FC = () => { open={isHeaderSessionMenuOpen} onOpenChange={setIsHeaderSessionMenuOpen} onOpenChangeComplete={(open) => { - if (!open && pendingHeaderRenameRef.current) { - pendingHeaderRenameRef.current = false; + if (!open && pendingHeaderRenameRef.current && pendingHeaderRenameRef.current === currentSessionId) { + pendingHeaderRenameRef.current = null; beginHeaderSessionRename(); } }} @@ -1746,7 +1758,7 @@ export const Header: React.FC = () => { - { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} + { pendingHeaderRenameRef.current = currentSessionId; }}>{t('sessions.sidebar.session.menu.rename')} currentSessionId && copySessionIdFor(currentSessionId)}>{t('sessions.sidebar.session.menu.copyId')} {currentSession?.shareUrl ? ( @@ -1806,8 +1818,8 @@ export const Header: React.FC = () => { renderMenu={renderSessionTabMenu} suppressActiveTabControls={isRenamingHeaderSession} onMenuOpenChangeComplete={(open) => { - if (!open && pendingHeaderRenameRef.current) { - pendingHeaderRenameRef.current = false; + if (!open && pendingHeaderRenameRef.current && pendingHeaderRenameRef.current === currentSessionId) { + pendingHeaderRenameRef.current = null; beginHeaderSessionRename(); } }} diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index 45018366..f4bbe964 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -222,10 +222,11 @@ const SessionTabItem: React.FC<{ data-controls-open={overlayVisible ? 'true' : 'false'} className={cn( 'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2', + 'transition-colors duration-75', isActive ? 'bg-interactive-selection' : cn( - 'cursor-pointer text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground', + 'cursor-pointer text-muted-foreground hover:bg-interactive-hover hover:text-foreground', overlayVisible && 'bg-interactive-hover text-foreground', ), )} From a53c78b54abccd7443e76b26f4d724b711a43cb4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:37:25 +0300 Subject: [PATCH 25/66] fix: adjust light theme selection color --- packages/ui/src/lib/theme/themes/openchamber-light.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/lib/theme/themes/openchamber-light.json b/packages/ui/src/lib/theme/themes/openchamber-light.json index 50b9780e..d1854ff2 100644 --- a/packages/ui/src/lib/theme/themes/openchamber-light.json +++ b/packages/ui/src/lib/theme/themes/openchamber-light.json @@ -37,7 +37,7 @@ "border": "#e5e1de", "borderHover": "#cbc7c2", "borderFocus": "#b35017", - "selection": "#b350172b", + "selection": "#a9998f2b", "selectionForeground": "#393a34", "focus": "#b35017", "focusRing": "#b3501755", From 275d381bd00a031715a2b5a806a7444eacf18397 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:41:42 +0300 Subject: [PATCH 26/66] docs: changelog entry for header session tabs --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f204ff00..1eec2e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - **Terminal:** terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload now shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name like "solo-is-a" finds the file inside it. +- **Session tabs:** the web/desktop header now shows your open sessions as browser-style tabs — every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many, carry the sidebar's running/unread dot, and show the sidebar's info tooltip (project, branch, PR status) on hover. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Search in dropdowns:** every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). The git branch and gitmoji pickers also stopped silently dropping rows that a second, built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way now. - Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. - Mobile: narrowing a browser window past phone size now switches into the mobile app layout (and back when widened) instead of squeezing the desktop layout. The old/new mobile layout setting is gone — phones always get the mobile layout. From dcacf4b3fcfaecfab31915f59dd73534c662bd78 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:47:51 +0300 Subject: [PATCH 27/66] perf(header): cap session tabs at 20; tooltip data loads on hover only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-add means the strip only grows, so past 20 tabs the oldest one leaves the working set (the newly opened tab is always the survivor). The branch/worktree/PR/project subscriptions that feed the hover tooltip moved into the tooltip body component, which mounts only while the tooltip is open — a resting tab now subscribes only to its status dot's session status and unread count. --- .../components/layout/SessionTabsStrip.tsx | 141 ++++++++++-------- .../lib/theme/themes/openchamber-dark.json | 2 +- .../ui/src/stores/useSessionTabsStore.test.ts | 9 ++ packages/ui/src/stores/useSessionTabsStore.ts | 8 +- 4 files changed, 93 insertions(+), 67 deletions(-) diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index f4bbe964..100d4216 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -97,6 +97,81 @@ const useTabProjectLabel = (directory: string | null): string | null => return null; }, [directory])); +/** + * Tooltip body for one tab. Lives in its own component so the branch, + * worktree, PR and project subscriptions exist only while the tooltip is + * open — the resting tab pays only for its status dot. + */ +const SessionTabTooltipBody: React.FC<{ tab: SessionTab; title: string }> = ({ tab, title }) => { + const { t } = useI18n(); + const directory = normalizePath(resolveGlobalSessionDirectory(tab.session) ?? null); + const projectLabel = useTabProjectLabel(directory); + const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); + const allBranches = useGitAllBranches(); + const branchLabel = React.useMemo(() => { + const meta = worktreeMetadata.get(tab.id); + if (meta?.branch?.trim()) return meta.branch.trim(); + if (directory) return allBranches.get(directory)?.trim() || null; + return null; + }, [worktreeMetadata, allBranches, tab.id, directory]); + const prSummary = usePrVisualSummary(directory && branchLabel ? getGitHubPrStatusKey(directory, branchLabel) : null); + const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; + const prStatusLabel = React.useMemo(() => { + if (!prSummary) return null; + switch (prSummary.visualState) { + case 'merged': + return t('sessions.sidebar.group.pr.status.merged'); + case 'open': + return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') + ? t('sessions.sidebar.group.pr.status.readyToMerge') + : t('sessions.sidebar.group.pr.status.open'); + case 'blocked': + return prSummary.mergeableState === 'dirty' + ? t('sessions.sidebar.group.pr.status.mergeConflicts') + : t('sessions.sidebar.group.pr.status.mergeBlocked'); + case 'draft': + return t('sessions.sidebar.group.pr.status.draft'); + case 'closed': + return t('sessions.sidebar.group.pr.status.closed'); + default: + return null; + } + }, [prSummary, t]); + const sessionTimestamp = tab.session.time?.updated || tab.session.time?.created || 0; + return ( +
+
+ {title} + {sessionTimestamp ? ( + + {formatSessionCompactDateLabel(sessionTimestamp)} + + ) : null} +
+ {projectLabel ? ( +
+ + {projectLabel} +
+ ) : null} + {branchLabel ? ( +
+ + {branchLabel} +
+ ) : null} + {prSummary && prStatusLabel ? ( +
+ + + #{prSummary.number} · {prStatusLabel} + +
+ ) : null} +
+ ); +}; + /** * One tab, active or not. The tab drags to reorder; the menu and close * controls sit in a hover-revealed overlay at the tab's end (menu first, @@ -139,41 +214,6 @@ const SessionTabItem: React.FC<{ ? t('sessions.sidebar.session.status.active') : t('sessions.sidebar.session.status.unread'); - const directory = normalizePath(resolveGlobalSessionDirectory(tab.session) ?? null); - const projectLabel = useTabProjectLabel(directory); - const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); - const allBranches = useGitAllBranches(); - const branchLabel = React.useMemo(() => { - const meta = worktreeMetadata.get(tab.id); - if (meta?.branch?.trim()) return meta.branch.trim(); - if (directory) return allBranches.get(directory)?.trim() || null; - return null; - }, [worktreeMetadata, allBranches, tab.id, directory]); - const prSummary = usePrVisualSummary(directory && branchLabel ? getGitHubPrStatusKey(directory, branchLabel) : null); - const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; - const prStatusLabel = React.useMemo(() => { - if (!prSummary) return null; - switch (prSummary.visualState) { - case 'merged': - return t('sessions.sidebar.group.pr.status.merged'); - case 'open': - return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') - ? t('sessions.sidebar.group.pr.status.readyToMerge') - : t('sessions.sidebar.group.pr.status.open'); - case 'blocked': - return prSummary.mergeableState === 'dirty' - ? t('sessions.sidebar.group.pr.status.mergeConflicts') - : t('sessions.sidebar.group.pr.status.mergeBlocked'); - case 'draft': - return t('sessions.sidebar.group.pr.status.draft'); - case 'closed': - return t('sessions.sidebar.group.pr.status.closed'); - default: - return null; - } - }, [prSummary, t]); - const sessionTimestamp = tab.session.time?.updated || tab.session.time?.created || 0; - const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({ session: tab.session, isActive, @@ -309,36 +349,7 @@ const SessionTabItem: React.FC<{ {!anyMenuOpen && !isDragging ? ( -
-
- {title} - {sessionTimestamp ? ( - - {formatSessionCompactDateLabel(sessionTimestamp)} - - ) : null} -
- {projectLabel ? ( -
- - {projectLabel} -
- ) : null} - {branchLabel ? ( -
- - {branchLabel} -
- ) : null} - {prSummary && prStatusLabel ? ( -
- - - #{prSummary.number} · {prStatusLabel} - -
- ) : null} -
+
) : null} diff --git a/packages/ui/src/lib/theme/themes/openchamber-dark.json b/packages/ui/src/lib/theme/themes/openchamber-dark.json index 22d083d3..648412f6 100644 --- a/packages/ui/src/lib/theme/themes/openchamber-dark.json +++ b/packages/ui/src/lib/theme/themes/openchamber-dark.json @@ -37,7 +37,7 @@ "border": "#242323", "borderHover": "#504e4c", "borderFocus": "#da7c47", - "selection": "#b9a5992b", + "selection": "#c8c6c52b", "selectionForeground": "#c9c5ba", "focus": "#da7c47", "focusRing": "#da7c4755", diff --git a/packages/ui/src/stores/useSessionTabsStore.test.ts b/packages/ui/src/stores/useSessionTabsStore.test.ts index 5e9d9547..5df0aa31 100644 --- a/packages/ui/src/stores/useSessionTabsStore.test.ts +++ b/packages/ui/src/stores/useSessionTabsStore.test.ts @@ -32,6 +32,15 @@ describe('useSessionTabsStore', () => { expect(useSessionTabsStore.getState().tabIds).toBe(before); }); + test('caps the working set at 20, evicting the oldest tab', () => { + useSessionTabsStore.setState({ tabIds: Array.from({ length: 20 }, (_, i) => `s${i}`) }); + useSessionTabsStore.getState().ensureTab('s-new'); + const ids = useSessionTabsStore.getState().tabIds; + expect(ids).toHaveLength(20); + expect(ids[0]).toBe('s1'); + expect(ids.at(-1)).toBe('s-new'); + }); + test('removeTabs drops only confirmed-gone ids and no-ops otherwise', () => { useSessionTabsStore.setState({ tabIds: ['a', 'b'] }); const before = useSessionTabsStore.getState().tabIds; diff --git a/packages/ui/src/stores/useSessionTabsStore.ts b/packages/ui/src/stores/useSessionTabsStore.ts index b2c6b3d2..7c1219ac 100644 --- a/packages/ui/src/stores/useSessionTabsStore.ts +++ b/packages/ui/src/stores/useSessionTabsStore.ts @@ -24,6 +24,8 @@ interface SessionTabsStore { removeTabs: (sessionIds: readonly string[]) => void; } +const MAX_SESSION_TABS = 20; + type PersistedSessionTabs = { tabIds: string[] }; export const useSessionTabsStore = create()( @@ -36,7 +38,11 @@ export const useSessionTabsStore = create()( if (!sessionId) return; const { tabIds } = get(); if (tabIds.includes(sessionId)) return; - set({ tabIds: [...tabIds, sessionId] }); + // Soft cap: with auto-add the strip only ever grows, so past the cap + // the oldest tab (never the one being opened, which lands last) + // leaves the working set. + const next = [...tabIds, sessionId]; + set({ tabIds: next.length > MAX_SESSION_TABS ? next.slice(next.length - MAX_SESSION_TABS) : next }); }, closeTab: (sessionId) => { From 71bfb3c8e0dd5a0d247c185c98fcfa3cc4df8440 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:50:22 +0300 Subject: [PATCH 28/66] fix(header): cap session tabs at 10 --- packages/ui/src/stores/useSessionTabsStore.test.ts | 6 +++--- packages/ui/src/stores/useSessionTabsStore.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/stores/useSessionTabsStore.test.ts b/packages/ui/src/stores/useSessionTabsStore.test.ts index 5df0aa31..83bec001 100644 --- a/packages/ui/src/stores/useSessionTabsStore.test.ts +++ b/packages/ui/src/stores/useSessionTabsStore.test.ts @@ -32,11 +32,11 @@ describe('useSessionTabsStore', () => { expect(useSessionTabsStore.getState().tabIds).toBe(before); }); - test('caps the working set at 20, evicting the oldest tab', () => { - useSessionTabsStore.setState({ tabIds: Array.from({ length: 20 }, (_, i) => `s${i}`) }); + test('caps the working set at 10, evicting the oldest tab', () => { + useSessionTabsStore.setState({ tabIds: Array.from({ length: 10 }, (_, i) => `s${i}`) }); useSessionTabsStore.getState().ensureTab('s-new'); const ids = useSessionTabsStore.getState().tabIds; - expect(ids).toHaveLength(20); + expect(ids).toHaveLength(10); expect(ids[0]).toBe('s1'); expect(ids.at(-1)).toBe('s-new'); }); diff --git a/packages/ui/src/stores/useSessionTabsStore.ts b/packages/ui/src/stores/useSessionTabsStore.ts index 7c1219ac..2412a960 100644 --- a/packages/ui/src/stores/useSessionTabsStore.ts +++ b/packages/ui/src/stores/useSessionTabsStore.ts @@ -24,7 +24,7 @@ interface SessionTabsStore { removeTabs: (sessionIds: readonly string[]) => void; } -const MAX_SESSION_TABS = 20; +const MAX_SESSION_TABS = 10; type PersistedSessionTabs = { tabIds: string[] }; From 2c1d8d592e352add49227f0ee5972026db8a9e5a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:57:01 +0300 Subject: [PATCH 29/66] feat(header): Alt+W closes the active session tab, customizable in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close-with-neighbour-activation logic moved into a shared helper (the strip and the shortcut use the same path). Alt+W is the default because the browser owns Cmd/Ctrl+W on web; desktop users can rebind it — the action is registered as customizable, so it appears in the Settings shortcuts section and the shortcuts help automatically. VS Code is excluded (it has no session tabs). --- .../components/layout/SessionTabsStrip.tsx | 18 ++-------- packages/ui/src/hooks/useKeyboardShortcuts.ts | 9 +++++ packages/ui/src/lib/sessionTabs.ts | 33 +++++++++++++++++++ packages/ui/src/lib/shortcuts.ts | 7 ++++ 4 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/lib/sessionTabs.ts diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index 100d4216..ff18ddd3 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -31,6 +31,7 @@ import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; +import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; @@ -391,13 +392,11 @@ export const SessionTabsStrip: React.FC<{ const { t } = useI18n(); const tabIds = useSessionTabsStore((state) => state.tabIds); const ensureTab = useSessionTabsStore((state) => state.ensureTab); - const closeTab = useSessionTabsStore((state) => state.closeTab); const closeOtherTabs = useSessionTabsStore((state) => state.closeOtherTabs); const reorderTabs = useSessionTabsStore((state) => state.reorderTabs); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); // Opening a session anywhere (sidebar, palette, deep link) adds its tab. @@ -425,20 +424,9 @@ export const SessionTabsStrip: React.FC<{ setCurrentSession(tab.id, resolveGlobalSessionDirectory(tab.session)); }, [setCurrentSession]); - const activateNeighbour = React.useCallback((closedId: string) => { - const index = tabs.findIndex((tab) => tab.id === closedId); - const neighbour = tabs[index + 1] ?? tabs[index - 1] ?? null; - if (neighbour) { - handleSelect(neighbour); - } else { - openNewSessionDraft(); - } - }, [tabs, handleSelect, openNewSessionDraft]); - const handleClose = React.useCallback((id: string) => { - if (id === currentSessionId) activateNeighbour(id); - closeTab(id); - }, [activateNeighbour, closeTab, currentSessionId]); + closeSessionTabAndActivateNeighbour(id); + }, []); const handleCloseOthers = React.useCallback((id: string) => { closeOtherTabs(id); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 2d2c4439..60db12cb 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,6 +1,7 @@ import React from 'react'; import { isTerminalEventTarget } from '@/lib/terminalFocus'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; @@ -299,6 +300,14 @@ export const useKeyboardShortcuts = () => { return; } + if (!isVSCodeRuntime() && eventMatchesShortcut(e, combo('close_session_tab'))) { + e.preventDefault(); + if (currentSessionId) { + closeSessionTabAndActivateNeighbour(currentSessionId); + } + return; + } + const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat')); const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree')); diff --git a/packages/ui/src/lib/sessionTabs.ts b/packages/ui/src/lib/sessionTabs.ts new file mode 100644 index 00000000..b3ce2bb6 --- /dev/null +++ b/packages/ui/src/lib/sessionTabs.ts @@ -0,0 +1,33 @@ +import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; +import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +/** + * Close one header session tab. Closing the active tab activates its right + * neighbour (falling back left), or opens a new-session draft when it was the + * last tab. Only tabs whose session is present in the loaded session list + * count as neighbours — the same rule the strip uses for rendering. The + * session itself is never touched. + */ +export const closeSessionTabAndActivateNeighbour = (sessionId: string): void => { + const { tabIds, closeTab } = useSessionTabsStore.getState(); + if (!tabIds.includes(sessionId)) return; + + const { currentSessionId, setCurrentSession, openNewSessionDraft } = useSessionUIStore.getState(); + if (sessionId === currentSessionId) { + const sessionsById = new Map( + useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const), + ); + const renderable = tabIds.filter((id) => sessionsById.has(id)); + const index = renderable.indexOf(sessionId); + const neighbourId = renderable[index + 1] ?? renderable[index - 1] ?? null; + const neighbour = neighbourId ? sessionsById.get(neighbourId) : null; + if (neighbour) { + setCurrentSession(neighbour.id, resolveGlobalSessionDirectory(neighbour)); + } else { + openNewSessionDraft(); + } + } + + closeTab(sessionId); +}; diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 4398aca5..aab2f9f1 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -239,6 +239,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ description: 'Create a new worktree and open a draft in it', customizable: true, }, + { + id: 'close_session_tab', + defaultCombo: 'alt+w', + label: 'Close session tab', + description: 'Close the active session tab in the header (the session itself stays)', + customizable: true, + }, { id: 'new_mini_chat', defaultCombo: 'mod+alt+n', From ce90e7e24fb5fef97846431fc68e5f822e805e33 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 00:05:45 +0300 Subject: [PATCH 30/66] =?UTF-8?q?feat(settings):=20Session=20tabs=20toggle?= =?UTF-8?q?=20in=20General=20=E2=86=92=20Navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Session tabs group (web/desktop only) turns the header session tabs off; disabled, the header renders the exact pre-tabs view — plain session title with meta row and the always-visible session menu (the same block VS Code uses). The Alt+W close-tab shortcut no-ops while tabs are off. Registered in settings search with a matching anchor; labels translated in all locales. --- packages/ui/src/components/layout/Header.tsx | 5 +++-- .../openchamber/OpenChamberVisualSettings.tsx | 20 +++++++++++++++++-- packages/ui/src/hooks/useKeyboardShortcuts.ts | 2 +- .../ui/src/lib/i18n/messages/de.settings.ts | 4 ++++ .../ui/src/lib/i18n/messages/en.settings.ts | 4 ++++ .../ui/src/lib/i18n/messages/es.settings.ts | 4 ++++ .../ui/src/lib/i18n/messages/fr.settings.ts | 4 ++++ .../ui/src/lib/i18n/messages/ja.settings.ts | 4 ++++ .../ui/src/lib/i18n/messages/ko.settings.ts | 4 ++++ .../ui/src/lib/i18n/messages/pl.settings.ts | 4 ++++ .../src/lib/i18n/messages/pt-BR.settings.ts | 4 ++++ .../ui/src/lib/i18n/messages/uk.settings.ts | 4 ++++ .../src/lib/i18n/messages/zh-CN.settings.ts | 4 ++++ .../src/lib/i18n/messages/zh-TW.settings.ts | 4 ++++ packages/ui/src/lib/settings/search.ts | 8 ++++++++ packages/ui/src/stores/useUIStore.ts | 9 +++++++++ 16 files changed, 83 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 265f2354..fd69095a 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -436,6 +436,7 @@ export const Header: React.FC = () => { const openContextPlan = useUIStore((state) => state.openContextPlan); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const runtimeApis = useRuntimeAPIs(); @@ -1654,7 +1655,7 @@ export const Header: React.FC = () => { ) : null}
- ) : isVSCode ? ( + ) : (isVSCode || !sessionTabsEnabled) ? (
{!isSidebarOpen ? ( @@ -1876,7 +1877,7 @@ export const Header: React.FC = () => {
)} - {activeSurfaceHeader || isVSCode ?
: null} + {activeSurfaceHeader || isVSCode || !sessionTabsEnabled ?
: null}
{showDesktopHeaderContextUsage && stableDesktopContextUsage ? ( diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 589e98d2..d0282c3f 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -266,7 +266,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled' | 'sessionTabs'; const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, @@ -350,6 +350,8 @@ export const OpenChamberVisualSettings: React.FC const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference); const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference); const showTerminalQuickKeysOnDesktop = useUIStore(state => state.showTerminalQuickKeysOnDesktop); + const sessionTabsEnabled = useUIStore(state => state.sessionTabsEnabled); + const setSessionTabsEnabled = useUIStore(state => state.setSessionTabsEnabled); const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop); const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap); const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap); @@ -621,7 +623,7 @@ export const OpenChamberVisualSettings: React.FC ? hasLocalizationSettings : (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile); - const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode); + const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode) || (shouldShow('sessionTabs') && !isVSCode && !isMobile); const hasBehaviorSettings = shouldShow('mermaidRendering') || (shouldShow('sessionGoal') && !isVSCode) || shouldShow('userMessageRendering') @@ -1436,6 +1438,20 @@ export const OpenChamberVisualSettings: React.FC )} + {shouldShow('sessionTabs') && !isVSCode && !isMobile && ( + + + + )}
{shouldShow('autoSaveEnabled') && ( { return; } - if (!isVSCodeRuntime() && eventMatchesShortcut(e, combo('close_session_tab'))) { + if (!isVSCodeRuntime() && useUIStore.getState().sessionTabsEnabled && eventMatchesShortcut(e, combo('close_session_tab'))) { e.preventDefault(); if (currentSessionId) { closeSessionTabAndActivateNeighbour(currentSessionId); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 28f6c5ef..6097bdef 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1917,6 +1917,10 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Versatz der Eingabeleiste zurücksetzen', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Schnelltasten des Terminals', 'settings.openchamber.visual.field.terminalQuickKeys': 'Schnelltasten des Terminals', + 'settings.openchamber.visual.field.sessionTabsGroup': 'Sitzungs-Tabs', + 'settings.openchamber.visual.field.sessionTabs': 'Sitzungen als Tabs in der Kopfzeile anzeigen', + 'settings.openchamber.visual.field.sessionTabsAria': 'Sitzungs-Tabs in der Kopfzeile umschalten', + 'settings.openchamber.visual.field.sessionTabsInfo': 'Geöffnete Sitzungen erscheinen als Tabs in der Kopfzeile. Ausgeschaltet zeigt die Kopfzeile wieder nur den Sitzungstitel.', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'Esc, Strg, Pfeiltasten in der Terminalansicht anzeigen', 'settings.openchamber.visual.field.fileEditorKeymap': 'Tastaturlayout für Datei-Editor', 'settings.openchamber.visual.option.fileEditorKeymap.default': 'Standard', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index fe98e79c..574e3a47 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1995,6 +1995,10 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Reset input bar offset', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal quick keys', 'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Quick Keys', + 'settings.openchamber.visual.field.sessionTabsGroup': 'Session tabs', + 'settings.openchamber.visual.field.sessionTabs': 'Show sessions as tabs in the header', + 'settings.openchamber.visual.field.sessionTabsAria': 'Toggle session tabs in the header', + 'settings.openchamber.visual.field.sessionTabsInfo': 'Sessions you open line up as tabs in the header. Turning this off restores the plain session title.', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'Show Esc, Ctrl, Arrows in terminal view', 'settings.openchamber.visual.field.fileEditorKeymap': 'File editor keymap', 'settings.openchamber.visual.option.fileEditorKeymap.default': 'Default', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index ee7e3163..4d0769c7 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1972,6 +1972,10 @@ export const settingsDict = { "settings.openchamber.visual.actions.resetInputBarOffsetAria": "Restablecer desplazamiento de la barra de entrada", "settings.openchamber.visual.field.terminalQuickKeysAria": "Teclas rápidas del terminal", "settings.openchamber.visual.field.terminalQuickKeys": "Teclas rápidas del terminal", + "settings.openchamber.visual.field.sessionTabsGroup": "Pestañas de sesión", + "settings.openchamber.visual.field.sessionTabs": "Mostrar las sesiones como pestañas en el encabezado", + "settings.openchamber.visual.field.sessionTabsAria": "Alternar las pestañas de sesión en el encabezado", + "settings.openchamber.visual.field.sessionTabsInfo": "Las sesiones que abres se alinean como pestañas en el encabezado. Al desactivarlo, el encabezado vuelve a mostrar solo el título de la sesión.", "settings.openchamber.visual.field.terminalQuickKeysTooltip": "Mostrar Esc, Ctrl y flechas en la vista del terminal", "settings.openchamber.visual.field.fileEditorKeymap": "Mapa de teclas del editor de archivos", "settings.openchamber.visual.option.fileEditorKeymap.default": "Predeterminado", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 9ad25052..62e92ec1 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1882,6 +1882,10 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Réinitialiser le décalage de la barre d\'entrée', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Touches rapides du terminal', 'settings.openchamber.visual.field.terminalQuickKeys': 'Touches rapides du terminal', + 'settings.openchamber.visual.field.sessionTabsGroup': 'Onglets de session', + 'settings.openchamber.visual.field.sessionTabs': 'Afficher les sessions sous forme d\'onglets dans l\'en-tête', + 'settings.openchamber.visual.field.sessionTabsAria': 'Basculer les onglets de session dans l\'en-tête', + 'settings.openchamber.visual.field.sessionTabsInfo': 'Les sessions ouvertes s\'alignent en onglets dans l\'en-tête. Désactivé, l\'en-tête n\'affiche que le titre de la session.', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'Afficher Esc, Ctrl, Flèches dans la vue du terminal', 'settings.openchamber.visual.field.activityDefaultModeAria': 'Mode d\'activité par défaut : {option}', 'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Afficher les outils bash étendus', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 20454d38..9cf90f81 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -2005,6 +2005,10 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetInputBarOffsetAria': '入力バーオフセットをリセット', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'ターミナルクイックキー', 'settings.openchamber.visual.field.terminalQuickKeys': 'ターミナルクイックキー', + 'settings.openchamber.visual.field.sessionTabsGroup': 'セッションタブ', + 'settings.openchamber.visual.field.sessionTabs': 'ヘッダーにセッションをタブとして表示', + 'settings.openchamber.visual.field.sessionTabsAria': 'ヘッダーのセッションタブを切り替え', + 'settings.openchamber.visual.field.sessionTabsInfo': '開いたセッションがヘッダーにタブとして並びます。オフにするとヘッダーはセッションタイトルのみ表示します。', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'ターミナルビューに Esc、Ctrl、矢印を表示', 'settings.openchamber.visual.field.fileEditorKeymap': 'ファイルエディターキーマップ', 'settings.openchamber.visual.option.fileEditorKeymap.default': 'デフォルト', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index d97dc80a..105c7cbd 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1972,6 +1972,10 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetInputBarOffsetAria': '입력 바 오프셋 초기화', 'settings.openchamber.visual.field.terminalQuickKeysAria': '터미널 빠른 키', 'settings.openchamber.visual.field.terminalQuickKeys': '터미널 빠른 키', + 'settings.openchamber.visual.field.sessionTabsGroup': '세션 탭', + 'settings.openchamber.visual.field.sessionTabs': '헤더에 세션을 탭으로 표시', + 'settings.openchamber.visual.field.sessionTabsAria': '헤더 세션 탭 전환', + 'settings.openchamber.visual.field.sessionTabsInfo': '연 세션이 헤더에 탭으로 나열됩니다. 끄면 헤더에 세션 제목만 표시됩니다.', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': '터미널 보기에서 Esc, Ctrl, 화살표를 표시합니다', 'settings.openchamber.visual.field.fileEditorKeymap': '파일 편집기 키맵', 'settings.openchamber.visual.option.fileEditorKeymap.default': '기본값', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index a84587d8..9871a460 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1124,6 +1124,10 @@ export const settingsDict = { 'settings.openchamber.visual.option.terminalShell.auto': 'Automatycznie', 'settings.openchamber.visual.field.editorFontSize': 'Rozmiar czcionki edytora', 'settings.openchamber.visual.field.terminalQuickKeys': 'Szybkie klawisze terminala', + 'settings.openchamber.visual.field.sessionTabsGroup': 'Karty sesji', + 'settings.openchamber.visual.field.sessionTabs': 'Pokazuj sesje jako karty w nagłówku', + 'settings.openchamber.visual.field.sessionTabsAria': 'Przełącz karty sesji w nagłówku', + 'settings.openchamber.visual.field.sessionTabsInfo': 'Otwierane sesje układają się jako karty w nagłówku. Po wyłączeniu nagłówek pokazuje tylko tytuł sesji.', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Szybkie klawisze terminala', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'Pokaż Esc, Ctrl i strzałki w widoku terminala', 'settings.openchamber.visual.field.fileEditorKeymap': 'Mapa klawiszy edytora plików', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 3a515bfd..c4f1c9bb 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1972,6 +1972,10 @@ export const settingsDict = { "settings.openchamber.visual.actions.resetInputBarOffsetAria": "Redefinir deslocamento da barra de entrada", "settings.openchamber.visual.field.terminalQuickKeysAria": "Teclas rápidas do terminal", "settings.openchamber.visual.field.terminalQuickKeys": "Teclas rápidas do terminal", + "settings.openchamber.visual.field.sessionTabsGroup": "Abas de sessão", + "settings.openchamber.visual.field.sessionTabs": "Mostrar sessões como abas no cabeçalho", + "settings.openchamber.visual.field.sessionTabsAria": "Alternar abas de sessão no cabeçalho", + "settings.openchamber.visual.field.sessionTabsInfo": "As sessões abertas se alinham como abas no cabeçalho. Desativado, o cabeçalho volta a mostrar apenas o título da sessão.", "settings.openchamber.visual.field.terminalQuickKeysTooltip": "Mostrar Esc, Ctrl e flechas na vista do terminal", "settings.openchamber.visual.field.fileEditorKeymap": "Mapa de teclas do editor de arquivos", "settings.openchamber.visual.option.fileEditorKeymap.default": "Padrão", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index c7ac4bbc..64f2c2ac 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1972,6 +1972,10 @@ export const settingsDict = { "settings.openchamber.visual.actions.resetInputBarOffsetAria": "Скинути зміщення панелі вводу", "settings.openchamber.visual.field.terminalQuickKeysAria": "Швидкі клавіші терміналу", "settings.openchamber.visual.field.terminalQuickKeys": "Швидкі клавіші терміналу", + "settings.openchamber.visual.field.sessionTabsGroup": "Вкладки сесій", + "settings.openchamber.visual.field.sessionTabs": "Показувати сесії як вкладки в хедері", + "settings.openchamber.visual.field.sessionTabsAria": "Перемкнути вкладки сесій у хедері", + "settings.openchamber.visual.field.sessionTabsInfo": "Відкриті сесії шикуються вкладками в хедері. Якщо вимкнено, хедер знову показує лише назву сесії.", "settings.openchamber.visual.field.terminalQuickKeysTooltip": "Показати Esc, Ctrl, стрілки в поданні терміналу", "settings.openchamber.visual.field.fileEditorKeymap": "Розкладка клавіш редактора файлів", "settings.openchamber.visual.option.fileEditorKeymap.default": "Типова", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 6b0f45cb..30abfdfd 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1972,6 +1972,10 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetInputBarOffsetAria': '重置输入栏偏移', 'settings.openchamber.visual.field.terminalQuickKeysAria': '终端快捷键', 'settings.openchamber.visual.field.terminalQuickKeys': '终端快捷键', + 'settings.openchamber.visual.field.sessionTabsGroup': '会话标签页', + 'settings.openchamber.visual.field.sessionTabs': '在页眉中以标签页显示会话', + 'settings.openchamber.visual.field.sessionTabsAria': '切换页眉会话标签页', + 'settings.openchamber.visual.field.sessionTabsInfo': '打开的会话会以标签页形式排列在页眉中。关闭后页眉仅显示会话标题。', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': '在终端视图显示 Esc、Ctrl、方向键', 'settings.openchamber.visual.field.fileEditorKeymap': '文件编辑器键位映射', 'settings.openchamber.visual.option.fileEditorKeymap.default': '默认', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index fcf9dae2..6cf0ba8d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1879,6 +1879,10 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetInputBarOffsetAria': '重設輸入列偏移', 'settings.openchamber.visual.field.terminalQuickKeysAria': '終端機快速鍵', 'settings.openchamber.visual.field.terminalQuickKeys': '終端機快速鍵', + 'settings.openchamber.visual.field.sessionTabsGroup': '會話分頁', + 'settings.openchamber.visual.field.sessionTabs': '在頁首以分頁顯示會話', + 'settings.openchamber.visual.field.sessionTabsAria': '切換頁首會話分頁', + 'settings.openchamber.visual.field.sessionTabsInfo': '開啟的會話會以分頁排列在頁首。關閉後頁首僅顯示會話標題。', 'settings.openchamber.visual.field.terminalQuickKeysTooltip': '在終端機檢視顯示 Esc、Ctrl、方向鍵', 'settings.openchamber.visual.field.fileEditorKeymap': '檔案編輯器鍵位映射', 'settings.openchamber.visual.option.fileEditorKeymap.default': '預設', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 9945f92c..73041c75 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -161,6 +161,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.visual.field.fileEditorKeymap', keywords: ['editor', 'vim', 'keymap'], }, + { + id: 'appearance.session-tabs', + page: 'general', + titleKey: 'settings.openchamber.visual.field.sessionTabsGroup', + descriptionKey: 'settings.openchamber.visual.field.sessionTabsInfo', + keywords: ['session', 'tabs', 'header', 'working set'], + isAvailable: (ctx) => !ctx.isMobile && !ctx.isVSCode, + }, { id: 'appearance.terminal-quick-keys', page: 'general', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index bbbe08b4..a6a14417 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -750,6 +750,8 @@ interface UIStore { maxLastMessageLength: number; // chars — truncate {last_message} when summarization is off showTerminalQuickKeysOnDesktop: boolean; + /** Header session tabs (web/desktop). Off restores the plain session title. */ + sessionTabsEnabled: boolean; persistChatDraft: boolean; showOpenCodeUpdateNotifications: boolean; agentControlToolEnabled: boolean; @@ -920,6 +922,7 @@ interface UIStore { setNativeNotificationsEnabled: (value: boolean) => void; setNotificationMode: (mode: 'always' | 'hidden-only') => void; setShowTerminalQuickKeysOnDesktop: (value: boolean) => void; + setSessionTabsEnabled: (value: boolean) => void; setNotifyOnSubtasks: (value: boolean) => void; setDockBadgeEnabled: (value: boolean) => void; setNotifyOnCompletion: (value: boolean) => void; @@ -1092,6 +1095,7 @@ export const useUIStore = create()( maxLastMessageLength: 250, showTerminalQuickKeysOnDesktop: false, + sessionTabsEnabled: true, persistChatDraft: true, showOpenCodeUpdateNotifications: !isWindowsArm64(), agentControlToolEnabled: true, @@ -2244,6 +2248,10 @@ export const useUIStore = create()( set({ showTerminalQuickKeysOnDesktop: value }); }, + setSessionTabsEnabled: (value) => { + set({ sessionTabsEnabled: value }); + }, + setNotifyOnSubtasks: (value) => { set({ notifyOnSubtasks: value }); }, @@ -2672,6 +2680,7 @@ export const useUIStore = create()( nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop, + sessionTabsEnabled: state.sessionTabsEnabled, notifyOnSubtasks: state.notifyOnSubtasks, dockBadgeEnabled: state.dockBadgeEnabled, notifyOnCompletion: state.notifyOnCompletion, From f40a247be90039bd0fdf8dc44d6be6a008b3743b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 00:20:37 +0300 Subject: [PATCH 31/66] fix(settings): include the Session tabs toggle in the General page's visible list The General section renders OpenChamberVisualSettings with an explicit visibleSettings allowlist, so the new control was searchable but never rendered. --- .../ui/src/components/sections/openchamber/OpenChamberPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 43fd56c0..2cd755ff 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -152,6 +152,7 @@ const GeneralSectionContent: React.FC = () => { {!isVSCode && } Date: Tue, 25 Aug 2026 00:32:09 +0300 Subject: [PATCH 32/66] refactor(settings): always-docked editor toolbar; reorder Navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Always show editor toolbar' preference is gone — the docked toolbar under the file tabs is now the only mode, and the floating hover toolbar branch in the files editor (with its open-state and outside-click machinery) is deleted. The stored preference is dropped by a store migration and removed from desktop settings persistence, settings search and every locale. Navigation section order now reads: file editor keymap, auto-save, terminal shell + login shell, Terminal Quick Keys, and the Session tabs group last. --- .../sections/openchamber/OpenChamberPage.tsx | 1 - .../openchamber/OpenChamberVisualSettings.tsx | 68 ++++++-------- .../ui/src/components/views/FilesView.tsx | 88 +------------------ packages/ui/src/lib/desktop.ts | 1 - .../ui/src/lib/i18n/messages/de.settings.ts | 2 - .../ui/src/lib/i18n/messages/en.settings.ts | 2 - .../ui/src/lib/i18n/messages/es.settings.ts | 2 - .../ui/src/lib/i18n/messages/fr.settings.ts | 2 - .../ui/src/lib/i18n/messages/ja.settings.ts | 2 - .../ui/src/lib/i18n/messages/ko.settings.ts | 2 - .../ui/src/lib/i18n/messages/pl.settings.ts | 2 - .../src/lib/i18n/messages/pt-BR.settings.ts | 2 - .../ui/src/lib/i18n/messages/uk.settings.ts | 2 - .../src/lib/i18n/messages/zh-CN.settings.ts | 2 - .../src/lib/i18n/messages/zh-TW.settings.ts | 2 - packages/ui/src/lib/persistence.ts | 7 -- packages/ui/src/lib/settings/search.ts | 7 -- packages/ui/src/stores/useUIStore.ts | 14 ++- 18 files changed, 34 insertions(+), 174 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 2cd755ff..72cf9227 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -154,7 +154,6 @@ const GeneralSectionContent: React.FC = () => { 'fileEditorKeymap', ...(!isVSCode ? ['sessionTabs' as const] : []), 'autoSaveEnabled', - 'expandedEditorToolbar', ...(!isVSCode ? ['terminalQuickKeys' as const] : []), ...(!isVSCode ? ['terminalShell' as const] : []), ...(!isVSCode ? ['terminalLoginShell' as const] : []), diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index d0282c3f..60313ff1 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -266,7 +266,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled' | 'sessionTabs'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs'; const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, @@ -315,8 +315,6 @@ export const OpenChamberVisualSettings: React.FC const promptNavigatorEnabled = useUIStore(state => state.promptNavigatorEnabled); const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader); const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled); - const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar); - const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar); const autoSaveEnabled = useUIStore(state => state.autoSaveEnabled); const setAutoSaveEnabled = useUIStore(state => state.setAutoSaveEnabled); const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled); @@ -500,11 +498,6 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ draftStartersVisible: enabled }); }, [setDraftStartersVisible]); - const handleExpandedEditorToolbarChange = React.useCallback((enabled: boolean) => { - setExpandedEditorToolbar(enabled); - void updateDesktopSettings({ expandedEditorToolbar: enabled }); - }, [setExpandedEditorToolbar]); - const handleCollapsibleUserMessagesChange = React.useCallback((enabled: boolean) => { setCollapsibleUserMessages(enabled); void updateDesktopSettings({ collapsibleUserMessages: enabled }); @@ -623,7 +616,7 @@ export const OpenChamberVisualSettings: React.FC ? hasLocalizationSettings : (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile); - const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode) || (shouldShow('sessionTabs') && !isVSCode && !isMobile); + const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('sessionTabs') && !isVSCode && !isMobile); const hasBehaviorSettings = shouldShow('mermaidRendering') || (shouldShow('sessionGoal') && !isVSCode) || shouldShow('userMessageRendering') @@ -1438,20 +1431,6 @@ export const OpenChamberVisualSettings: React.FC )} - {shouldShow('sessionTabs') && !isVSCode && !isMobile && ( - - - - )}
{shouldShow('autoSaveEnabled') && ( settingsItem="appearance.auto-save-enabled" /> )} - {shouldShow('expandedEditorToolbar') && !isVSCode && ( - - )} - {shouldShow('terminalQuickKeys') && !isMobile && ( - - )} {showTerminalShellSetting && ( settingsItem="appearance.terminal-login-shell" /> )} + {shouldShow('terminalQuickKeys') && !isMobile && ( + + )}
+ {shouldShow('sessionTabs') && !isVSCode && !isMobile && ( + + + + )} )} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index ecedf593..43b8e246 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -750,8 +750,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [wrapLines, setWrapLines] = React.useState(true); const [isFullscreen, setIsFullscreen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); - const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false); - const floatingToolbarRef = React.useRef(null); const toolbarDropdownOpenCountRef = React.useRef(0); const handleToolbarDropdownOpenChange = React.useCallback((open: boolean) => { @@ -761,23 +759,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ); }, []); - const isClickInsidePortalledMenu = React.useCallback((target: EventTarget | null) => { - if (!(target instanceof Element)) return false; - return target.closest('[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-item"]') !== null; - }, []); - - React.useEffect(() => { - if (!isFloatingToolbarOpen) return; - const handler = (event: MouseEvent) => { - if (toolbarDropdownOpenCountRef.current > 0) return; - if (isClickInsidePortalledMenu(event.target)) return; - if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) { - setIsFloatingToolbarOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [isClickInsidePortalledMenu, isFloatingToolbarOpen]); type TextViewMode = 'view' | 'edit'; type PreviewViewMode = 'preview' | 'edit'; @@ -1036,7 +1017,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); - const settingsExpandedEditorToolbar = useUIStore((state) => state.expandedEditorToolbar); // Global mouseup to end drag selection React.useEffect(() => { @@ -3741,9 +3721,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : null} - {/* Row 2: Docked editor toolbar (expanded). Desktop opt-in; ALWAYS on - for mobile — floating hover controls don't work with touch. */} - {(settingsExpandedEditorToolbar || isMobile) && selectedFile ? ( + {/* Row 2: Docked editor toolbar. */} + {selectedFile ? (
{/* Mobile hosts already show the file name in their own header; a truncated duplicate here just eats toolbar width. */} @@ -3764,69 +3743,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
- {selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar || isMobile) && ( -
{ - if (toolbarDropdownOpenCountRef.current > 0) return; - setIsFloatingToolbarOpen(false); - }} - > - {isFloatingToolbarOpen ? ( - renderFloatingFileControls() - ) : ( -
- {isMarkdown ? ( - - - - - - - - {t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')} - - - ) : null} - - - setIsFloatingToolbarOpen(true)} - > - - - - {t('filesView.editor.controlsTitle')} - -
- )} -
- )} {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 57579982..3d7a0ebe 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -179,7 +179,6 @@ export type DesktopSettings = { collapsibleUserMessages?: boolean; stickyUserHeader?: boolean; promptNavigatorEnabled?: boolean; - expandedEditorToolbar?: boolean; wideChatLayoutEnabled?: boolean; showSplitAssistantMessageActions?: boolean; fontSize?: number; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 6097bdef..cf20c06d 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1953,8 +1953,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeader': 'Fixierter Benutzerkopf', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'Prompt-Navigator', 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt-Navigator', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Editor-Werkzeugleiste immer anzeigen', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'Editor-Werkzeugleiste immer anzeigen (unter den Datei-Reitern angeheftet)', 'settings.openchamber.visual.field.wideChatLayoutAria': 'Breites Chat-Layout', 'settings.openchamber.visual.field.wideChatLayout': 'Breites Chat-Layout', 'settings.openchamber.visual.field.codeBlockLineWrapAria': 'Codeblock-Zeilen umbrechen', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 574e3a47..a1e1435f 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -2031,8 +2031,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeader': 'Sticky User Header', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'Prompt navigator', 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt Navigator', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', 'settings.openchamber.visual.field.autoSaveEnabledAria': 'Auto-save files', 'settings.openchamber.visual.field.autoSaveEnabled': 'Auto-save files', 'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Automatically save file edits after you stop typing. Disable to require manual save.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 4d0769c7..064a329f 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -2008,8 +2008,6 @@ export const settingsDict = { "settings.openchamber.visual.field.stickyUserHeader": "Encabezado de usuario fijo", "settings.openchamber.visual.field.promptNavigatorEnabledAria": "Navegador de prompts", "settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts", - "settings.openchamber.visual.field.expandedEditorToolbarAria": "Mostrar siempre la barra de herramientas del editor", - "settings.openchamber.visual.field.expandedEditorToolbar": "Mostrar siempre la barra de herramientas del editor (anclada bajo las pestañas)", "settings.openchamber.visual.field.autoSaveEnabledAria": "Guardado automático de archivos", "settings.openchamber.visual.field.autoSaveEnabled": "Guardado automático de archivos", "settings.openchamber.visual.field.autoSaveEnabledInfo": "Guarda automáticamente las ediciones del archivo después de dejar de escribir. Desactívalo para exigir un guardado manual.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 62e92ec1..ae539b59 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1915,8 +1915,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeader': 'En-tête utilisateur collant', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'Navigateur de prompts', 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Navigateur de prompts', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Toujours afficher la barre d’outils de l’éditeur', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'Toujours afficher la barre d’outils de l’éditeur (ancrée sous les onglets de fichiers)', 'settings.openchamber.visual.field.autoSaveEnabledAria': 'Enregistrement automatique des fichiers', 'settings.openchamber.visual.field.autoSaveEnabled': 'Enregistrement automatique des fichiers', 'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Enregistre automatiquement les modifications après l’arrêt de la saisie. Désactivez pour exiger un enregistrement manuel.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 9cf90f81..24db27fe 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -2041,8 +2041,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeader': 'ユーザーヘッダー固定', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'プロンプトナビゲーター', 'settings.openchamber.visual.field.promptNavigatorEnabled': 'プロンプトナビゲーター', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'エディターツールバーを常に表示', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'エディターツールバーを常に表示(ファイルタブの下にドッキング)', 'settings.openchamber.visual.field.autoSaveEnabledAria': 'ファイルの自動保存', 'settings.openchamber.visual.field.autoSaveEnabled': 'ファイルの自動保存', 'settings.openchamber.visual.field.autoSaveEnabledInfo': '入力を止めた後にファイルの編集内容を自動保存します。無効にすると手動保存が必要になります。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 105c7cbd..a2a025f3 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -2008,8 +2008,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeader': '고정 사용자 헤더', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': '프롬프트 탐색기', 'settings.openchamber.visual.field.promptNavigatorEnabled': '프롬프트 탐색기', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', 'settings.openchamber.visual.field.autoSaveEnabledAria': '파일 자동 저장', 'settings.openchamber.visual.field.autoSaveEnabled': '파일 자동 저장', 'settings.openchamber.visual.field.autoSaveEnabledInfo': '입력을 멈춘 후 파일 편집 내용을 자동으로 저장합니다. 끄면 수동으로 저장해야 합니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 9871a460..352c9c14 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1111,8 +1111,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeaderAria': 'Przyklejony nagłówek użytkownika', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'Nawigator promptów', 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Nawigator promptów', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', 'settings.openchamber.visual.field.autoSaveEnabledAria': 'Autozapis plików', 'settings.openchamber.visual.field.autoSaveEnabled': 'Autozapis plików', 'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Automatycznie zapisuje edycje pliku po zatrzymaniu pisania. Wyłącz, aby wymagać ręcznego zapisu.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index c4f1c9bb..01743896 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -2008,8 +2008,6 @@ export const settingsDict = { "settings.openchamber.visual.field.stickyUserHeader": "Cabeçalho do usuário fixo", "settings.openchamber.visual.field.promptNavigatorEnabledAria": "Navegador de prompts", "settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts", - "settings.openchamber.visual.field.expandedEditorToolbarAria": "Mostrar sempre a barra de ferramentas do editor", - "settings.openchamber.visual.field.expandedEditorToolbar": "Mostrar sempre a barra de ferramentas do editor (ancorada sob as abas)", "settings.openchamber.visual.field.autoSaveEnabledAria": "Salvamento automático de arquivos", "settings.openchamber.visual.field.autoSaveEnabled": "Salvamento automático de arquivos", "settings.openchamber.visual.field.autoSaveEnabledInfo": "Salva automaticamente as edições do arquivo depois que você parar de digitar. Desative para exigir salvamento manual.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 64f2c2ac..887f359b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -2008,8 +2008,6 @@ export const settingsDict = { "settings.openchamber.visual.field.stickyUserHeader": "Закріплений заголовок користувача", "settings.openchamber.visual.field.promptNavigatorEnabledAria": "Навігатор промптів", "settings.openchamber.visual.field.promptNavigatorEnabled": "Навігатор промптів", - "settings.openchamber.visual.field.expandedEditorToolbarAria": "Завжди показувати панель інструментів редактора", - "settings.openchamber.visual.field.expandedEditorToolbar": "Завжди показувати панель інструментів редактора (закріплена під вкладками)", "settings.openchamber.visual.field.autoSaveEnabledAria": "Автозбереження файлів", "settings.openchamber.visual.field.autoSaveEnabled": "Автозбереження файлів", "settings.openchamber.visual.field.autoSaveEnabledInfo": "Автоматично зберігати зміни у файлі після того, як ви припините друкувати. Вимкніть, щоб зберігати лише вручну.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 30abfdfd..d8b99d7f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -2008,8 +2008,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeader': '固定用户消息头', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': '提示词导航', 'settings.openchamber.visual.field.promptNavigatorEnabled': '提示词导航', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', 'settings.openchamber.visual.field.autoSaveEnabledAria': '自动保存文件', 'settings.openchamber.visual.field.autoSaveEnabled': '自动保存文件', 'settings.openchamber.visual.field.autoSaveEnabledInfo': '停止输入后自动保存文件编辑内容。关闭后需手动保存。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 6cf0ba8d..99ddd925 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1915,8 +1915,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.stickyUserHeader': '固定使用者訊息標頭', 'settings.openchamber.visual.field.promptNavigatorEnabledAria': '提示詞導覽', 'settings.openchamber.visual.field.promptNavigatorEnabled': '提示詞導覽', - 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', - 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', 'settings.openchamber.visual.field.autoSaveEnabledAria': '自動儲存檔案', 'settings.openchamber.visual.field.autoSaveEnabled': '自動儲存檔案', 'settings.openchamber.visual.field.autoSaveEnabledInfo': '停止輸入後自動儲存檔案編輯內容。關閉後需手動儲存。', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index a3ed076b..7d3ca54c 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -578,7 +578,6 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS messageStreamTransport: 'auto', stickyUserHeader: defaults.stickyUserHeader, promptNavigatorEnabled: defaults.promptNavigatorEnabled, - expandedEditorToolbar: defaults.expandedEditorToolbar, wideChatLayoutEnabled: defaults.wideChatLayoutEnabled, showSplitAssistantMessageActions: defaults.showSplitAssistantMessageActions, draftStartersVisible: defaults.draftStartersVisible, @@ -842,9 +841,6 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.promptNavigatorEnabled === 'boolean' && settings.promptNavigatorEnabled !== store.promptNavigatorEnabled) { store.setPromptNavigatorEnabled(settings.promptNavigatorEnabled); } - if (typeof settings.expandedEditorToolbar === 'boolean' && settings.expandedEditorToolbar !== store.expandedEditorToolbar) { - store.setExpandedEditorToolbar(settings.expandedEditorToolbar); - } if (typeof settings.wideChatLayoutEnabled === 'boolean' && settings.wideChatLayoutEnabled !== store.wideChatLayoutEnabled) { store.setWideChatLayoutEnabled(settings.wideChatLayoutEnabled); } @@ -1511,9 +1507,6 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.promptNavigatorEnabled === 'boolean') { result.promptNavigatorEnabled = candidate.promptNavigatorEnabled; } - if (typeof candidate.expandedEditorToolbar === 'boolean') { - result.expandedEditorToolbar = candidate.expandedEditorToolbar; - } if (typeof candidate.wideChatLayoutEnabled === 'boolean') { result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 73041c75..134c17e4 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -148,13 +148,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.visual.field.autoSaveEnabledInfo', keywords: ['editor', 'autosave', 'auto-save', 'files', 'save'], }, - { - id: 'appearance.expanded-editor-toolbar', - page: 'general', - titleKey: 'settings.openchamber.visual.field.expandedEditorToolbar', - keywords: ['editor', 'toolbar', 'tabs', 'docked', 'files'], - isAvailable: (ctx) => !ctx.isVSCode, - }, { id: 'appearance.file-editor-keymap', page: 'general', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index a6a14417..73fbcded 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -787,7 +787,6 @@ interface UIStore { collapsibleUserMessages: boolean; stickyUserHeader: boolean; promptNavigatorEnabled: boolean; - expandedEditorToolbar: boolean; showSplitAssistantMessageActions: boolean; allowPromptingSubagentSessions: boolean; isExpandedInput: boolean; @@ -960,7 +959,6 @@ interface UIStore { setCollapsibleUserMessages: (value: boolean) => void; setStickyUserHeader: (value: boolean) => void; setPromptNavigatorEnabled: (value: boolean) => void; - setExpandedEditorToolbar: (value: boolean) => void; setShowSplitAssistantMessageActions: (value: boolean) => void; setAllowPromptingSubagentSessions: (value: boolean) => void; viewPagerPage: 'left' | 'center' | 'right'; @@ -1121,7 +1119,6 @@ export const useUIStore = create()( collapsibleUserMessages: true, stickyUserHeader: false, promptNavigatorEnabled: true, - expandedEditorToolbar: false, showSplitAssistantMessageActions: false, allowPromptingSubagentSessions: false, draftStartersVisible: true, @@ -2357,9 +2354,6 @@ export const useUIStore = create()( setPromptNavigatorEnabled: (value) => { set({ promptNavigatorEnabled: value }); }, - setExpandedEditorToolbar: (value: boolean) => { - set({ expandedEditorToolbar: value }); - }, setShowSplitAssistantMessageActions: (value) => { set({ showSplitAssistantMessageActions: value }); }, @@ -2411,7 +2405,7 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 16, + version: 17, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -2427,6 +2421,11 @@ export const useUIStore = create()( delete state.activeSurface; } + // v16 -> v17: the editor toolbar is always docked; the preference is gone. + if (version < 17) { + delete state.expandedEditorToolbar; + } + // v13 -> v14: the separate 'preview' surface merged into 'browser'. // Stored preview tabs keep their URL and become browser tabs; their // id encodes the mode, so it is rebuilt rather than left dangling. @@ -2714,7 +2713,6 @@ export const useUIStore = create()( collapsibleUserMessages: state.collapsibleUserMessages, stickyUserHeader: state.stickyUserHeader, promptNavigatorEnabled: state.promptNavigatorEnabled, - expandedEditorToolbar: state.expandedEditorToolbar, showSplitAssistantMessageActions: state.showSplitAssistantMessageActions, allowPromptingSubagentSessions: state.allowPromptingSubagentSessions, draftStartersVisible: state.draftStartersVisible, From 8458d2a44644b7227c77271bf1df44e32aa10b16 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 00:50:19 +0300 Subject: [PATCH 33/66] feat(header): session tabs are opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default off; the changelog entry points at Settings → General → Navigation → Session tabs and mentions the Alt+W close shortcut. --- CHANGELOG.md | 3 ++- packages/ui/src/stores/useUIStore.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eec2e9e..e9e3cfdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ All notable changes to this project will be documented in this file. - **Terminal:** terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload now shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name like "solo-is-a" finds the file inside it. -- **Session tabs:** the web/desktop header now shows your open sessions as browser-style tabs — every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many, carry the sidebar's running/unread dot, and show the sidebar's info tooltip (project, branch, PR status) on hover. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. +- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many, carry the sidebar's running/unread dot, and show the sidebar's info tooltip (project, branch, PR status) on hover. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. +- Files: the editor toolbar is now always docked under the file tabs; the floating hover toolbar and its setting were removed. - **Search in dropdowns:** every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). The git branch and gitmoji pickers also stopped silently dropping rows that a second, built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way now. - Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. - Mobile: narrowing a browser window past phone size now switches into the mobile app layout (and back when widened) instead of squeezing the desktop layout. The old/new mobile layout setting is gone — phones always get the mobile layout. diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 73fbcded..812a5ffe 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -750,7 +750,7 @@ interface UIStore { maxLastMessageLength: number; // chars — truncate {last_message} when summarization is off showTerminalQuickKeysOnDesktop: boolean; - /** Header session tabs (web/desktop). Off restores the plain session title. */ + /** Header session tabs (web/desktop), opt-in. Off keeps the plain session title. */ sessionTabsEnabled: boolean; persistChatDraft: boolean; showOpenCodeUpdateNotifications: boolean; @@ -1093,7 +1093,7 @@ export const useUIStore = create()( maxLastMessageLength: 250, showTerminalQuickKeysOnDesktop: false, - sessionTabsEnabled: true, + sessionTabsEnabled: false, persistChatDraft: true, showOpenCodeUpdateNotifications: !isWindowsArm64(), agentControlToolEnabled: true, From 3b7d5e1c348bf47e348dec238e36f2d9273eaff2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 25 Aug 2026 00:52:01 +0300 Subject: [PATCH 34/66] refactor(header): drop session tab tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hover info card (project/branch/PR/time) added noise without pull — the tab title plus the sidebar already cover it. The tooltip body component, its per-hover subscriptions, and the native title attribute are gone; right-click/menu and the status dot are untouched. --- CHANGELOG.md | 2 +- .../components/layout/SessionTabsStrip.tsx | 114 +----------------- 2 files changed, 3 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e3cfdb..30a8f4aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file. - **Terminal:** terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload now shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name like "solo-is-a" finds the file inside it. -- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many, carry the sidebar's running/unread dot, and show the sidebar's info tooltip (project, branch, PR status) on hover. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. +- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - Files: the editor toolbar is now always docked under the file tabs; the floating hover toolbar and its setting were removed. - **Search in dropdowns:** every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). The git branch and gitmoji pickers also stopped silently dropping rows that a second, built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way now. - Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index ff18ddd3..bd646e2f 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -26,7 +26,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass } from '@/components/ui/dropdown-menu.styles'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; @@ -36,15 +35,6 @@ import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; import { useSessionUnseenCount } from '@/sync/notification-store'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useGitAllBranches } from '@/stores/useGitStore'; -import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; -import { - formatProjectLabel, - formatSessionCompactDateLabel, - formatSessionDateLabel, - normalizePath, -} from '@/components/session/sidebar/utils'; const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); @@ -83,96 +73,6 @@ const contextComponents: SessionTabMenuComponents = { ), }; -/** Resolve the project a session directory belongs to, for the hover tooltip. */ -const useTabProjectLabel = (directory: string | null): string | null => - useProjectsStore(React.useCallback((state) => { - if (!directory) return null; - const dir = normalizePath(directory); - if (!dir) return null; - for (const project of state.projects) { - const path = normalizePath(project.path); - if (path && (dir === path || dir.startsWith(`${path}/`))) { - return formatProjectLabel(project.label?.trim() || path.split('/').pop() || path); - } - } - return null; - }, [directory])); - -/** - * Tooltip body for one tab. Lives in its own component so the branch, - * worktree, PR and project subscriptions exist only while the tooltip is - * open — the resting tab pays only for its status dot. - */ -const SessionTabTooltipBody: React.FC<{ tab: SessionTab; title: string }> = ({ tab, title }) => { - const { t } = useI18n(); - const directory = normalizePath(resolveGlobalSessionDirectory(tab.session) ?? null); - const projectLabel = useTabProjectLabel(directory); - const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); - const allBranches = useGitAllBranches(); - const branchLabel = React.useMemo(() => { - const meta = worktreeMetadata.get(tab.id); - if (meta?.branch?.trim()) return meta.branch.trim(); - if (directory) return allBranches.get(directory)?.trim() || null; - return null; - }, [worktreeMetadata, allBranches, tab.id, directory]); - const prSummary = usePrVisualSummary(directory && branchLabel ? getGitHubPrStatusKey(directory, branchLabel) : null); - const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; - const prStatusLabel = React.useMemo(() => { - if (!prSummary) return null; - switch (prSummary.visualState) { - case 'merged': - return t('sessions.sidebar.group.pr.status.merged'); - case 'open': - return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') - ? t('sessions.sidebar.group.pr.status.readyToMerge') - : t('sessions.sidebar.group.pr.status.open'); - case 'blocked': - return prSummary.mergeableState === 'dirty' - ? t('sessions.sidebar.group.pr.status.mergeConflicts') - : t('sessions.sidebar.group.pr.status.mergeBlocked'); - case 'draft': - return t('sessions.sidebar.group.pr.status.draft'); - case 'closed': - return t('sessions.sidebar.group.pr.status.closed'); - default: - return null; - } - }, [prSummary, t]); - const sessionTimestamp = tab.session.time?.updated || tab.session.time?.created || 0; - return ( -
-
- {title} - {sessionTimestamp ? ( - - {formatSessionCompactDateLabel(sessionTimestamp)} - - ) : null} -
- {projectLabel ? ( -
- - {projectLabel} -
- ) : null} - {branchLabel ? ( -
- - {branchLabel} -
- ) : null} - {prSummary && prStatusLabel ? ( -
- - - #{prSummary.number} · {prStatusLabel} - -
- ) : null} -
- ); -}; - /** * One tab, active or not. The tab drags to reorder; the menu and close * controls sit in a hover-revealed overlay at the tab's end (menu first, @@ -203,7 +103,6 @@ const SessionTabItem: React.FC<{ const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); const overlayVisible = !suppressControls && (menuOpen || menuVisible); - const anyMenuOpen = menuOpen || contextMenuOpen; // Session state for the dot and the hover tooltip. const sessionStatus = useGlobalSessionStatus(tab.id); @@ -238,9 +137,7 @@ const SessionTabItem: React.FC<{ onOpenChange={setContextMenuOpen} onOpenChangeComplete={(open) => onMenuOpenChangeComplete?.(open)} > - - - (
)} - /> - - {!anyMenuOpen && !isDragging ? ( - - - - ) : null} - + /> Date: Tue, 4 Aug 2026 12:09:37 +0300 Subject: [PATCH 35/66] refactor(chat): replace timeline scroll engine with anchored-turn LegendList Sending a message now parks that message near the top of the viewport and streams the reply into reserved end space below it, instead of jumping to the bottom and chasing it. - swap @tanstack/react-virtual for @legendapp/list in the chat timeline; the streaming tail becomes a normal list row rather than a separately rendered block, so one component owns the scroll position - add timelineScrollAnchoring: pure anchored-turn geometry plus the three scroll modes (following-end / anchoring-new-turn / free-scrolling) - replace useChatAutoFollow with useChatTimelineScroll, which opts out of automatic movement on real gestures via a generation counter instead of the timer windows the old implementation needed to recognise its own writes - move the load-older button, question/permission cards, recap, status row and bottom spacer into the list header/footer, since the list owns its container - extract useScrollShadow so the shadows can attach to that container maintainScrollAtEnd and maintainVisibleContentPosition replace the manual prepend anchor-hold and the mobile quiet-window prepend deferral. Validated: workspace type-check, lint, web build, ui tests per file. Scroll behaviour itself is unverified and needs manual testing on web, desktop and iOS. --- bun.lock | 3 + packages/ui/package.json | 1 + .../ui/src/components/chat/ChatContainer.tsx | 231 +++-- .../ui/src/components/chat/ChatMessage.tsx | 2 +- .../ui/src/components/chat/MessageList.tsx | 750 +++++++------- .../chat/components/TurnActivity.tsx | 2 +- .../scroll/timelineScrollAnchoring.test.ts | 225 +++++ .../lib/scroll/timelineScrollAnchoring.ts | 147 +++ .../components/chat/message/MessageBody.tsx | 2 +- .../chat/message/parts/AssistantTextPart.tsx | 2 +- .../chat/message/parts/JustificationBlock.tsx | 2 +- .../chat/message/parts/ProgressiveGroup.tsx | 2 +- .../chat/message/parts/ReasoningPart.tsx | 2 +- .../chat/message/parts/ToolPart.tsx | 2 +- .../ui/src/components/ui/ScrollShadow.tsx | 114 +-- .../ui/src/components/ui/useScrollShadow.ts | 159 +++ packages/ui/src/hooks/useChatAutoFollow.ts | 938 ------------------ .../ui/src/hooks/useChatTimelineScroll.ts | 691 +++++++++++++ 18 files changed, 1739 insertions(+), 1536 deletions(-) create mode 100644 packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.test.ts create mode 100644 packages/ui/src/components/chat/lib/scroll/timelineScrollAnchoring.ts create mode 100644 packages/ui/src/components/ui/useScrollShadow.ts delete mode 100644 packages/ui/src/hooks/useChatAutoFollow.ts create mode 100644 packages/ui/src/hooks/useChatTimelineScroll.ts diff --git a/bun.lock b/bun.lock index 98e47d8f..d9713441 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +167,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@legendapp/list": "3.2.0", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", @@ -917,6 +918,8 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@legendapp/list": ["@legendapp/list@3.2.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 14eaf104..dc5e40f1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,6 +43,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@legendapp/list": "3.2.0", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a6384531..9174668b 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; -import { ScrollShadow } from '@/components/ui/ScrollShadow'; -import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow'; +import { useScrollShadow } from '@/components/ui/useScrollShadow'; +import { useChatTimelineScroll, type AnimationHandlers, type ContentChangeReason, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { TimelineDialog } from './TimelineDialog'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; @@ -151,10 +151,16 @@ type ChatViewportProps = { currentSessionKey: string; isDesktopExpandedInput: boolean; isMobile: boolean; - stickyUserHeader: boolean; directory?: string; scrollRef: React.RefObject; messageListRef: React.RefObject; + registerList: (list: TimelineListHandle | null) => void; + anchorMessageId: string | null; + onAnchorReady: (messageId: string, anchorIndex: number) => void; + onAnchorSizeChanged: (messageId: string) => void; + composerOverlayHeight: number; + onIsAtEndChange: (isAtEnd: boolean) => void; + onTimelineDataChange: () => void; pendingRevealWork: boolean; renderedMessages: SessionMessageRecord[]; isLoadingOlder: boolean; @@ -169,7 +175,6 @@ type ChatViewportProps = { } | null; handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; - handleHistoryScroll: () => void; scrollToBottom: () => void; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; @@ -190,10 +195,16 @@ const ChatViewport = React.memo(({ currentSessionKey, isDesktopExpandedInput, isMobile, - stickyUserHeader, directory, scrollRef, messageListRef, + registerList, + anchorMessageId, + onAnchorReady, + onAnchorSizeChanged, + composerOverlayHeight, + onIsAtEndChange, + onTimelineDataChange, pendingRevealWork, renderedMessages, isLoadingOlder, @@ -203,7 +214,6 @@ const ChatViewport = React.memo(({ retryOverlay, handleMessageContentChange, getAnimationHandlers, - handleHistoryScroll, scrollToBottom, sessionQuestions, sessionPermissions, @@ -315,6 +325,60 @@ const ChatViewport = React.memo(({ scrollRef.current?.focus({ preventScroll: true }); }, [scrollRef]); + // Everything that used to sit beside the list inside the scroll container + // now renders as the list's header/footer, so it keeps scrolling with the + // rows exactly as before. + const listHeader = React.useMemo(() => ( + showLoadOlderButton ? ( +
+ +
+ ) : null + ), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]); + + const listFooter = React.useMemo(() => ( + <> + {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && ( +
+ {sessionQuestions.map((question) => ( + + ))} + {sessionPermissions.map((permission) => ( + + ))} +
+ )} + + + +
+ +
+ +