diff --git a/apps/desktop/src/main/__tests__/appShortcuts.test.ts b/apps/desktop/src/main/__tests__/appShortcuts.test.ts index 5481e2aba3d..7c19dd9be5c 100644 --- a/apps/desktop/src/main/__tests__/appShortcuts.test.ts +++ b/apps/desktop/src/main/__tests__/appShortcuts.test.ts @@ -52,6 +52,13 @@ function electronInput(code: string, mods: Partial = {}) { } describe('matching', () => { + it('provides the app-wide quick switch shortcut on each platform and respects existing user bindings', () => { + for (const platform of ['win32', 'linux', 'darwin']) { + const expected = combo('KeyK', platform === 'darwin' ? { meta: true } : { ctrl: true }); + expect(getEffectiveAppShortcuts({}, platform).get('open-quick-switcher')).toEqual([expected]); + expect(getEffectiveAppShortcuts({ 'new-maker': expected }, platform).get('open-quick-switcher')).toEqual([]); + } + }); it('matches exact modifier state only', () => { const c = combo('KeyB', { meta: true }); expect(matchesKeyboardEvent(keyboardEvent('KeyB', { meta: true }), c)).toBe(true); diff --git a/apps/desktop/src/main/__tests__/webview-security.test.ts b/apps/desktop/src/main/__tests__/webview-security.test.ts index d28ded90cc2..b536d25b5e7 100644 --- a/apps/desktop/src/main/__tests__/webview-security.test.ts +++ b/apps/desktop/src/main/__tests__/webview-security.test.ts @@ -21,6 +21,25 @@ import { EventEmitter } from 'node:events'; import type { BrowserWindow, Session, WebContents } from 'electron'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AppShortcutCombo, AppShortcutOverrides } from '../../shared/appShortcuts'; +import type { VoiceInputShortcut } from '../../shared/voiceInputData'; + +const shortcutMocks = vi.hoisted((): { + overrides: AppShortcutOverrides; + platform: string; + voiceShortcut: VoiceInputShortcut | null; +} => ({ overrides: {}, platform: 'win32', voiceShortcut: null })); +vi.mock('../app-shortcuts/index.js', () => ({ + getAppShortcutStore: () => ({ + getEffectiveMap: (yieldToCombos: ReadonlyArray = []) => + getEffectiveAppShortcuts(shortcutMocks.overrides, shortcutMocks.platform, yieldToCombos), + }), +})); +vi.mock('../voice-input/VoiceInputDataStore.js', () => ({ + voiceInputDataStore: { + getShortcut: () => shortcutMocks.voiceShortcut, + }, +})); const nativeSurfaceMocks = vi.hoisted(() => ({ create: vi.fn(() => 'surface-oauth'), @@ -46,6 +65,7 @@ import { setRsbPopupHostResolver, setRsbPopupOpenerReportSubscriber, RSB_BROWSER_POPUP_CHANNEL, + RSB_BROWSER_COMMAND_CHANNEL, applyGhostWebviewHardening, applyLoginCaptchaWebviewHardening, applyWebviewHardening, @@ -210,6 +230,9 @@ describe('BLANK_POPUP_WINDOW_WEB_PREFERENCES(popup WebContents 安全集)', () = describe('installBrowserGuestHandlers(main-owned popup)', () => { afterEach(() => { + shortcutMocks.overrides = {}; + shortcutMocks.platform = 'win32'; + shortcutMocks.voiceShortcut = null; nativeSurfaceMocks.create.mockClear(); nativeSurfaceMocks.attribute.mockClear(); setRsbPopupOpenerResolver(null); @@ -237,6 +260,91 @@ describe('installBrowserGuestHandlers(main-owned popup)', () => { return contents; } + it('forwards guest keydown to the current host, using effective bindings and ignoring keyup', () => { + const host = makeContents(1); + const guest = makeContents(42); + const currentHost = makeContents(2); + setRsbPopupHostResolver(() => currentHost as never); + installBrowserGuestHandlers(host as never, guest as never); + const event = { preventDefault: vi.fn() }; + const input = { code: 'KeyK', control: true, meta: false, alt: false, shift: false }; + guest.emit('before-input-event', event, { ...input, type: 'keyUp' }); + expect(event.preventDefault).not.toHaveBeenCalled(); + guest.emit('before-input-event', event, { ...input, type: 'keyDown' }); + expect(currentHost.send).toHaveBeenCalledExactlyOnceWith(RSB_BROWSER_COMMAND_CHANNEL, { + command: 'open-quick-switcher', + }); + expect(host.send).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + + currentHost.send.mockClear(); + event.preventDefault.mockClear(); + for (const state of [{ isAutoRepeat: true }, { isComposing: true }]) { + guest.emit('before-input-event', event, { ...input, ...state, type: 'keyDown' }); + } + expect(currentHost.send).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + // The quick switcher's default must yield to an existing user binding, just as in the host. + shortcutMocks.overrides = { + 'new-maker': { code: 'KeyK', ctrl: true, meta: false, alt: false, shift: false }, + }; + guest.emit('before-input-event', event, { ...input, type: 'keyDown' }); + expect(currentHost.send).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'win32', 'linux'])( + 'yields guest quick-switcher keys to live voice bindings on %s', + (platform) => { + shortcutMocks.platform = platform; + const host = makeContents(1); + const guest = makeContents(42); + installBrowserGuestHandlers(host as never, guest as never); + const event = { preventDefault: vi.fn() }; + const input = { + type: 'keyDown', + code: 'KeyK', + key: 'k', + meta: platform === 'darwin', + control: platform !== 'darwin', + alt: false, + shift: false, + }; + const voiceShortcut: VoiceInputShortcut = { + trigger: 'keyboard', + code: 'KeyK', + key: 'k', + modifiers: { meta: input.meta, ctrl: input.control, alt: false, shift: false, fn: false }, + }; + shortcutMocks.voiceShortcut = voiceShortcut; + guest.emit('before-input-event', event, input); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(host.send).not.toHaveBeenCalled(); + + // Rebinds, clearing and native-only keys release the default on the same guest. + const nonConflicting: Array = [ + { ...voiceShortcut, code: 'KeyJ', key: 'j' }, + null, + { ...voiceShortcut, modifiers: { ...voiceShortcut.modifiers, fn: true } }, + { ...voiceShortcut, trigger: 'modifier', code: 'ControlLeft', key: 'Control' }, + ]; + for (const shortcut of nonConflicting) { + shortcutMocks.voiceShortcut = shortcut; + guest.emit('before-input-event', event, input); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(host.send).toHaveBeenCalledExactlyOnceWith(RSB_BROWSER_COMMAND_CHANNEL, { + command: 'open-quick-switcher', + }); + event.preventDefault.mockClear(); + host.send.mockClear(); + } + shortcutMocks.voiceShortcut = voiceShortcut; + guest.emit('before-input-event', event, input); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(host.send).not.toHaveBeenCalled(); + }, + ); + it.each([ ['direct URL', 'https://accounts.example.com/oauth'], ['about:blank', 'about:blank'], @@ -1000,6 +1108,27 @@ describe('resolveGuestShortcutAction', () => { keyValue?: string, ) => ({ code, key: keyValue, meta: false, control: false, alt: false, shift: false, ...mods }); + it.each(['darwin', 'win32', 'linux'])('forwards the quick switcher shortcut on %s', (platform) => { + expect( + resolveGuestShortcutAction( + key('KeyK', platform === 'darwin' ? { meta: true } : { control: true }), + combosFor(platform), + ), + ).toEqual({ kind: 'command', command: 'open-quick-switcher' }); + }); + + it('uses the current quick switcher override and releases its old default', () => { + const effective = getEffectiveAppShortcuts( + { 'open-quick-switcher': { code: 'KeyJ', ctrl: true, meta: false, alt: true, shift: false } }, + 'win32', + ); + const getCombos = (id: AppShortcutId) => effective.get(id) ?? []; + expect(resolveGuestShortcutAction(key('KeyK', { control: true }), getCombos)).toBeNull(); + expect( + resolveGuestShortcutAction(key('KeyJ', { control: true, alt: true }), getCombos), + ).toEqual({ kind: 'command', command: 'open-quick-switcher' }); + }); + it('maps darwin default combos to host actions (incl. ⌘W close-tab)', () => { const getCombos = combosFor('darwin'); expect(resolveGuestShortcutAction(key('KeyL', { meta: true }), getCombos)).toEqual({ diff --git a/apps/desktop/src/main/app-shortcuts/AppShortcutStore.ts b/apps/desktop/src/main/app-shortcuts/AppShortcutStore.ts index 6113c2bd589..a82348559d7 100644 --- a/apps/desktop/src/main/app-shortcuts/AppShortcutStore.ts +++ b/apps/desktop/src/main/app-shortcuts/AppShortcutStore.ts @@ -78,8 +78,10 @@ export class AppShortcutStore { return getAppShortcutDefinition(id).getDefaultCombos(this.options.platform); } - getEffectiveMap(): Map { - return getEffectiveAppShortcuts(this.load(), this.options.platform); + getEffectiveMap( + yieldToCombos: ReadonlyArray = [], + ): Map { + return getEffectiveAppShortcuts(this.load(), this.options.platform, yieldToCombos); } /** diff --git a/apps/desktop/src/main/app-shortcuts/__tests__/AppShortcutStore.test.ts b/apps/desktop/src/main/app-shortcuts/__tests__/AppShortcutStore.test.ts index 35634952ed3..859ba04a461 100644 --- a/apps/desktop/src/main/app-shortcuts/__tests__/AppShortcutStore.test.ts +++ b/apps/desktop/src/main/app-shortcuts/__tests__/AppShortcutStore.test.ts @@ -47,6 +47,21 @@ afterEach(() => { }); describe('AppShortcutStore', () => { + it('yields defaults to external keys without persisting them or suppressing explicit overrides', () => { + const store = makeStore('win32'); + const voiceCombo = combo('KeyK', { ctrl: true }); + expect(store.getEffectiveMap([voiceCombo]).get('open-quick-switcher')).toEqual([]); + expect(store.getEffectiveMap().get('open-quick-switcher')).toEqual([voiceCombo]); + expect(fs.existsSync(filePath())).toBe(false); + + expect(store.setOverride('open-quick-switcher', combo('KeyJ', { ctrl: true }))).toBeNull(); + expect(store.getEffectiveMap([voiceCombo]).get('open-quick-switcher')).toEqual([ + combo('KeyJ', { ctrl: true }), + ]); + store.setOverride('open-quick-switcher', null); + expect(store.getEffectiveMap([voiceCombo]).get('open-quick-switcher')).toEqual([]); + }); + it('returns registry defaults when no overrides exist', () => { const store = makeStore('darwin'); expect(store.getEffectiveCombos('toggle-sidebar')).toEqual([ diff --git a/apps/desktop/src/main/localDb/__tests__/quickSwitcher.test.ts b/apps/desktop/src/main/localDb/__tests__/quickSwitcher.test.ts new file mode 100644 index 00000000000..9c599869811 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/quickSwitcher.test.ts @@ -0,0 +1,152 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + catalogSessionForGrouping, + quickSwitcherProjects, + searchQuickSwitcher, +} from '../../../renderer/features/cc-agent/lib/quickSwitcher'; + +const h = vi.hoisted(() => ({ db: null as ReturnType | null })); +vi.mock('../client/current', () => ({ getDbClient: () => ({ drizzle: h.db }) })); +import { listQuickSwitcherCatalog } from '../quickSwitcher'; + +let sqlite: Database.Database; +afterEach(() => sqlite?.close()); + +describe('title catalogue database query', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, working_dir TEXT, workspace_kind TEXT, remote_host_id TEXT, agent_kind TEXT, status TEXT, source TEXT, orca_role TEXT, parent_session_id TEXT, pinned_at INTEGER, user_send_at INTEGER, updated_at INTEGER, created_at INTEGER); + CREATE TABLE messages (session_id TEXT, rewind_at INTEGER); + CREATE INDEX messages_session_id_idx ON messages(session_id);`); + h.db = drizzle(sqlite); + }); + + it('paginates all visible history and excludes messages, deleted rows and workers', async () => { + const insert = sqlite.prepare( + 'INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + for (let i = 0; i < 270; i++) + insert.run( + String(i).padStart(3, '0'), + `History ${i}`, + '/repo', + 'project', + null, + 'cc', + i === 269 ? 'archived' : 'active', + 'desktop', + null, + null, + null, + null, + i, + i, + ); + insert.run( + 'deleted', + 'Deleted', + '/repo', + 'project', + null, + 'cc', + 'deleted', + 'desktop', + null, + null, + null, + null, + 1, + 1, + ); + insert.run( + 'worker', + 'Worker', + '/repo', + 'project', + null, + 'cc', + 'active', + 'desktop', + 'worker', + null, + null, + null, + 1, + 1, + ); + sqlite.prepare('INSERT INTO messages VALUES (?, NULL)').run('269'); + const first = await listQuickSwitcherCatalog(null); + const second = await listQuickSwitcherCatalog(first.nextCursor); + const third = await listQuickSwitcherCatalog(second.nextCursor); + expect(first.sessions).toHaveLength(128); + expect(second.sessions).toHaveLength(128); + expect(third.sessions).toHaveLength(14); + expect(third.nextCursor).toBeNull(); + expect(third.sessions.at(-1)).toMatchObject({ + id: '269', + status: 'archived', + _count: { messages: 1 }, + }); + expect(first.sessions[0]._count.messages).toBe(0); + expect(first.sessions[0]).not.toHaveProperty('preview'); + }); + + it.each(['desktop', 'shared'])( + 'finds a %s project by name when userSendAt is null and all its messages are rewound', + async (source) => { + const insertSession = sqlite.prepare( + 'INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + for (const id of ['draft', 'live', 'rewound', 'mixed']) { + // These logical project paths are grouping inputs, never host filesystem paths. + insertSession.run( + id, + 'History', + `/projects/${id}`, + 'project', + null, + 'cc', + 'active', + source, + null, + null, + null, + null, + 1, + 1, + ); + } + const insertMessage = sqlite.prepare('INSERT INTO messages VALUES (?, ?)'); + insertMessage.run('live', null); + insertMessage.run('rewound', 2); + insertMessage.run('mixed', null); + insertMessage.run('mixed', 2); + + const page = await listQuickSwitcherCatalog(null); + const sessions = page.sessions.map(catalogSessionForGrouping); + const projects = quickSwitcherProjects(sessions, new Map(), [], process.platform); + const result = searchQuickSwitcher({ + query: 'rewound', + sessions, + projects, + hiddenProjectKeys: new Set(), + platform: process.platform, + unnamedLabel: 'Untitled', + }); + expect(result.total).toBe(1); + expect(result.results[0]).toMatchObject({ + kind: 'project', + project: { workingDir: '/projects/rewound', sessions: [{ id: 'rewound' }] }, + }); + expect(page.sessions.map((session) => [session.id, session._count.messages])).toEqual([ + ['draft', 0], + ['live', 1], + ['mixed', 1], + ['rewound', 1], + ]); + expect(projects.map((project) => project.workingDir)).not.toContain('/projects/draft'); + }, + ); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/quickSwitcherIpc.test.ts b/apps/desktop/src/main/localDb/__tests__/quickSwitcherIpc.test.ts new file mode 100644 index 00000000000..9bee2772a65 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/quickSwitcherIpc.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ handle: vi.fn(), list: vi.fn(), trust: vi.fn(), remote: vi.fn() })); +vi.mock('electron', () => ({ ipcMain: { handle: h.handle } })); +vi.mock('../conversationSearch.js', () => ({ searchConversations: vi.fn() })); +vi.mock('../quickSwitcher.js', () => ({ listQuickSwitcherCatalog: h.list })); +vi.mock('../../security/trustedAppRenderer.js', () => ({ assertTrustedAppRendererEvent: h.trust })); +vi.mock('../../device-link/invoke-context.js', () => ({ isDeviceLinkInvoke: h.remote })); + +import { registerSearchIpc } from '../ipc/search'; +import { createIpcError } from '../../../shared/ipc-errors'; + +function handler(): (event: unknown, cursor?: unknown) => Promise { + registerSearchIpc(); + return h.handle.mock.calls.find(([channel]) => channel === 'local-db:conversations:catalog')![1]; +} +beforeEach(() => { + vi.resetAllMocks(); + h.remote.mockReturnValue(false); + h.list.mockResolvedValue({ version: 1, sessions: [], nextCursor: null }); +}); +describe('title catalogue IPC boundary', () => { + it('validates the app renderer and passes the opaque page cursor', async () => { + const event = {}; + await handler()(event, 'last-id'); + expect(h.trust).toHaveBeenCalledWith(event); + expect(h.list).toHaveBeenCalledWith('last-id'); + }); + it('accepts authenticated device-link dispatch without requiring a BrowserWindow', async () => { + h.remote.mockReturnValue(true); + await handler()(null); + expect(h.trust).not.toHaveBeenCalled(); + expect(h.list).toHaveBeenCalledWith(null); + }); + it('denies an untrusted local sender before reading any data', async () => { + h.trust.mockImplementation(() => { + throw createIpcError('PERMISSION_DENIED', 'untrusted'); + }); + await expect(handler()({})).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(h.list).not.toHaveBeenCalled(); + }); + it.each([1, {}, '', 'x'.repeat(257)])('rejects an invalid cursor', async (cursor) => { + await expect(handler()({}, cursor)).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + expect(h.list).not.toHaveBeenCalled(); + }); + it('does not expose database paths or error details', async () => { + h.list.mockRejectedValue(new Error('database /private/data/file.db failed')); + await expect(handler()({})).rejects.toMatchObject({ + code: 'INTERNAL', + message: expect.not.stringContaining('/private/'), + }); + }); +}); diff --git a/apps/desktop/src/main/localDb/ipc/search.ts b/apps/desktop/src/main/localDb/ipc/search.ts index 67d03c3c24f..d56a96bbd18 100644 --- a/apps/desktop/src/main/localDb/ipc/search.ts +++ b/apps/desktop/src/main/localDb/ipc/search.ts @@ -1,6 +1,9 @@ import { ipcMain } from 'electron'; import { searchConversations } from '../conversationSearch.js'; +import { listQuickSwitcherCatalog } from '../quickSwitcher.js'; +import { assertTrustedAppRendererEvent } from '../../security/trustedAppRenderer.js'; +import { isDeviceLinkInvoke } from '../../device-link/invoke-context.js'; import type { ConversationSearchAgentFilter, ConversationSearchFilters, @@ -18,6 +21,18 @@ const AGENT_VALUES = ['all', 'cc', 'codex', 'pi'] as const; const LAST_ACTIVITY_VALUES = ['all', '1d', '3d', '7d', '30d'] as const; export function registerSearchIpc(): void { + ipcMain.handle('local-db:conversations:catalog', async (event, cursor: unknown) => { + // Remote calls have already passed the authenticated device-link allowlist. + if (!isDeviceLinkInvoke()) assertTrustedAppRendererEvent(event); + if (cursor != null && (typeof cursor !== 'string' || !cursor || cursor.length > 256)) { + throwIpcError('INVALID_PARAMS', 'invalid catalogue cursor'); + } + try { + return await listQuickSwitcherCatalog(typeof cursor === 'string' ? cursor : null); + } catch { + throwIpcError('INTERNAL', 'Title catalogue is unavailable'); + } + }); ipcMain.handle('local-db:conversations:search', async (_e, payload: unknown) => { const body = requireObject(payload, 'payload'); const query = typeof body.query === 'string' ? body.query.trim() : ''; diff --git a/apps/desktop/src/main/localDb/quickSwitcher.ts b/apps/desktop/src/main/localDb/quickSwitcher.ts new file mode 100644 index 00000000000..1429cc3fa54 --- /dev/null +++ b/apps/desktop/src/main/localDb/quickSwitcher.ts @@ -0,0 +1,62 @@ +import { and, asc, gt, inArray, isNull, ne, or, sql } from 'drizzle-orm'; + +import { normalizeDbAgentKind } from '../../shared/agentKindConversion'; +import { DESKTOP_VISIBLE_SESSION_SOURCES } from '../../shared/sessionSource'; +import { + QUICK_SWITCHER_PAGE_SIZE, + type QuickSwitcherCatalogPage, +} from '../../shared/quickSwitcher'; +import { getDbClient } from './client/current'; +import { sessions } from './schema'; + +/** Keyset pages cover old/archived titles without FTS, vector search or message previews. */ +export async function listQuickSwitcherCatalog( + afterId: string | null, +): Promise { + const db = getDbClient().drizzle; + const rows = await db + .select({ + id: sessions.id, + title: sessions.title, + workingDir: sessions.workingDir, + workspaceKind: sessions.workspaceKind, + remoteHostId: sessions.remoteHostId, + agentKind: sessions.agentKind, + status: sessions.status, + source: sessions.source, + orcaRole: sessions.orcaRole, + parentSessionId: sessions.parentSessionId, + pinnedAt: sessions.pinnedAt, + userSendAt: sessions.userSendAt, + updatedAt: sessions.updatedAt, + createdAt: sessions.createdAt, + // Grouping uses all physical messages, including rewound rows, just like + // sessions:list/get. This indexed probe neither reads content nor counts history. + hasMessages: sql`exists(select 1 from messages where session_id = ${sessions.id})`, + }) + .from(sessions) + .where( + and( + inArray(sessions.source, DESKTOP_VISIBLE_SESSION_SOURCES), + inArray(sessions.status, ['active', 'archived']), + or(isNull(sessions.orcaRole), ne(sessions.orcaRole, 'worker')), + afterId === null ? undefined : gt(sessions.id, afterId), + ), + ) + .orderBy(asc(sessions.id)) + .limit(QUICK_SWITCHER_PAGE_SIZE + 1); + const page = rows.slice(0, QUICK_SWITCHER_PAGE_SIZE); + return { + version: 1, + sessions: page.map(({ hasMessages, ...row }) => ({ + ...row, + agentKind: normalizeDbAgentKind(row.agentKind), + pinnedAt: row.pinnedAt === null ? null : new Date(row.pinnedAt).toISOString(), + userSendAt: row.userSendAt === null ? null : new Date(row.userSendAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + _count: { messages: Number(hasMessages) }, + })), + nextCursor: rows.length > QUICK_SWITCHER_PAGE_SIZE ? page[page.length - 1].id : null, + }; +} diff --git a/apps/desktop/src/main/maker-host/__tests__/codexExecFunctionAdapter.e2e.test.ts b/apps/desktop/src/main/maker-host/__tests__/codexExecFunctionAdapter.e2e.test.ts index fc972622fd6..42d71a648bf 100644 --- a/apps/desktop/src/main/maker-host/__tests__/codexExecFunctionAdapter.e2e.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/codexExecFunctionAdapter.e2e.test.ts @@ -300,6 +300,10 @@ stream_max_retries = 0 createTransport: () => createStdioTransport({ binaryPath: codexBinary, + // This loopback fixture needs exec only. Match the local host's plugin + // isolation so background marketplace git downloads cannot outlive the + // app-server and keep its temporary CODEX_HOME locked on Windows. + extraArgs: ['--disable', 'plugins', '--disable', 'remote_plugin'], cwd: workingDir, env: { ...process.env, diff --git a/apps/desktop/src/main/voice-input/VoiceInputDataStore.ts b/apps/desktop/src/main/voice-input/VoiceInputDataStore.ts index 46a6b3020f8..06dd7ef17cb 100644 --- a/apps/desktop/src/main/voice-input/VoiceInputDataStore.ts +++ b/apps/desktop/src/main/voice-input/VoiceInputDataStore.ts @@ -36,6 +36,7 @@ import { type VoiceInputDictionaryEntry, type VoiceInputHistoryEntry, type VoiceInputSettings, + type VoiceInputShortcut, type VoiceInputSyncErrorResult, } from '../../shared/voiceInputData.js'; @@ -83,6 +84,12 @@ export class VoiceInputDataStore { return this.getSnapshot().settings; } + /** Read live owner-scoped keys without cloning the dictionary/history on every keydown. */ + getShortcut(): VoiceInputShortcut | null { + const shortcut = this.load().settings.shortcut; + return shortcut ? { ...shortcut, modifiers: { ...shortcut.modifiers } } : null; + } + getHistory(limit?: number): VoiceInputHistoryEntry[] { const history = this.load().history; return cloneHistory(typeof limit === 'number' ? history.slice(0, Math.max(0, limit)) : history); diff --git a/apps/desktop/src/main/voice-input/__tests__/VoiceInputDataStore.test.ts b/apps/desktop/src/main/voice-input/__tests__/VoiceInputDataStore.test.ts index 9e87429faeb..d19ef0275ad 100644 --- a/apps/desktop/src/main/voice-input/__tests__/VoiceInputDataStore.test.ts +++ b/apps/desktop/src/main/voice-input/__tests__/VoiceInputDataStore.test.ts @@ -87,6 +87,46 @@ describe('VoiceInputDataStore persistence', () => { ); }); + it.each(['win32', 'darwin'] as const)( + 'returns isolated live shortcut snapshots and reflects clearing the binding on %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); + const store = new VoiceInputDataStore(); + const shortcut = { + trigger: 'keyboard', + code: 'KeyK', + key: 'k', + modifiers: { meta: false, ctrl: true, alt: false, shift: false, fn: false }, + }; + store.updateSettings({ shortcut }); + const snapshot = store.getShortcut(); + expect(snapshot).toEqual(shortcut); + snapshot!.code = 'KeyJ'; + snapshot!.modifiers.ctrl = false; + expect(store.getShortcut()).toEqual(shortcut); + store.updateSettings({ shortcut: null }); + expect(store.getShortcut()).toBeNull(); + }, + ); + + it('keeps shortcuts disabled on Linux when a keyboard binding is supplied', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const store = new VoiceInputDataStore(); + expect(store.getShortcut()).toBeNull(); + + const settings = store.updateSettings({ + shortcut: { + trigger: 'keyboard', + code: 'KeyK', + key: 'k', + modifiers: { meta: false, ctrl: true, alt: false, shift: false, fn: false }, + }, + }); + expect(settings.shortcut).toBeNull(); + expect(store.getShortcut()).toBeNull(); + expect(new VoiceInputDataStore().getShortcut()).toBeNull(); + }); + it.each([ ['writeFileSync', 'disk full'], ['renameSync', 'rename denied'], diff --git a/apps/desktop/src/main/webview-security.ts b/apps/desktop/src/main/webview-security.ts index 88ba90e9c76..27f7618a630 100644 --- a/apps/desktop/src/main/webview-security.ts +++ b/apps/desktop/src/main/webview-security.ts @@ -40,6 +40,8 @@ import { type AppShortcutId, } from '../shared/appShortcuts'; import { getAppShortcutStore } from './app-shortcuts/index.js'; +import { voiceInputShortcutToAppShortcutCombo } from '../shared/voiceInputAppShortcut'; +import { voiceInputDataStore } from './voice-input/VoiceInputDataStore.js'; import { handleGhostExternalLinkNavigation, handleGhostPreviewNavigation, @@ -331,7 +333,8 @@ export type RsbBrowserCommand = | 'reload' | 'close-tab' | 'right-tab-prev' - | 'right-tab-next'; + | 'right-tab-next' + | 'open-quick-switcher'; /** guest before-input-event 命中后的动作。 */ export type RsbGuestShortcutAction = @@ -367,6 +370,7 @@ const GUEST_SHORTCUT_ACTIONS: ReadonlyArray<{ { id: 'close-tab-or-window', action: { kind: 'command', command: 'close-tab' } }, { id: 'right-tab-prev', action: { kind: 'command', command: 'right-tab-prev' } }, { id: 'right-tab-next', action: { kind: 'command', command: 'right-tab-next' } }, + { id: 'open-quick-switcher', action: { kind: 'command', command: 'open-quick-switcher' } }, { id: 'browser-focus-url', action: { kind: 'focus-url-bar' } }, { id: 'browser-back', action: { kind: 'command', command: 'go-back' } }, { id: 'browser-forward', action: { kind: 'command', command: 'go-forward' } }, @@ -778,15 +782,22 @@ export function installBrowserGuestHandlers( }); // Chromium guest key events do not bubble to the host renderer. Forward the - // browser-level shortcuts through the existing host channel for both + // browser and quick-switcher shortcuts through the existing host channel for both // ordinary webviews and adopted popup WebContents. guestContents.on('before-input-event', (event, input) => { if (!isGuestShortcutKeyDownType(input.type)) return; - const store = getAppShortcutStore(); - const action = resolveGuestShortcutAction(input, (id) => - store.getEffectiveCombos(id), - ); + // Match the renderer's external voice-key yielding, including live rebinds/owner changes. + const voiceShortcut = voiceInputDataStore.getShortcut(); + const voiceCombo = voiceShortcut ? voiceInputShortcutToAppShortcutCombo(voiceShortcut) : null; + const combos = getAppShortcutStore().getEffectiveMap(voiceCombo ? [voiceCombo] : []); + const action = resolveGuestShortcutAction(input, (id) => combos.get(id) ?? []); if (!action) return; + if ( + action.kind === 'command' && + action.command === 'open-quick-switcher' && + (input.isAutoRepeat || input.isComposing) + ) + return; event.preventDefault(); let target: WebContents | null = null; try { diff --git a/apps/desktop/src/preload/__tests__/browserShortcutBridge.test.ts b/apps/desktop/src/preload/__tests__/browserShortcutBridge.test.ts new file mode 100644 index 00000000000..05107996888 --- /dev/null +++ b/apps/desktop/src/preload/__tests__/browserShortcutBridge.test.ts @@ -0,0 +1,31 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { afterAll, expect, it, vi } from 'vitest'; + +afterAll(() => vi.restoreAllMocks()); + +it('passes quick switcher commands through the real preload without exposing events or unknown commands', async () => { + let bridge: Pick | undefined; + vi.spyOn(ipcRenderer, 'sendSync').mockImplementation((channel) => + channel === 'get-app-display-version-info' ? { display: 'test', detail: 'test' } : undefined, + ); + vi.spyOn(contextBridge, 'exposeInMainWorld').mockImplementation((name, api) => { + if (name === 'electronAPI') bridge = api; + }); + await import('../preload'); + expect(bridge).toBeDefined(); + const callback = vi.fn(); + const unsubscribe = bridge!.onRsbBrowserCommand(callback); + const event = { sender: 'must not reach the renderer' }; + try { + ipcRenderer.emit('rsb:browser-command', event, { command: 'open-quick-switcher' }); + expect(callback).toHaveBeenCalledExactlyOnceWith({ command: 'open-quick-switcher' }); + for (const payload of [null, {}, { command: 'unknown' }]) { + ipcRenderer.emit('rsb:browser-command', event, payload); + } + expect(callback).toHaveBeenCalledOnce(); + } finally { + unsubscribe(); + } + ipcRenderer.emit('rsb:browser-command', event, { command: 'open-quick-switcher' }); + expect(callback).toHaveBeenCalledOnce(); +}); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c7fbb2406b3..0aa0e0c62e6 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3581,11 +3581,17 @@ contextBridge.exposeInMainWorld('electronAPI', { // BrowserTabBody 焦点 chrome URL bar。payload 是 null,只用作信号。 onRsbBrowserFocusUrlBar: (callback: () => void): (() => void) => fanOutRsbBrowserFocusUrlBar(() => callback()), - // RSB web-browser plugin:guest webview 内浏览器级快捷键(导航 + ⌘W 关 tab)。 + // Guest WebView shortcuts: browser commands and the host quick switcher. onRsbBrowserCommand: ( callback: (payload: { command: - 'go-back' | 'go-forward' | 'reload' | 'close-tab' | 'right-tab-prev' | 'right-tab-next'; + | 'go-back' + | 'go-forward' + | 'reload' + | 'close-tab' + | 'right-tab-prev' + | 'right-tab-next' + | 'open-quick-switcher'; }) => void, ): (() => void) => fanOutRsbBrowserCommand((payload) => { @@ -3596,7 +3602,8 @@ contextBridge.exposeInMainWorld('electronAPI', { command === 'reload' || command === 'close-tab' || command === 'right-tab-prev' || - command === 'right-tab-next' + command === 'right-tab-next' || + command === 'open-quick-switcher' ) { callback({ command }); } @@ -5131,6 +5138,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('local-db:bots:history', botId), }, conversations: { + catalog: (cursor?: string | null) => ipcRenderer.invoke('local-db:conversations:catalog', cursor), search: (request: unknown): Promise => ipcRenderer.invoke('local-db:conversations:search', request), }, diff --git a/apps/desktop/src/renderer/__tests__/pinnedProjectSidebarIntegration.test.ts b/apps/desktop/src/renderer/__tests__/pinnedProjectSidebarIntegration.test.ts index c519261e2ef..6e73a618662 100644 --- a/apps/desktop/src/renderer/__tests__/pinnedProjectSidebarIntegration.test.ts +++ b/apps/desktop/src/renderer/__tests__/pinnedProjectSidebarIntegration.test.ts @@ -29,7 +29,7 @@ describe('pinned project sidebar integration', () => { }); it('keeps all-pinned projects available while omitting their pinned child rows', () => { - expect(sidebarSource).toContain('const groupsWithPinnedProjects = useProjectGroups('); + expect(sidebarSource).toContain('const groupedWithPinnedProjects = useProjectGroups('); expect(sidebarSource).toContain('const notHidden = visibleSidebarProjects('); expect(sidebarSource).toContain('if (filter.projectsAsSet === null) return notHidden;'); expect(sidebarSource).toContain( @@ -80,7 +80,7 @@ describe('pinned project sidebar integration', () => { it('restores against the latest project catalogue and re-admits the active filter', () => { expect(sidebarSource).toContain( - 'const filter = useSidebarFilter(hiddenProjectKeys, sidebarSettingsSnapshot);', + 'const savedFilter = useSidebarFilter(hiddenProjectKeys, sidebarSettingsSnapshot);', ); expect(sidebarSource).toContain('collectRestorableProjectKeys({'); expect(sidebarSource).toContain('sessions: scopedSidebarSessions,'); diff --git a/apps/desktop/src/renderer/__tests__/projectCollapsedAttention.test.ts b/apps/desktop/src/renderer/__tests__/projectCollapsedAttention.test.ts index f7b6ecfb2a0..e64bbc0e606 100644 --- a/apps/desktop/src/renderer/__tests__/projectCollapsedAttention.test.ts +++ b/apps/desktop/src/renderer/__tests__/projectCollapsedAttention.test.ts @@ -282,11 +282,16 @@ describe('collapsed project attention wiring', () => { }); it('feeds both regular and pinned project rows from their displayed children', () => { + // Quick-switch expansion must drive both row visibility and its collapsed-only alert. + expect(sidebarUpperSource).toContain('collapsed={projectReveal.collapsed}'); + expect(sidebarUpperSource).toContain( + 'isCollapsed={projectReveal.collapsed.has(project.projectKey)}', + ); expect(projectsSectionSource).toContain( 'collapsed.has(project.projectKey) ? collapsedAttentionToneFor(project.sessions) : null', ); expect(sidebarUpperSource).toMatch( - /collapse\.collapsed\.has\(project\.projectKey\)[\s\S]*?collapsedAttentionToneFor\(displaySessions \?\? project\.sessions\)[\s\S]*?: null/, + /projectReveal\.collapsed\.has\(project\.projectKey\)[\s\S]*?collapsedAttentionToneFor\(displaySessions \?\? project\.sessions\)[\s\S]*?: null/, ); }); }); diff --git a/apps/desktop/src/renderer/__tests__/quickSwitcher.test.ts b/apps/desktop/src/renderer/__tests__/quickSwitcher.test.ts new file mode 100644 index 00000000000..dc0ed7f4ba5 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/quickSwitcher.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { + catalogSessionForGrouping, + projectSwitchTarget, + quickSwitcherProjects, + searchQuickSwitcher, +} from '../features/cc-agent/lib/quickSwitcher'; +import { readQuickSwitcherCatalog } from '../lib/quickSwitcherService'; +import type { QuickSwitcherSession } from '../../shared/quickSwitcher'; + +function row(id: string, patch: Partial = {}): QuickSwitcherSession { + return { + id, + title: `Task ${id}`, + workingDir: '/repo', + workspaceKind: 'project', + remoteHostId: null, + agentKind: 'cc', + status: 'active', + pinnedAt: null, + userSendAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + _count: { messages: 1 }, + ...patch, + }; +} + +function search( + rows: QuickSwitcherSession[], + query: string, + hidden = new Set(), + aliases = new Map(), +) { + const sessions = rows.map(catalogSessionForGrouping); + return searchQuickSwitcher({ + query, + sessions, + projects: quickSwitcherProjects(sessions, aliases, [], 'win32'), + hiddenProjectKeys: hidden, + platform: 'win32', + unnamedLabel: 'Untitled Task', + }); +} + +describe('quick switch matching and project identity', () => { + it('excludes unavailable recent empty projects without hiding real local, SSH or device tasks', () => { + // These are logical project paths, independent of the host filesystem. + const sessions = [ + row('local'), + row('ssh', { remoteHostId: 'ssh-box' }), + row('device', { deviceLinkDeviceId: 'device' }), + ].map(catalogSessionForGrouping); + const projects = quickSwitcherProjects( + sessions, + new Map(), + [ + { path: '/missing', exists: false, lastUsedAt: '2026-01-01T00:00:00Z' }, + { path: '/empty', exists: true, lastUsedAt: '2026-01-01T00:00:00Z' }, + { path: '/repo', exists: false, lastUsedAt: '2026-01-01T00:00:00Z' }, + ], + 'win32', + ); + expect( + projects.filter((project) => project.sessions.length === 0).map((p) => p.workingDir), + ).toEqual(['/empty']); + expect( + projects.flatMap((project) => project.sessions.map((session) => session.id)).sort(), + ).toEqual(['device', 'local', 'ssh']); + expect( + searchQuickSwitcher({ + query: 'missing', + sessions, + projects, + hiddenProjectKeys: new Set(), + platform: 'win32', + unnamedLabel: 'Untitled Task', + }).results, + ).toEqual([]); + }); + + it('searches the entire history before limiting, including archived tasks', () => { + const rows = Array.from({ length: 300 }, (_, i) => + row(String(i), { title: `long needle ${i}` }), + ); + rows.push( + row('old', { title: 'needle', status: 'archived', updatedAt: '2000-01-01T00:00:00Z' }), + ); + const result = search(rows, 'needle'); + expect(result.total).toBe(301); + expect(result.results).toHaveLength(24); + expect(result.results[0].key).toBe('session:local:old'); + }); + it('matches subsequences in names and aliases without matching paths or machines', () => { + const rows = [ + row('a', { + title: 'Alpha Beta', + workingDir: '/secret/RepoName', + remoteHostId: 'secret-host', + }), + ]; + expect(search(rows, 'AB').results[0].kind).toBe('session'); + expect(search(rows, 'rpn').results.some((r) => r.kind === 'project')).toBe(true); + expect(search(rows, 'secret').results).toEqual([]); + const aliases = new Map([['remote:secret-host:/secret/RepoName', 'Alias']]); + expect(search(rows, 'alias', new Set(), aliases).results.map((r) => r.title)).toEqual([ + 'Alias', + ]); + expect(search(rows, 'rpn', new Set(), aliases).results.map((r) => r.title)).toEqual(['Alias']); + }); + it('keeps same-named local, SSH and device projects separate and hides only the requested identity', () => { + const rows = [ + row('a'), + row('b', { remoteHostId: 'ssh-box' }), + row('a', { deviceLinkDeviceId: 'device' }), + ]; + const projects = quickSwitcherProjects( + rows.map(catalogSessionForGrouping), + new Map(), + [], + 'win32', + ); + expect(new Set(projects.map((p) => p.projectKey)).size).toBe(3); + const localKey = projects.find((p) => p.scope === 'local')!.projectKey; + expect( + search(rows, 'repo', new Set([localKey])).results.filter((r) => r.kind === 'project'), + ).toHaveLength(2); + expect(search(rows, 'Task').results.map((r) => r.key)).toContain('session:device:a'); + expect(search(rows, 'Task', new Set([localKey])).results).toHaveLength(3); + }); + it('filters deleted/worker tasks and projects, and leaves an empty query empty', () => { + expect( + search([row('deleted', { status: 'deleted' }), row('worker', { orcaRole: 'worker' })], 'Task') + .results, + ).toEqual([]); + expect( + search([row('deleted', { status: 'deleted' }), row('worker', { orcaRole: 'worker' })], 'repo') + .results, + ).toEqual([]); + expect(search([row('a')], ' ')).toEqual({ results: [], total: 0 }); + }); + it('uses the localized visible title and ignores message preview text', () => { + const session = { + ...catalogSessionForGrouping(row('draft', { title: 'New Maker' })), + preview: 'body-only-token', + }; + const args = { + sessions: [session], + projects: [], + hiddenProjectKeys: new Set(), + platform: 'win32', + unnamedLabel: 'Untitled Task', + }; + expect(searchQuickSwitcher({ ...args, query: 'body-only-token' }).results).toEqual([]); + expect(searchQuickSwitcher({ ...args, query: 'Untitled Task' }).results).toHaveLength(1); + }); + it('keeps the current task, otherwise prefers latest active over archived, and does not invent an empty task', () => { + const sessions = [ + row('archived', { status: 'archived', updatedAt: '2026-08-01T00:00:00Z' }), + row('active'), + ].map(catalogSessionForGrouping); + const project = quickSwitcherProjects(sessions, new Map(), [], 'win32')[0]; + expect(projectSwitchTarget(project, 'local:archived')?.id).toBe('archived'); + expect(projectSwitchTarget(project, null)?.id).toBe('active'); + expect(projectSwitchTarget({ ...project, sessions: [sessions[0]] }, null)?.id).toBe('archived'); + expect(projectSwitchTarget({ ...project, sessions: [] }, null)).toBeNull(); + }); +}); + +describe('complete directory pagination', () => { + it('reads beyond the first page and preserves stable ids', async () => { + const calls: Array = []; + const rows = await readQuickSwitcherCatalog( + async (cursor) => { + calls.push(cursor); + return cursor === null + ? { version: 1, sessions: [row('a')], nextCursor: 'a' } + : { version: 1, sessions: [row('b')], nextCursor: null }; + }, + () => true, + ); + expect(calls).toEqual([null, 'a']); + expect(rows?.map((s) => s.id)).toEqual(['a', 'b']); + }); + it('rejects repeated cursors instead of hanging or claiming a complete directory', async () => { + await expect( + readQuickSwitcherCatalog( + async () => ({ version: 1, sessions: [], nextCursor: 'a' }), + () => true, + ), + ).rejects.toThrow('incomplete'); + }); + it('discards an in-flight page when closed or invalidated', async () => { + let current = true; + expect( + await readQuickSwitcherCatalog( + async () => { + current = false; + return { version: 1, sessions: [row('old')], nextCursor: null }; + }, + () => current, + ), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/quickSwitcherDialog.test.tsx b/apps/desktop/src/renderer/__tests__/quickSwitcherDialog.test.tsx new file mode 100644 index 00000000000..c64c95d05f1 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/quickSwitcherDialog.test.tsx @@ -0,0 +1,440 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '@/lib/ccAgent.types'; +import type { SwitcherDevice } from '@/features/device-link/switcherDevices'; +import type { QuickSwitcherCatalogPage, QuickSwitcherSession } from '../../shared/quickSwitcher'; + +const h = vi.hoisted(() => ({ + devices: [] as SwitcherDevice[], + local: [] as Session[], + remote: [] as Session[], + aliases: new Map(), + hidden: new Set(), + catalog: vi.fn(), + get: vi.fn(), + dirs: vi.fn(), + invoke: vi.fn(), + reveal: vi.fn(), + draft: vi.fn(), + machine: vi.fn(), + merge: vi.fn(), + pin: vi.fn(), + origin: vi.fn(), + patched: new Set<(payload: { sessionId: string; patch: Partial }) => void>(), + browserCommands: new Set<(payload: { command: string }) => void>(), + t: (key: string) => key, +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: h.t }) })); +vi.mock('@/features/device-link/useMachineSwitcher', () => ({ + useSwitcherDevices: () => h.devices, +})); +vi.mock('@/features/device-link/remoteProjectsStore', () => ({ + useRemoteProjectSessions: () => h.remote, + remoteProjectsStore: { + getSessionDeviceId: h.origin, + mergeDeviceSessions: h.merge, + pinSessionOrigin: h.pin, + }, +})); +vi.mock('@/features/device-link/selectedMachineStore', () => ({ + MACHINE_LOCAL: 'local', + setSelectedMachineIdTransient: h.machine, +})); +vi.mock('@/hooks/useCCSessions', () => ({ useCCSessions: () => ({ sessions: h.local }) })); +vi.mock('@/features/cc-agent/hooks/useProjectAliases', () => ({ + useProjectAliases: () => ({ aliases: h.aliases }), +})); +vi.mock('@/features/cc-agent/hooks/useHiddenProjects', () => ({ + useHiddenProjects: () => ({ hiddenProjectKeys: h.hidden }), +})); +vi.mock('@/state/newMakerDraft', () => ({ patchDraft: h.draft })); + +import { QuickSwitcher } from '@/features/cc-agent/QuickSwitcher'; +import { catalogSessionForGrouping } from '@/features/cc-agent/lib/quickSwitcher'; +import { setDataOwnerGeneration } from '@/contexts/dataOwnerGeneration'; +import { clearQuickSwitcherFocus, useQuickSwitcherFocus } from '@/state/quickSwitcherFocus'; +import { acquireAppInteractionLock } from '@/lib/appInteractionLock'; + +function row(id: string, patch: Partial = {}): QuickSwitcherSession { + return { + id, + title: `Task ${id}`, + workingDir: '/repo', + workspaceKind: 'project', + remoteHostId: null, + agentKind: 'cc', + status: 'active', + pinnedAt: null, + userSendAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + _count: { messages: 1 }, + ...patch, + }; +} +const page = (...sessions: QuickSwitcherSession[]): QuickSwitcherCatalogPage => ({ + version: 1, + sessions, + nextCursor: null, +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function Harness() { + const location = useLocation(); + const focus = useQuickSwitcherFocus(); + return ( + <> + + + {location.pathname} + {location.search} + + {focus?.session?.id ?? focus?.project?.projectKey ?? ''} + + + ); +} +function setup(initial = '/settings') { + render( + + + , + ); + screen.getByLabelText('draft').focus(); + fireEvent.keyDown(window, { key: 'k', code: 'KeyK', ctrlKey: true }); + return screen.getByRole('combobox'); +} +async function search(query: string) { + fireEvent.change(screen.getByRole('combobox'), { target: { value: query } }); + await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0)); +} +beforeEach(() => { + vi.clearAllMocks(); + h.devices = []; + h.local = []; + h.remote = []; + h.aliases.clear(); + h.hidden.clear(); + h.patched.clear(); + h.browserCommands.clear(); + setDataOwnerGeneration('owner', 1); + h.catalog.mockResolvedValue(page(row('a'), row('b'))); + h.get.mockImplementation(async (id: string) => catalogSessionForGrouping(row(id))); + h.dirs.mockResolvedValue([]); + h.origin.mockReturnValue(undefined); + h.invoke.mockRejectedValue(new Error('legacy peer')); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + platform: 'win32', + onRsbBrowserCommand: (listener: (payload: { command: string }) => void) => { + h.browserCommands.add(listener); + return () => h.browserCommands.delete(listener); + }, + appShortcuts: { + getState: () => ({ platform: 'win32', overrides: {} }), + onChanged: () => () => {}, + }, + localDb: { + conversations: { catalog: h.catalog }, + sessions: { get: h.get }, + recentWorkdirs: { list: h.dirs }, + sessionsPush: { + onCreated: () => () => {}, + onPatched: ( + listener: (payload: { sessionId: string; patch: Partial }) => void, + ) => { + h.patched.add(listener); + return () => h.patched.delete(listener); + }, + }, + }, + deviceLink: { invoke: h.invoke, onRemotePush: () => () => {} }, + }, + }); +}); +afterEach(() => { + cleanup(); + clearQuickSwitcherFocus(); +}); + +describe('quick switch dialog', () => { + it('opens and focuses from a forwarded WebView shortcut, then removes its listener on unmount', async () => { + const view = render(); + screen.getByLabelText('draft').focus(); + act(() => h.browserCommands.forEach((listener) => listener({ command: 'reload' }))); + expect(screen.queryByRole('dialog')).toBeNull(); + await act(async () => + h.browserCommands.forEach((listener) => listener({ command: 'open-quick-switcher' })), + ); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(document.activeElement).toBe(screen.getByRole('combobox')); + act(() => h.browserCommands.forEach((listener) => listener({ command: 'open-quick-switcher' }))); + expect(screen.getByRole('dialog')).toBeTruthy(); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }); + await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('draft'))); + view.unmount(); + expect(h.browserCommands.size).toBe(0); + }); + + it('ignores forwarded shortcuts during recording or an interaction lock', async () => { + render(); + const forward = () => + h.browserCommands.forEach((listener) => listener({ command: 'open-quick-switcher' })); + document.body.dataset.appShortcutRecording = '1'; + try { + act(forward); + expect(screen.queryByRole('dialog')).toBeNull(); + } finally { + delete document.body.dataset.appShortcutRecording; + } + const release = acquireAppInteractionLock(); + try { + act(forward); + expect(screen.queryByRole('dialog')).toBeNull(); + } finally { + release(); + } + await act(async () => forward()); + expect(screen.getByRole('dialog')).toBeTruthy(); + }); + + it('opens the latest keyboard choice when arrow and Enter arrive before the next paint', async () => { + const input = setup(); + await search('Task'); + act(() => { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + await waitFor(() => expect(screen.getByTestId('route').textContent).toBe('/cc-agent/b')); + }); + + it('does not open a result from the previous query before the new query is painted', async () => { + const input = setup(); + await search('Task'); + act(() => { + fireEvent.change(input, { target: { value: 'unmatched' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId('route').textContent).toBe('/settings'); + }); + + it('opens from settings with focus and no recommendations, navigates with arrows, and preserves the draft', async () => { + const input = setup(); + expect(document.activeElement).toBe(input); + expect(screen.queryByRole('option')).toBeNull(); + await search('Task'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(screen.getByTestId('route').textContent).toBe('/cc-agent/b')); + expect(screen.getByTestId('focus').textContent).toBe('b'); + expect(h.reveal).toHaveBeenCalledOnce(); + expect(h.draft).not.toHaveBeenCalled(); + expect((screen.getByLabelText('draft') as HTMLInputElement).value).toBe('unsent draft'); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it.each(['escape', 'cancel', 'shortcut'] as const)( + 'restores focus on %s and clears only its own query on reopening', + async (method) => { + const input = setup(); + await search('Task'); + if (method === 'escape') fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + else if (method === 'cancel') + fireEvent.click(screen.getByText('ccAgent.quickSwitcher.cancel')); + else fireEvent.keyDown(input, { key: 'k', code: 'KeyK', ctrlKey: true }); + await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('draft'))); + expect(h.get).not.toHaveBeenCalled(); + await act(async () => { + fireEvent.keyDown(window, { key: 'k', code: 'KeyK', ctrlKey: true }); + }); + expect((screen.getByRole('combobox') as HTMLInputElement).value).toBe(''); + expect(screen.queryByRole('option')).toBeNull(); + }, + ); + + it('does not navigate or dismiss while confirming IME input', async () => { + const input = setup(); + await search('Task'); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); + fireEvent.keyDown(input, { key: 'Escape', isComposing: true }); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeTruthy(); + fireEvent.compositionEnd(input); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(screen.getByTestId('route').textContent).toBe('/cc-agent/a')); + }); + + it('dismisses on an outside pointer action and ignores a late directory result after reopening', async () => { + const pending = deferred(); + h.catalog.mockReturnValueOnce(pending.promise); + setup(); + // Radix installs its outside-pointer listener after the opening event. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const overlay = document.querySelector('[data-state="open"].fixed.inset-0'); + expect(overlay).not.toBeNull(); + fireEvent.pointerDown(overlay!, { button: 0, pointerType: 'mouse' }); + fireEvent.pointerUp(overlay!, { button: 0, pointerType: 'mouse' }); + fireEvent.click(overlay!, { button: 0 }); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await act(async () => { + fireEvent.keyDown(window, { key: 'k', code: 'KeyK', ctrlKey: true }); + }); + await act(async () => pending.resolve(page(row('obsolete', { title: 'Obsolete' })))); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Obsolete' } }); + expect(screen.queryByRole('option')).toBeNull(); + expect(h.get).not.toHaveBeenCalled(); + }); + + it.each(['/cc-agent/b', '/cc-agent/files/b'])( + 'keeps the current task from %s when selecting its project', + async (route) => { + setup(route); + await search('repo'); + fireEvent.click(screen.getByRole('option')); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(h.get).toHaveBeenCalledWith('b'); + expect(screen.getByTestId('route').textContent).toBe('/cc-agent/b'); + }, + ); + + it('never redirects a local result through an ambiguous remote task id', async () => { + h.origin.mockReturnValue('other-device'); + setup(); + await search('Task a'); + fireEvent.click(screen.getByRole('option')); + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe('ccAgent.quickSwitcher.unavailable'), + ); + expect(screen.getByTestId('route').textContent).toBe('/settings'); + expect(h.machine).not.toHaveBeenCalled(); + }); + + it.each(['close', 'query', 'owner'] as const)( + 'discards a pending selection after %s changes', + async (change) => { + const pending = deferred(); + h.get.mockReturnValue(pending.promise); + const input = setup(); + await search('Task'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(h.get).toHaveBeenCalledOnce(); + if (change === 'close') fireEvent.keyDown(input, { key: 'Escape' }); + if (change === 'query') fireEvent.change(input, { target: { value: 'different query' } }); + if (change === 'owner') setDataOwnerGeneration('other', 2); + await act(async () => pending.resolve(catalogSessionForGrouping(row('a')))); + expect(screen.getByTestId('route').textContent).toBe('/settings'); + expect(h.machine).not.toHaveBeenCalled(); + expect(h.draft).not.toHaveBeenCalled(); + }, + ); + + it('shows local matches while a peer is pending and marks a legacy peer as partial', async () => { + h.devices = [{ deviceId: 'peer', name: 'Peer', status: 'connected' }]; + const pending = deferred(); + h.invoke.mockReturnValue(pending.promise); + setup(); + await search('Task'); + expect(screen.getByRole('status').textContent).toBe('ccAgent.quickSwitcher.incomplete'); + await act(async () => pending.resolve({ version: 0 } as unknown as QuickSwitcherCatalogPage)); + expect(screen.getByRole('status').textContent).toBe('ccAgent.quickSwitcher.incomplete'); + expect(h.invoke).toHaveBeenCalledWith('peer', 'local-db:conversations:catalog', [null]); + }); + + it('preserves the selected identity when a better-ranked remote match arrives', async () => { + h.catalog.mockResolvedValue(page(row('local', { title: 'Match local' }))); + h.devices = [{ deviceId: 'peer', name: 'Peer', status: 'connected' }]; + const pending = deferred(); + h.invoke.mockReturnValue(pending.promise); + const input = setup(); + await search('Match'); + await act(async () => pending.resolve(page(row('remote', { title: 'Match' })))); + expect(screen.getAllByRole('option')[0].getAttribute('aria-selected')).toBe('false'); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(screen.getByTestId('route').textContent).toBe('/cc-agent/local')); + expect(h.get).toHaveBeenCalledWith('local'); + }); + + it('opens an archived remote task using its explicit origin, without a search jump', async () => { + const task = row('archived', { title: 'Remote Only', status: 'archived' }); + h.devices = [{ deviceId: 'peer', name: 'Peer', status: 'connected' }]; + h.invoke.mockImplementation(async (_device: string, channel: string) => + channel.endsWith(':catalog') ? page(task) : catalogSessionForGrouping(task), + ); + setup(); + await search('Remote Only'); + fireEvent.click(screen.getByRole('option')); + await waitFor(() => expect(screen.getByTestId('route').textContent).toBe('/cc-agent/archived')); + expect(h.get).not.toHaveBeenCalled(); + expect(h.invoke).toHaveBeenCalledWith('peer', 'local-db:sessions:get', ['archived']); + expect(h.pin).toHaveBeenCalledWith('peer', 'archived'); + expect(h.merge).toHaveBeenCalledWith( + 'peer', + 'Peer', + [expect.objectContaining({ status: 'archived', deviceLinkDeviceId: 'peer' })], + 'archived', + ); + }); + + it('explains a missing target and refuses a project whose task moved', async () => { + setup(); + await search('Task'); + h.get.mockResolvedValueOnce(null); + fireEvent.click(screen.getAllByRole('option')[0]); + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe('ccAgent.quickSwitcher.missing'), + ); + await search('repo'); + h.get.mockResolvedValueOnce(catalogSessionForGrouping(row('a', { workingDir: '/moved' }))); + fireEvent.click(screen.getByRole('option')); + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe('ccAgent.quickSwitcher.changed'), + ); + expect(screen.getByTestId('route').textContent).toBe('/settings'); + }); + + it('opens an empty project as a draft without creating a task', async () => { + h.dirs.mockResolvedValue([ + { path: '/empty', exists: true, lastUsedAt: '2026-01-01T00:00:00Z' }, + ]); + setup(); + await search('empty'); + fireEvent.click(screen.getByRole('option')); + await waitFor(() => expect(screen.getByTestId('route').textContent).toBe('/cc-agent/new')); + expect(h.get).not.toHaveBeenCalled(); + expect(h.draft).toHaveBeenCalledWith({ + workingDir: '/empty', + remoteHostId: null, + deviceLinkDeviceId: null, + deviceLinkDeviceName: null, + }); + expect((screen.getByLabelText('draft') as HTMLInputElement).value).toBe('unsent draft'); + }); + + it('clears an injected historical row when a later authoritative delete arrives', async () => { + setup(); + await search('Task a'); + fireEvent.click(screen.getByRole('option')); + await waitFor(() => expect(screen.getByTestId('focus').textContent).toBe('a')); + act(() => + h.patched.forEach((listener) => listener({ sessionId: 'a', patch: { status: 'deleted' } })), + ); + expect(screen.getByTestId('focus').textContent).toBe(''); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/quickSwitcherReveal.test.ts b/apps/desktop/src/renderer/__tests__/quickSwitcherReveal.test.ts new file mode 100644 index 00000000000..d703ec4f5fc --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/quickSwitcherReveal.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { revealQuickSwitcherTarget } from '../features/cc-agent/lib/quickSwitcherReveal'; + +const cleanups: Array<() => void> = []; +beforeEach(() => { + vi.stubGlobal('CSS', { escape: (value: string) => value }); + const rect = new DOMRect(0, 0, 100, 20); + vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue( + Object.assign([rect], { item: (index: number) => (index === 0 ? rect : null) }), + ); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); +}); +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + document.body.replaceChildren(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('quick switch sidebar reveal', () => { + it('waits for mounting and expansion before focusing and scrolling the exact task', async () => { + cleanups.push( + revealQuickSwitcherTarget({ kind: 'session', sessionId: 'target', projectKey: 'local:repo' }), + ); + document.body.innerHTML = + '
'; + const section = document.body.firstElementChild!; + const row = section.firstElementChild!; + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(row.scrollIntoView).not.toHaveBeenCalled(); + let finish!: () => void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + Object.defineProperty(section, 'getAnimations', { value: () => [{ finished }] }); + section.removeAttribute('data-sidebar-section-collapsed'); + await Promise.resolve(); + expect(row.scrollIntoView).not.toHaveBeenCalled(); + finish(); + await vi.waitFor(() => expect(document.activeElement).toBe(row)); + expect(row.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }); + }); + it('targets the project header rather than its potentially tall child list', async () => { + document.body.innerHTML = + '
'; + const header = document.querySelector('[data-project-header]'); + cleanups.push( + revealQuickSwitcherTarget({ kind: 'project', sessionId: 'target', projectKey: 'local:repo' }), + ); + await vi.waitFor(() => expect(document.activeElement).toBe(header)); + }); + it('does not steal focus after its navigation has been canceled', async () => { + const cancel = revealQuickSwitcherTarget({ + kind: 'session', + sessionId: 'target', + projectKey: null, + }); + cancel(); + document.body.innerHTML = + '
'; + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/components/layout/MainLayout.tsx b/apps/desktop/src/renderer/components/layout/MainLayout.tsx index 69ec3e7f57b..2a8aafbe024 100644 --- a/apps/desktop/src/renderer/components/layout/MainLayout.tsx +++ b/apps/desktop/src/renderer/components/layout/MainLayout.tsx @@ -34,6 +34,7 @@ import { pluginScheduleNavigationState } from '@/features/scheduler/lib/pluginSc import { ScheduleSessionIndexOwner } from '@/features/scheduler/components/ScheduleSessionIndexOwner'; import { FeatureSidebarSlotProvider } from '@/features/feature-context'; import { useAppShortcut } from '@/hooks/useAppShortcut'; +import { QuickSwitcher } from '@/features/cc-agent/QuickSwitcher'; import { isAppInteractionLocked } from '@/lib/appInteractionLock'; import { useCloseShortcutShellOwner } from '@/hooks/useCloseWindowShortcut'; import { @@ -1361,6 +1362,7 @@ export function MainLayout() { + { setIsSidebarCollapsed(false); setIsRailMode(false); }} />
(() => quickFocus ? { + ...savedFilter, status: 'all', projects: 'all', projectsAsSet: null, + vendor: 'all', lastActivity: 'all', groupBy: 'project', + isFilterActive: false, isSessionContentFiltered: false, + // A temporary projection must not garbage-collect the saved filter universe. + gc: () => {}, + setStatus: (value) => { clearQuickSwitcherFocus(); savedFilter.setStatus(value); }, + toggleProject: (value) => { clearQuickSwitcherFocus(); savedFilter.toggleProject(value); }, + setProjectsAll: () => { clearQuickSwitcherFocus(); savedFilter.setProjectsAll(); }, + setVendor: (value) => { clearQuickSwitcherFocus(); savedFilter.setVendor(value); }, + setLastActivity: (value) => { clearQuickSwitcherFocus(); savedFilter.setLastActivity(value); }, + setGroupBy: (value) => { clearQuickSwitcherFocus(); savedFilter.setGroupBy(value); }, + resetContentFilters: () => { clearQuickSwitcherFocus(); savedFilter.resetContentFilters(); }, + } : savedFilter, [savedFilter, quickFocus]); const includeArchived = filter.status; - const sessionsHook = useCCSessions({ includeArchived }); + const storedSessionsHook = useCCSessions({ includeArchived }); + const focusedSessions = useMemo(() => { + const target = quickFocus?.session; + if (!target || target.deviceLinkDeviceId || storedSessionsHook.sessions.some((s) => s.id === target.id)) return storedSessionsHook.sessions; + return [...storedSessionsHook.sessions, target]; + }, [storedSessionsHook.sessions, quickFocus]); + const sessionsHook = { ...storedSessionsHook, sessions: focusedSessions, + effectiveIncludeArchived: quickFocus ? 'all' as const : storedSessionsHook.effectiveIncludeArchived }; const { sessions: allSessionsForAttention } = useCCSessions({ includeArchived: 'all' }); const remoteProjectSessions = useRemoteProjectSessions(); const remoteDevices = useRemoteDevices(); @@ -804,6 +828,7 @@ function ExpandedView({ scheduleSessionIndex, }: ExpandedProps) { const { t, i18n } = useTranslation(); + const quickFocus = useQuickSwitcherFocus(); const localPlatform = window.electronAPI.platform; const { sessions, refreshSessions, patchLocal, effectiveIncludeArchived } = sessionsHook; const { @@ -1373,20 +1398,27 @@ function ExpandedView({ const groups = useProjectGroups(activityFilteredSessions, projectAliases.aliases); // 普通项目目录也需要保留「所有会话都已单独置顶」的项目身份,供用户继续 // 从 ProjectNode 菜单置顶整个项目;实际项目子行在渲染前仍会排除已置顶会话。 - const groupsWithPinnedProjects = useProjectGroups( + const groupedWithPinnedProjects = useProjectGroups( activityFilteredSessions, projectAliases.aliases, true, ); + const groupsWithPinnedProjects = useMemo(() => { + const project = quickFocus?.project; + if (!project || project.sessions.length > 0 || isProjectHidden(project.projectKey, hiddenProjectKeys, localPlatform) || groupedWithPinnedProjects.projects.some((p) => p.projectKey === project.projectKey)) return groupedWithPinnedProjects; + return { ...groupedWithPinnedProjects, projects: [...groupedWithPinnedProjects.projects, project] }; + }, [groupedWithPinnedProjects, quickFocus, hiddenProjectKeys, localPlatform]); // Project pinning is independent from conversation pinning. This catalogue // keeps pinned conversations inside their project solely for project identity // and project-level actions; the normal project tree above remains deduped. - const allProjectGroups = useProjectGroups(sidebarSessions, projectAliases.aliases, true); + const groupedAllProjectGroups = useProjectGroups(sidebarSessions, projectAliases.aliases, true); + const allProjectGroups = quickFocus ? groupsWithPinnedProjects : groupedAllProjectGroups; const activeWorkingDirs = useMemo( () => allProjectGroups.projects.map((p) => p.projectKey), [allProjectGroups.projects], ); const collapse = useCollapsedProjects(activeWorkingDirs, sidebarSettingsSnapshot.dataOwnerId); + const projectReveal = useQuickSwitcherProjectReveal(collapse, activeWorkingDirs); // 项目过滤 GC 的「宇宙」用**全量**(不按机器过滤)项目键 —— 否则在某机器作用域下 remount, // gcProjectsAgainstActive 会把其它机器的项目从已保存的项目过滤里误删(它们只是被切换栏隐藏、 @@ -1406,7 +1438,16 @@ function ExpandedView({ // 内联会话搜索:输入行在 SidebarTopNav 末行,状态经 ConversationSearchProvider 共享; // query 非空时同一份顶部导航 sticky 钉住,结果替换下方列表,不再用 overlay 盖输入框。 - const { search, openSignal } = useConversationSearchContext(); + const { search: sidebarSearch, openSignal } = useConversationSearchContext(); + const revealSearch = useRef({ nonce: quickFocus?.nonce, query: sidebarSearch.query, openSignal }); + useEffect(() => { + const previous = revealSearch.current; + if (quickFocus && previous.nonce === quickFocus.nonce && (previous.query !== sidebarSearch.query || previous.openSignal !== openSignal)) clearQuickSwitcherFocus(); + revealSearch.current = { nonce: quickFocus?.nonce, query: sidebarSearch.query, openSignal }; + }, [quickFocus, sidebarSearch.query, openSignal]); + // Preserve the independent search query/lock; only its result overlay yields + // while a quick-switch destination is being revealed. + const search = quickFocus ? { ...sidebarSearch, trimmed: '' } : sidebarSearch; const gcProjectKeys = useMemo( () => projectUniverse.projects.map((p) => p.projectKey), [projectUniverse.projects], @@ -1672,7 +1713,7 @@ function ExpandedView({ const matchingSessions = vendorPredicate ? project.sessions.filter(vendorPredicate) : project.sessions; - if (matchingSessions.length === 0) return []; + if (matchingSessions.length === 0 && project.projectKey !== quickFocus?.project?.projectKey) return []; return [ { ...project, @@ -1686,7 +1727,7 @@ function ExpandedView({ hostProjectSort.order, hostProjectSort.projectOrder, ); - }, [visibleProjects, pinnedProjectKeys, vendorPredicate, hostProjectSort]); + }, [visibleProjects, pinnedProjectKeys, vendorPredicate, hostProjectSort, quickFocus]); // 折叠 rail 没有独立的 Pinned 项目瓷砖,因此项目面板必须保留置顶项目, // 否则侧栏折叠后这些项目及其取消置顶入口都会完全不可达。 @@ -3502,9 +3543,9 @@ function ExpandedView({ displaySessions={displaySessions} sessionVariant={sessionVariant} statusFilter={filter.status} - isCollapsed={collapse.collapsed.has(project.projectKey)} + isCollapsed={projectReveal.collapsed.has(project.projectKey)} collapsedAttentionTone={ - collapse.collapsed.has(project.projectKey) + projectReveal.collapsed.has(project.projectKey) ? collapsedAttentionToneFor(displaySessions ?? project.sessions) : null } @@ -3516,7 +3557,7 @@ function ExpandedView({ scheduleSessionIndex={scheduleSessionIndex} selectedSessionIds={selectedSessionIds} disableSessionCollapse={false} - onToggle={collapse.toggle} + onToggle={projectReveal.toggle} isProjectPinned onToggleProjectPin={handleToggleProjectPin} onRenameProject={handleProjectAliasChange} @@ -3562,8 +3603,8 @@ function ExpandedView({ dialogueCount={allGroups.dialogues.length} allProjectKeysForOrder={gcProjectKeys} filter={filter} - collapsed={collapse.collapsed} - isAllCollapsed={collapse.isAllCollapsed} + collapsed={projectReveal.collapsed} + isAllCollapsed={projectReveal.isAllCollapsed} activeSessionId={activeSessionId} viewedSessionId={viewedSessionId} runningSessionIds={displayRunningSessionIds} @@ -3578,12 +3619,12 @@ function ExpandedView({ onMoveSession={handleMoveSession} projectOptions={projectPickerOptions} onScheduleAction={handleScheduleAction} - onToggleProject={collapse.toggle} + onToggleProject={projectReveal.toggle} onToggleProjectPin={handleToggleProjectPin} onRenameProject={handleProjectAliasChange} onRemoveFromSidebar={handleRemoveProjectFromSidebar} - onCollapseAll={collapse.collapseAll} - onExpandAll={collapse.expandAll} + onCollapseAll={projectReveal.collapseAll} + onExpandAll={projectReveal.expandAll} onCreateProject={handleCreateProject} onCreateInProject={handleCreateInProject} onOpenConversationSearch={handleOpenConversationSearch} diff --git a/apps/desktop/src/renderer/features/cc-agent/QuickSwitcher.tsx b/apps/desktop/src/renderer/features/cc-agent/QuickSwitcher.tsx new file mode 100644 index 00000000000..842f22bc884 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/QuickSwitcher.tsx @@ -0,0 +1,657 @@ +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { Folder, MessageSquare, Search } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useAppShortcut } from '@/hooks/useAppShortcut'; +import { isAppInteractionLocked } from '@/lib/appInteractionLock'; +import { useSwitcherDevices } from '@/features/device-link/useMachineSwitcher'; +import { + remoteProjectsStore, + useRemoteProjectSessions, +} from '@/features/device-link/remoteProjectsStore'; +import { + MACHINE_LOCAL, + setSelectedMachineIdTransient, +} from '@/features/device-link/selectedMachineStore'; +import { + getDataOwnerGeneration, + isDataOwnerGenerationCurrent, + isDataOwnerPushCurrent, +} from '@/contexts/dataOwnerGeneration'; +import { useCCSessions } from '@/hooks/useCCSessions'; +import { readQuickSwitcherCatalog } from '@/lib/quickSwitcherService'; +import { isDeviceLinkRemotePushCurrent } from '@/lib/remoteDataOwnerPushFence'; +import { resolveSessionRoute } from '@/lib/orcaSessionIdentity'; +import { onPatch, onRefresh } from '@/lib/sessionsBus'; +import type { Session } from '@/lib/ccAgent.types'; +import type { QuickSwitcherCatalogPage } from '../../../shared/quickSwitcher'; +import { patchDraft } from '@/state/newMakerDraft'; +import { extractIpcError } from '@/utils/ipcError'; +import { + clearQuickSwitcherFocus, + clearQuickSwitcherFocusOutsideRoute, + patchQuickSwitcherFocusSession, + requestQuickSwitcherFocus, +} from '@/state/quickSwitcherFocus'; +import { useProjectAliases } from './hooks/useProjectAliases'; +import { useHiddenProjects } from './hooks/useHiddenProjects'; +import { isProjectHidden } from './lib/sidebarProjectVisibility'; +import { + projectIdentityKey, + projectIdentityKeyForSession, + projectKeyComparisonKey, +} from './lib/projectGrouping'; +import { + catalogSessionForGrouping, + projectSwitchTarget, + quickSwitcherProjects, + searchQuickSwitcher, + type QuickSwitcherResult, +} from './lib/quickSwitcher'; + +/** Main-window entry; its directory and query state are independent of sidebar search. */ +export function QuickSwitcher({ revealSidebar }: { revealSidebar: () => void }) { + const [open, setOpen] = useState(false); + const location = useLocation(); + useLayoutEffect(() => { + clearQuickSwitcherFocusOutsideRoute(location.pathname); + }, [location.pathname]); + useEffect(() => () => clearQuickSwitcherFocus(), []); + useEffect( + () => + window.electronAPI.onRsbBrowserCommand?.(({ command }) => { + if ( + command === 'open-quick-switcher' && + document.body.dataset.appShortcutRecording !== '1' && + !isAppInteractionLocked() + ) { + // Guest keys cannot reach useAppShortcut; repeated signals must not close the dialog. + setOpen(true); + } + }), + [], + ); + useEffect(() => { + const offLocal = window.electronAPI.localDb.sessionsPush?.onPatched( + ({ sessionId, patch }, stamp) => { + if (isDataOwnerPushCurrent(stamp)) + patchQuickSwitcherFocusSession(undefined, sessionId, patch); + }, + ); + const offPatch = onPatch((id, patch) => patchQuickSwitcherFocusSession(undefined, id, patch)); + const offRemote = window.electronAPI.deviceLink?.onRemotePush?.((push, stamp) => { + if ( + !isDeviceLinkRemotePushCurrent(push, stamp) || + push.channel !== 'local-db:sessions:patched' || + !push.payload || + typeof push.payload !== 'object' + ) + return; + if ( + 'sessionId' in push.payload && + typeof push.payload.sessionId === 'string' && + 'patch' in push.payload && + push.payload.patch && + typeof push.payload.patch === 'object' + ) { + patchQuickSwitcherFocusSession(push.deviceId, push.payload.sessionId, push.payload.patch); + } + }); + return () => { + offLocal?.(); + offRemote?.(); + offPatch(); + }; + }, []); + useAppShortcut( + 'open-quick-switcher', + () => { + setOpen((value) => !value); + return true; + }, + { stopImmediate: true }, + ); + return ( + + {open && setOpen(false)} revealSidebar={revealSidebar} />} + + ); +} + +function QuickSwitcherBody({ + close, + revealSidebar, +}: { + close: () => void; + revealSidebar: () => void; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const input = useRef(null); + const priorFocus = useRef(document.activeElement); + const selectedOnClose = useRef(false); + const composing = useRef(false); + const generation = useRef(0); + const selectionGeneration = useRef(0); + const alive = useRef(true); + const [owner] = useState(getDataOwnerGeneration); + const [query, setQuery] = useState(''); + const [selectedKey, setSelectedKey] = useState(null); + // Keyboard events can arrive before React paints the preceding selection. + // Event handlers consume this synchronous cursor; state renders the same key. + const selectedKeyRef = useRef(null); + const queryRef = useRef(''); + const chooseKey = useCallback((key: string | null) => { + selectedKeyRef.current = key; + setSelectedKey(key); + }, []); + const [catalog, setCatalog] = useState([]); + const [recent, setRecent] = useState< + Array<{ path: string; lastUsedAt: string; exists: boolean }> + >([]); + const [incomplete, setIncomplete] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [opening, setOpening] = useState(false); + const openingRef = useRef(false); + const devices = useSwitcherDevices(); + const devicesRef = useRef(devices); + devicesRef.current = devices; + const remote = useRemoteProjectSessions(); + const { sessions: knownLocal } = useCCSessions({ includeArchived: 'all' }); + const knownRef = useRef({ local: knownLocal, remote }); + knownRef.current = { local: knownLocal, remote }; + const { aliases } = useProjectAliases(); + const { hiddenProjectKeys } = useHiddenProjects(); + const hiddenRef = useRef(hiddenProjectKeys); + hiddenRef.current = hiddenProjectKeys; + const platform = window.electronAPI.platform; + const deviceSignature = JSON.stringify(devices); + + useEffect(() => { + alive.current = true; + let timer: ReturnType | undefined; + const refresh = async () => { + const revision = ++generation.current; + const current = () => + alive.current && generation.current === revision && isDataOwnerGenerationCurrent(owner); + setLoading(true); + const local = readQuickSwitcherCatalog( + (cursor) => window.electronAPI.localDb.conversations.catalog(cursor), + current, + ) + .then((rows) => ({ + rows: rows?.map(catalogSessionForGrouping) ?? [], + complete: rows !== null, + })) + .catch(() => ({ rows: knownRef.current.local, complete: false })); + const origins = devicesRef.current + .filter((d) => d.status !== 'rejected') + .map(async (device) => { + try { + if (device.status !== 'connected') throw new Error('offline'); + const rows = await readQuickSwitcherCatalog( + async (cursor) => + (await window.electronAPI.deviceLink.invoke( + device.deviceId, + 'local-db:conversations:catalog', + [cursor], + )) as QuickSwitcherCatalogPage, + current, + ); + return { + rows: + rows?.map((row) => + catalogSessionForGrouping({ + ...row, + deviceLinkDeviceId: device.deviceId, + deviceLinkDeviceName: device.name, + }), + ) ?? [], + complete: rows !== null, + }; + } catch { + return { + rows: knownRef.current.remote.filter((s) => s.deviceLinkDeviceId === device.deviceId), + complete: false, + }; + } + }); + const pages = new Map(); + let pending = origins.length + 2; + let dirsFailed = false; + const publish = () => { + if (!current()) return; + setCatalog([...pages.values()].flatMap((page) => page.rows)); + setIncomplete( + pending > 0 || [...pages.values()].some((page) => !page.complete) || dirsFailed, + ); + setLoading(pending > 0); + }; + // A slow/offline peer must not hold back already available local results. + await Promise.all([ + ...[local, ...origins].map(async (request, index) => { + pages.set(index, await request); + --pending; + publish(); + }), + window.electronAPI.localDb.recentWorkdirs + .list() + .catch(() => null) + .then((dirs) => { + dirsFailed = dirs === null; + --pending; + if (current()) setRecent(dirs ?? []); + publish(); + }), + ]); + }; + const scheduleRefresh = () => { + ++generation.current; // stale reads lose authority immediately, before the debounce. + clearTimeout(timer); + timer = setTimeout(() => void refresh(), 150); + }; + void refresh(); + const offRefresh = onRefresh(scheduleRefresh); + const metadataChanged = (id: string, patch: Partial, deviceId?: string) => { + if ( + [ + 'title', + 'status', + 'workingDir', + 'workspaceKind', + 'remoteHostId', + 'pinnedAt', + 'userSendAt', + ].some((key) => key in patch) + ) + scheduleRefresh(); + else if ('updatedAt' in patch && isDataOwnerGenerationCurrent(owner)) { + // Streaming activity does not require rereading every title. Keep the + // visible activity field fresh without merging any message payload. + setCatalog((rows) => + rows.map((row) => + row.id === id && row.deviceLinkDeviceId === deviceId + ? { ...row, updatedAt: patch.updatedAt ?? row.updatedAt } + : row, + ), + ); + } + }; + const offPatch = onPatch((id, patch) => metadataChanged(id, patch)); + const offCreated = window.electronAPI.localDb.sessionsPush?.onCreated((_payload, stamp) => { + if (isDataOwnerPushCurrent(stamp)) scheduleRefresh(); + }); + const offPatched = window.electronAPI.localDb.sessionsPush?.onPatched( + ({ sessionId, patch }, stamp) => { + if (isDataOwnerPushCurrent(stamp)) metadataChanged(sessionId, patch); + }, + ); + // Reuse the existing sessions subscription; opening this panel does not + // subscribe to new devices or broaden remote permissions. + const offRemote = window.electronAPI.deviceLink?.onRemotePush?.((push, stamp) => { + if ( + !isDeviceLinkRemotePushCurrent(push, stamp) || + !devicesRef.current.some((d) => d.deviceId === push.deviceId && d.status === 'connected') + ) + return; + if (push.channel === 'local-db:sessions:created') scheduleRefresh(); + if ( + push.channel === 'local-db:sessions:patched' && + push.payload && + typeof push.payload === 'object' && + 'patch' in push.payload + ) { + const patch = push.payload.patch; + if ( + patch && + typeof patch === 'object' && + 'sessionId' in push.payload && + typeof push.payload.sessionId === 'string' + ) + metadataChanged(push.payload.sessionId, patch, push.deviceId); + } + }); + return () => { + alive.current = false; + ++generation.current; + clearTimeout(timer); + offRefresh(); + offPatch(); + offCreated?.(); + offPatched?.(); + offRemote?.(); + }; + }, [deviceSignature, owner]); + + const visibleCatalog = useMemo( + () => + catalog.filter( + (s) => + !s.deviceLinkDeviceId || + devices.some((d) => d.deviceId === s.deviceLinkDeviceId && d.status !== 'rejected'), + ), + [catalog, devices], + ); + const projects = useMemo( + () => quickSwitcherProjects(visibleCatalog, aliases, recent, platform), + [visibleCatalog, aliases, recent, platform], + ); + const { results, total } = useMemo( + () => + searchQuickSwitcher({ + query, + sessions: visibleCatalog, + projects, + hiddenProjectKeys, + platform, + unnamedLabel: t('ccAgent.common.unnamedSession'), + }), + [query, visibleCatalog, projects, hiddenProjectKeys, platform, t], + ); + const active = + results.find((result) => result.key === selectedKey) ?? + (selectedKey === null ? results[0] : undefined); + useEffect(() => { + // A passive default must not overwrite a newer query or keyboard choice. + if (queryRef.current === query && selectedKeyRef.current === null && results[0]) + chooseKey(results[0].key); + }, [chooseKey, query, results, selectedKey]); + const listId = useId(); + const rowRefs = useRef(new Map()); + useEffect(() => { + if (active) rowRefs.current.get(active.key)?.scrollIntoView({ block: 'nearest' }); + }, [active?.key]); + + async function select(result: QuickSwitcherResult) { + if (openingRef.current || !isDataOwnerGenerationCurrent(owner)) return; + openingRef.current = true; + setOpening(true); + setError(null); + const revision = generation.current; + const selection = ++selectionGeneration.current; + const current = () => + alive.current && + revision === generation.current && + selection === selectionGeneration.current && + isDataOwnerGenerationCurrent(owner); + try { + const currentId = /^\/cc-agent\/(?:files\/)?([^/]+)$/.exec(location.pathname)?.[1]; + const currentKey = currentId + ? `${remoteProjectsStore.getSessionDeviceId(currentId) ?? 'local'}:${currentId}` + : null; + const candidate = + result.kind === 'session' + ? result.session + : projectSwitchTarget(result.project, currentKey); + let session: Session | null = null; + if (candidate) { + const deviceId = candidate.deviceLinkDeviceId; + if ( + deviceId && + !devicesRef.current.some((d) => d.deviceId === deviceId && d.status === 'connected') + ) + throw new Error('offline'); + // Read from the explicitly selected origin, never the ambiguous bare-id router. + session = deviceId + ? ((await window.electronAPI.deviceLink.invoke(deviceId, 'local-db:sessions:get', [ + candidate.id, + ])) as Session) + : await window.electronAPI.localDb.sessions.get(candidate.id); + if (!current()) return; + if ( + deviceId && + !devicesRef.current.some((d) => d.deviceId === deviceId && d.status === 'connected') + ) + throw new Error('offline'); + if (!session || session.id !== candidate.id || session.status === 'deleted') + throw new Error('missing'); + session = { + ...session, + deviceLinkDeviceId: deviceId, + deviceLinkDeviceName: candidate.deviceLinkDeviceName, + }; + if ( + result.kind === 'project' && + projectKeyComparisonKey(projectIdentityKeyForSession(session), platform) !== + projectKeyComparisonKey(result.project.projectKey, platform) + ) + throw new Error('changed'); + const routedOrigin = remoteProjectsStore.getSessionDeviceId(session.id); + if (routedOrigin && routedOrigin !== deviceId) throw new Error('ambiguous origin'); + } + const project = + result.kind === 'project' + ? result.project + : (projects.find( + (p) => p.projectKey === projectIdentityKeyForSession(session ?? result.session), + ) ?? null); + if ( + project && + isProjectHidden(project.projectKey, hiddenRef.current, platform) && + result.kind === 'project' + ) + throw new Error('changed'); + const route = session ? await resolveSessionRoute(session.id, session) : '/cc-agent/new'; + if (!current()) return; + if (!session && project) { + const currentDirs = await window.electronAPI.localDb.recentWorkdirs.list(); + if (!current()) return; + if ( + !currentDirs.some( + (dir) => + dir.exists && + projectKeyComparisonKey(projectIdentityKey('local', dir.path, null), platform) === + projectKeyComparisonKey(project.projectKey, platform), + ) + ) + throw new Error('missing'); + patchDraft({ + workingDir: project.workingDir, + remoteHostId: project.remoteHostId, + deviceLinkDeviceId: project.deviceLinkDeviceId, + deviceLinkDeviceName: project.deviceLinkDeviceName, + }); + } + if (session?.deviceLinkDeviceId) { + remoteProjectsStore.mergeDeviceSessions( + session.deviceLinkDeviceId, + session.deviceLinkDeviceName ?? '', + [session], + session.status === 'archived' ? 'archived' : 'active', + ); + remoteProjectsStore.pinSessionOrigin(session.deviceLinkDeviceId, session.id); + } + setSelectedMachineIdTransient([ + session?.deviceLinkDeviceId ?? project?.deviceLinkDeviceId ?? MACHINE_LOCAL, + ]); + selectedOnClose.current = true; + revealSidebar(); + close(); + navigate(route); + requestQuickSwitcherFocus({ kind: result.kind, route, session, project }); + } catch (cause) { + const code = extractIpcError(cause)?.code; + const reason = + code === 'NOT_FOUND' + ? 'missing' + : code === 'PERMISSION_DENIED' + ? 'access' + : cause instanceof Error && ['offline', 'missing', 'changed'].includes(cause.message) + ? cause.message + : 'unavailable'; + if (current()) setError(t(`ccAgent.quickSwitcher.${reason}`)); + } finally { + openingRef.current = false; + if (alive.current) setOpening(false); + } + } + + return ( + + + { + event.preventDefault(); + input.current?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + if ( + !selectedOnClose.current && + priorFocus.current instanceof HTMLElement && + priorFocus.current.isConnected + ) + priorFocus.current.focus(); + }} + onEscapeKeyDown={(event) => { + if (composing.current || event.isComposing) event.preventDefault(); + }} + > + + {t('ccAgent.quickSwitcher.title')} + + + {t('ccAgent.quickSwitcher.hint')} + +
+ + {/* The wrapper owns the pill shape; rounding this unpadded input clips its caret. */} + { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onChange={(event) => { + ++selectionGeneration.current; + queryRef.current = event.target.value; + setQuery(event.target.value); + chooseKey(null); + setError(null); + }} + onKeyDown={(event) => { + if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) + return; + if (queryRef.current !== query) return; + const currentChoice = + results.find((result) => result.key === selectedKeyRef.current) ?? + (selectedKeyRef.current === null ? results[0] : undefined); + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const index = currentChoice ? results.indexOf(currentChoice) : -1; + const next = + index < 0 + ? event.key === 'ArrowDown' + ? 0 + : results.length - 1 + : (index + (event.key === 'ArrowDown' ? 1 : -1) + results.length) % + results.length; + chooseKey(results[next]?.key ?? null); + } else if (event.key === 'Enter') { + event.preventDefault(); + if (currentChoice) void select(currentChoice); + } + }} + /> +
+
+ {results.map((result, index) => { + const Icon = result.kind === 'project' ? Folder : MessageSquare; + const item = result.kind === 'project' ? result.project : result.session; + const hiddenProject = + result.kind === 'session' && + isProjectHidden( + projectIdentityKeyForSession(result.session) ?? '', + hiddenProjectKeys, + platform, + ); + return ( +
{ + if (node) rowRefs.current.set(result.key, node); + else rowRefs.current.delete(result.key); + }} + onMouseMove={() => chooseKey(result.key)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => void select(result)} + className={`flex cursor-pointer select-none items-center gap-3 rounded-lg px-3 py-2 ${result.key === active?.key ? 'bg-[var(--surface-hover)]' : ''}`} + > + +
+
{result.title}
+
+ {[ + t( + result.kind === 'project' + ? 'ccAgent.quickSwitcher.project' + : 'ccAgent.quickSwitcher.session', + ), + item.deviceLinkDeviceName ?? item.remoteHostId, + hiddenProject || + (result.kind === 'session' && result.session.workspaceKind === 'dialogue') + ? t('ccAgent.sidebar.dialogues') + : item.workingDir, + ] + .filter(Boolean) + .join(' · ')} +
+
+
+ ); + })} + {results.length === 0 && ( +

+ {t( + query.trim() + ? loading + ? 'ccAgent.quickSwitcher.loading' + : 'ccAgent.quickSwitcher.noResults' + : 'ccAgent.quickSwitcher.hint', + )} +

+ )} +
+
+ {error ?? + (incomplete + ? t('ccAgent.quickSwitcher.incomplete') + : total > 24 + ? t('ccAgent.quickSwitcher.refine') + : '')} +
+
+ {t('ccAgent.quickSwitcher.keys')} + + {t('ccAgent.quickSwitcher.cancel')} + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/features/cc-agent/hooks/__tests__/useQuickSwitcherProjectReveal.test.tsx b/apps/desktop/src/renderer/features/cc-agent/hooks/__tests__/useQuickSwitcherProjectReveal.test.tsx new file mode 100644 index 00000000000..602b2446b6b --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/hooks/__tests__/useQuickSwitcherProjectReveal.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setDataOwnerGeneration } from '@/contexts/dataOwnerGeneration'; +import { sidebarOwnerStorageKey } from '@/lib/sidebarOwnerStorage'; +import { + clearQuickSwitcherFocus, + clearQuickSwitcherFocusOutsideRoute, + requestQuickSwitcherFocus, +} from '@/state/quickSwitcherFocus'; +import type { ProjectNode } from '../../lib/projectGrouping'; +import { deviceLinkProjectKey, projectIdentityKey } from '../../lib/projectGrouping'; +import { catalogSessionForGrouping } from '../../lib/quickSwitcher'; +import { useCollapsedProjects } from '../useCollapsedProjects'; +import { useQuickSwitcherProjectReveal } from '../useQuickSwitcherProjectReveal'; + +const PROJECT = 'local:/workspace/a'; +const OTHER_PROJECT = 'local:/workspace/b'; +const ACTIVE_PROJECTS = [PROJECT, OTHER_PROJECT]; +const STORAGE_KEY = sidebarOwnerStorageKey('cc-agent.sidebar.collapsedProjects', 'owner-a'); + +function focusProject(projectKey = PROJECT, kind: 'project' | 'session' = 'project') { + const project: ProjectNode = { + projectKey, + scope: 'local', + workingDir: projectKey.slice('local:'.length), + remoteHostId: null, + deviceLinkDeviceId: null, + deviceLinkDeviceName: null, + deviceLinkConnectionStatus: null, + displayName: projectKey, + segments: 1, + sessions: [], + latestActivityAt: '', + }; + const session = + kind === 'session' + ? catalogSessionForGrouping({ + id: 'target', + title: 'Target task', + workingDir: project.workingDir, + workspaceKind: 'project', + remoteHostId: null, + agentKind: 'cc', + status: 'active', + pinnedAt: null, + userSendAt: null, + createdAt: '', + updatedAt: '', + _count: { messages: 1 }, + }) + : null; + requestQuickSwitcherFocus({ kind, route: '/new', session, project }); +} + +function setup(activeProjectKeys = ACTIVE_PROJECTS) { + return renderHook( + ({ ownerId }) => { + const saved = useCollapsedProjects(activeProjectKeys, ownerId); + const visible = useQuickSwitcherProjectReveal(saved, activeProjectKeys); + return { saved, visible }; + }, + { initialProps: { ownerId: 'owner-a' } }, + ); +} + +beforeEach(() => { + setDataOwnerGeneration('owner-a', 1); + window.localStorage.clear(); + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + [PROJECT]: { collapsed: true, lastSeenAt: new Date().toISOString() }, + [OTHER_PROJECT]: { collapsed: true, lastSeenAt: new Date().toISOString() }, + }), + ); + vi.stubGlobal('CSS', { escape: (value: string) => value }); +}); + +afterEach(() => { + cleanup(); + clearQuickSwitcherFocus(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('quick-switch project expansion', () => { + it.each(['project', 'session'] as const)( + 'reveals a %s without changing its saved preference and restores it on navigation', + (kind) => { + const hook = setup(); + const storedBefore = window.localStorage.getItem(STORAGE_KEY); + const writes = vi.spyOn(Storage.prototype, 'setItem'); + expect(hook.result.current.visible.isAllCollapsed).toBe(true); + + act(() => focusProject(PROJECT, kind)); + + expect(hook.result.current.visible.collapsed).toEqual(new Set([OTHER_PROJECT])); + expect(hook.result.current.visible.isAllCollapsed).toBe(false); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + expect(writes).not.toHaveBeenCalled(); + expect(hook.result.current.saved.collapsed).toEqual(new Set(ACTIVE_PROJECTS)); + + act(() => clearQuickSwitcherFocusOutsideRoute('/settings')); + + expect(hook.result.current.visible.collapsed).toEqual(new Set(ACTIVE_PROJECTS)); + expect(hook.result.current.visible.isAllCollapsed).toBe(true); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + }, + ); + + it('moves temporary expansion between destinations and preserves preferences across remounts', () => { + const hook = setup(); + const storedBefore = window.localStorage.getItem(STORAGE_KEY); + act(() => focusProject()); + act(() => focusProject(OTHER_PROJECT)); + expect(hook.result.current.visible.collapsed).toEqual(new Set([PROJECT])); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + + hook.unmount(); + act(() => clearQuickSwitcherFocus()); + const reloaded = setup(); + expect(reloaded.result.current.visible.collapsed).toEqual(new Set(ACTIVE_PROJECTS)); + }); + + it('collapses a manually toggled revealed project instead of expanding its saved preference', () => { + const hook = setup(); + const storedBefore = window.localStorage.getItem(STORAGE_KEY); + act(() => focusProject()); + act(() => hook.result.current.visible.toggle(PROJECT)); + expect(hook.result.current.visible.collapsed).toEqual(new Set(ACTIVE_PROJECTS)); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + + act(() => hook.result.current.visible.toggle(PROJECT)); + expect(hook.result.current.visible.collapsed).toEqual(new Set([OTHER_PROJECT])); + expect(hook.result.current.saved.collapsed).toEqual(new Set([OTHER_PROJECT])); + }); + + it('persists an explicit collapse when the revealed project was originally expanded', () => { + const hook = setup(); + act(() => hook.result.current.visible.toggle(PROJECT)); + const storedBefore = window.localStorage.getItem(STORAGE_KEY); + act(() => focusProject()); + act(() => clearQuickSwitcherFocus()); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + expect(hook.result.current.visible.collapsed.has(PROJECT)).toBe(false); + + act(() => focusProject()); + act(() => hook.result.current.visible.toggle(PROJECT)); + expect(hook.result.current.saved.collapsed.has(PROJECT)).toBe(true); + expect(hook.result.current.visible.collapsed.has(PROJECT)).toBe(true); + }); + + it('respects toggling another project and ends the temporary expansion', () => { + const hook = setup(); + act(() => focusProject()); + act(() => hook.result.current.visible.toggle(OTHER_PROJECT)); + expect(hook.result.current.visible.collapsed).toEqual(new Set([PROJECT])); + expect(hook.result.current.saved.collapsed).toEqual(new Set([PROJECT])); + }); + + it.each(['collapseAll', 'expandAll'] as const)( + 'respects explicit %s during a reveal', + (action) => { + const hook = setup(); + act(() => focusProject()); + act(() => hook.result.current.visible[action]()); + const expected = new Set(action === 'collapseAll' ? ACTIVE_PROJECTS : []); + expect(hook.result.current.visible.collapsed).toEqual(expected); + expect(hook.result.current.saved.collapsed).toEqual(expected); + expect(hook.result.current.visible.isAllCollapsed).toBe(action === 'collapseAll'); + }, + ); + + it('does not project stale focus into another owner', () => { + const hook = setup(); + const ownerBKey = sidebarOwnerStorageKey('cc-agent.sidebar.collapsedProjects', 'owner-b'); + const storedBefore = window.localStorage.getItem(STORAGE_KEY)!; + window.localStorage.setItem(ownerBKey, storedBefore); + act(() => focusProject()); + setDataOwnerGeneration('owner-b', 2); + hook.rerender({ ownerId: 'owner-b' }); + expect(hook.result.current.visible.collapsed).toEqual(new Set(ACTIVE_PROJECTS)); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + expect(window.localStorage.getItem(ownerBKey)).toBe(storedBefore); + }); + + it('does not expand projects on other hosts with the same working directory', () => { + const remoteKey = projectIdentityKey('remote', '/workspace/a', 'ssh-host'); + const deviceKey = deviceLinkProjectKey('device-a', '/workspace/a'); + const keys = [PROJECT, remoteKey, deviceKey]; + const storedBefore = JSON.stringify( + Object.fromEntries( + keys.map((key) => [key, { collapsed: true, lastSeenAt: new Date().toISOString() }]), + ), + ); + window.localStorage.setItem(STORAGE_KEY, storedBefore); + const hook = setup(keys); + act(() => focusProject()); + expect(hook.result.current.visible.collapsed).toEqual(new Set([remoteKey, deviceKey])); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(storedBefore); + }); + + it('keeps an empty project catalogue out of the all-collapsed state', () => { + const hook = setup([]); + act(() => focusProject()); + expect(hook.result.current.visible.isAllCollapsed).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/features/cc-agent/hooks/useQuickSwitcherProjectReveal.ts b/apps/desktop/src/renderer/features/cc-agent/hooks/useQuickSwitcherProjectReveal.ts new file mode 100644 index 00000000000..d42e3f429a3 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/hooks/useQuickSwitcherProjectReveal.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { clearQuickSwitcherFocus, useQuickSwitcherFocus } from '@/state/quickSwitcherFocus'; +import { normalizeProjectKey } from '../lib/projectGrouping'; +import { revealQuickSwitcherTarget } from '../lib/quickSwitcherReveal'; +import type { useCollapsedProjects } from './useCollapsedProjects'; + +/** Navigation-only expansion shared by pinned and regular project rows. */ +export function useQuickSwitcherProjectReveal( + collapse: ReturnType, + activeProjectKeys: readonly string[], +) { + const quickFocus = useQuickSwitcherFocus(); + const focusedProjectKey = quickFocus?.project?.projectKey; + const collapsed = useMemo(() => { + if (!focusedProjectKey || !collapse.collapsed.has(focusedProjectKey)) return collapse.collapsed; + const visible = new Set(collapse.collapsed); + visible.delete(focusedProjectKey); + return visible; + }, [collapse.collapsed, focusedProjectKey]); + + useEffect(() => { + if (!quickFocus) return; + return revealQuickSwitcherTarget({ + kind: quickFocus.kind, + sessionId: quickFocus.session?.id ?? null, + projectKey: quickFocus.project?.projectKey ?? null, + }); + }, [quickFocus]); + + // Explicit user actions end the temporary override. Toggling a revealed row + // means collapse, even when its saved state was already collapsed. + const toggle = useCallback( + (projectKey: string) => { + const key = normalizeProjectKey(projectKey); + if (!key) return; + if (key === focusedProjectKey) collapse.setCollapsed(key, true); + else collapse.toggle(key); + clearQuickSwitcherFocus(); + }, + [collapse.setCollapsed, collapse.toggle, focusedProjectKey], + ); + const collapseAll = useCallback(() => { + collapse.collapseAll(); + clearQuickSwitcherFocus(); + }, [collapse.collapseAll]); + const expandAll = useCallback(() => { + collapse.expandAll(); + clearQuickSwitcherFocus(); + }, [collapse.expandAll]); + + return { + collapsed, + isAllCollapsed: + activeProjectKeys.length > 0 && activeProjectKeys.every((key) => collapsed.has(key)), + toggle, + collapseAll, + expandAll, + }; +} diff --git a/apps/desktop/src/renderer/features/cc-agent/lib/quickSwitcher.ts b/apps/desktop/src/renderer/features/cc-agent/lib/quickSwitcher.ts new file mode 100644 index 00000000000..83e50b62d59 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/lib/quickSwitcher.ts @@ -0,0 +1,167 @@ +import type { Session } from '@/lib/ccAgent.types'; +import { conversationSearchTitle } from '../../../../shared/conversationSearch'; +import type { QuickSwitcherSession } from '../../../../shared/quickSwitcher'; +import { fuzzyMatch } from './fuzzyMatch'; +import { + groupSessions, + normalizeWorkingDir, + projectIdentityKey, + projectKeyComparisonKey, + type ProjectNode, +} from './projectGrouping'; +import { isProjectHidden } from './sidebarProjectVisibility'; +import { sessionActivityMs } from './dateSessionGrouping'; + +export type QuickSwitcherResult = { + key: string; + title: string; + score: number; + exact: boolean; + activity: number; +} & ({ kind: 'project'; project: ProjectNode } | { kind: 'session'; session: Session }); + +export function quickSwitcherSessionKey( + session: Pick, +): string { + return `${session.deviceLinkDeviceId ?? 'local'}:${session.id}`; +} + +/** A grouping-only projection. Fetch a real Session before opening; never cache these defaults. */ +export function catalogSessionForGrouping(row: QuickSwitcherSession): Session { + return { + ...row, + deviceLinkDeviceId: row.deviceLinkDeviceId ?? undefined, + deviceLinkDeviceName: row.deviceLinkDeviceName ?? undefined, + userId: '', + model: '', + effort: 'medium', + permissionMode: 'auto', + sdkSessionId: null, + totalTokenUsage: 0, + totalCostUsd: 0, + contextTokens: 0, + contextWindow: 0, + fastMode: false, + clearedAt: null, + extraDirs: [], + }; +} + +export function quickSwitcherProjects( + sessions: readonly Session[], + aliases: ReadonlyMap, + recent: readonly { path: string; lastUsedAt: string; exists: boolean }[], + platform: string, +): ProjectNode[] { + const projects = groupSessions( + sessions.filter((s) => s.status !== 'deleted' && s.orcaRole !== 'worker'), + { projectAliases: aliases, includePinnedInProjects: true }, + ).projects; + const keys = new Set(projects.map((p) => projectKeyComparisonKey(p.projectKey, platform))); + // Recent directories also represent empty projects. They do not contribute fake tasks. + for (const dir of recent) { + if (!dir.exists) continue; + const workingDir = normalizeWorkingDir(dir.path); + if (!workingDir) continue; + const projectKey = projectIdentityKey('local', workingDir, null); + const key = projectKeyComparisonKey(projectKey, platform); + if (keys.has(key)) continue; + keys.add(key); + projects.push({ + projectKey, + workingDir, + scope: 'local', + remoteHostId: null, + deviceLinkDeviceId: null, + deviceLinkDeviceName: null, + deviceLinkConnectionStatus: null, + displayName: + aliases.get(projectKey) ?? workingDir.split('/').filter(Boolean).at(-1) ?? workingDir, + segments: 1, + sessions: [], + latestActivityAt: dir.lastUsedAt, + }); + } + return projects; +} + +/** Rank the complete catalogue before applying the shared 24-row display limit. */ +export function searchQuickSwitcher(args: { + query: string; + sessions: readonly Session[]; + projects: readonly ProjectNode[]; + hiddenProjectKeys: ReadonlySet; + platform: string; + unnamedLabel: string; +}): { results: QuickSwitcherResult[]; total: number } { + const query = args.query.trim(); + if (!query) return { results: [], total: 0 }; + const results: QuickSwitcherResult[] = []; + const rank = (title: string, activity: string) => { + const match = fuzzyMatch(title, query); + return match + ? { + title, + score: match.score, + exact: title.toLowerCase() === query.toLowerCase(), + activity: Date.parse(activity) || 0, + } + : null; + }; + for (const project of args.projects) { + if (isProjectHidden(project.projectKey, args.hiddenProjectKeys, args.platform)) continue; + const baseName = project.workingDir.split('/').filter(Boolean).at(-1) ?? project.displayName; + const matches = [ + rank(project.displayName, project.latestActivityAt), + rank(baseName, project.latestActivityAt), + ] + .filter((m): m is NonNullable => m !== null) + .sort((a, b) => Number(b.exact) - Number(a.exact) || b.score - a.score); + if (matches[0]) + results.push({ + ...matches[0], + title: project.displayName, + kind: 'project', + key: `project:${project.projectKey}`, + project, + }); + } + for (const session of args.sessions) { + if (session.status === 'deleted' || session.orcaRole === 'worker') continue; + const title = conversationSearchTitle(session.title, args.unnamedLabel); + const match = rank(title, new Date(sessionActivityMs(session) || 0).toISOString()); + if (match) + results.push({ + ...match, + kind: 'session', + key: `session:${quickSwitcherSessionKey(session)}`, + session, + }); + } + results.sort( + (a, b) => + Number(b.exact) - Number(a.exact) || + b.score - a.score || + b.activity - a.activity || + a.key.localeCompare(b.key), + ); + return { results: results.slice(0, 24), total: results.length }; +} + +export function projectSwitchTarget( + project: ProjectNode, + currentKey: string | null, +): Session | null { + return ( + project.sessions.find((s) => quickSwitcherSessionKey(s) === currentKey) ?? + [...project.sessions] + .filter((s) => s.status !== 'deleted') + .sort( + (a, b) => + Number(b.status === 'active') - Number(a.status === 'active') || + sessionActivityMs(b) - sessionActivityMs(a) || + a.id.localeCompare(b.id), + )[0] ?? + null + ); +} diff --git a/apps/desktop/src/renderer/features/cc-agent/lib/quickSwitcherReveal.ts b/apps/desktop/src/renderer/features/cc-agent/lib/quickSwitcherReveal.ts new file mode 100644 index 00000000000..3320a6fdede --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/lib/quickSwitcherReveal.ts @@ -0,0 +1,60 @@ +/** Reveal a mounted sidebar destination after its ancestor expansion transitions. */ +export function revealQuickSwitcherTarget(target: { + kind: 'project' | 'session'; + sessionId: string | null; + projectKey: string | null; +}): () => void { + const selector = + target.kind === 'project' && target.projectKey + ? `[data-project-workingdir="${CSS.escape(target.projectKey)}"] > [data-project-header="true"]` + : target.sessionId + ? `[data-sidebar-session-row="true"][data-session-id="${CSS.escape(target.sessionId)}"]` + : null; + if (!selector) return () => {}; + let disposed = false; + let waiting = false; + const reveal = () => { + if (disposed || waiting) return; + const row = document.querySelector(selector); + if ( + !row || + row.closest('[data-sidebar-section-collapsed="true"]') || + row.getClientRects().length === 0 + ) + return; + waiting = true; + observer.disconnect(); + const animations: Animation[] = []; + for (let element: HTMLElement | null = row; element; element = element.parentElement) { + animations.push( + ...(element.getAnimations?.() ?? []).filter( + (animation) => animation.effect?.getComputedTiming().iterations !== Infinity, + ), + ); + } + void Promise.allSettled(animations.map((animation) => animation.finished)).then(() => { + if (disposed || !row.isConnected) return; + row.scrollIntoView({ block: 'nearest' }); + row.focus({ preventScroll: true }); + clearTimeout(timeout); + }); + }; + const observer = new MutationObserver(reveal); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class', 'data-sidebar-section-collapsed'], + }); + const frame = requestAnimationFrame(reveal); + const timeout = setTimeout(() => { + disposed = true; + observer.disconnect(); + }, 2000); + return () => { + disposed = true; + observer.disconnect(); + cancelAnimationFrame(frame); + clearTimeout(timeout); + }; +} diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/AutomationSessionGroupItem.tsx b/apps/desktop/src/renderer/features/cc-agent/sidebar/AutomationSessionGroupItem.tsx index 5c4df0c0770..844c7adab05 100644 --- a/apps/desktop/src/renderer/features/cc-agent/sidebar/AutomationSessionGroupItem.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/AutomationSessionGroupItem.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { ChevronDown, ChevronRight, EllipsisVertical, Play } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { clearQuickSwitcherFocus, useQuickSwitcherFocus } from '@/state/quickSwitcherFocus'; import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; @@ -124,14 +125,16 @@ export const AutomationSessionGroupItem = memo(function AutomationSessionGroupIt group.id, group.legacyId, ); - const collapsed = controlledCollapsed ?? storedCollapsed; + const quickFocus = useQuickSwitcherFocus(); + const collapsed = (controlledCollapsed ?? storedCollapsed) && !group.sessions.some((s) => s.id === quickFocus?.session?.id); const toggleCollapsed = useCallback(() => { + clearQuickSwitcherFocus(); if (onCollapsedChange) { onCollapsedChange(!collapsed); return; } - toggleStoredCollapsed(); - }, [collapsed, onCollapsedChange, toggleStoredCollapsed]); + if (collapsed === storedCollapsed) toggleStoredCollapsed(); + }, [collapsed, storedCollapsed, onCollapsedChange, toggleStoredCollapsed]); // 轴 2:运行列表内部的「前 5 条 / 显示全部」临时态,离开自动收回。 // 收起告警列表和展开历史列表共用这一份状态,所以切折叠必须复位 —— 否则 // 收起态点过「显示全部」再展开会一次摊开整组历史(Codex #3184),对称地, diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/sections/PinnedSection.tsx b/apps/desktop/src/renderer/features/cc-agent/sidebar/sections/PinnedSection.tsx index 7d5ca136bef..fc0b232d8f9 100644 --- a/apps/desktop/src/renderer/features/cc-agent/sidebar/sections/PinnedSection.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/sections/PinnedSection.tsx @@ -32,6 +32,7 @@ import { type LucideIcon, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { clearQuickSwitcherFocus, useQuickSwitcherFocus } from '@/state/quickSwitcherFocus'; import { cn } from '@/lib/utils'; import { Tip } from '@/components/ui/tooltip'; @@ -196,7 +197,9 @@ export function PinnedSection({ const ViewStyleTriggerIcon = viewStyleIcon(mode); // 卡片视觉(card 瀑布流 / list 单列满宽)统称"卡片态";text 为紧凑行。 const isCardLike = mode !== 'text'; - const [collapsed, setCollapsed] = useState(false); + const quickFocus = useQuickSwitcherFocus(); + const [storedCollapsed, setCollapsed] = useState(false); + const collapsed = storedCollapsed && !quickFocus; const visibleEntries = entries; const visibleSessions = useMemo( @@ -313,7 +316,7 @@ export function PinnedSection({ {/* 段标题:与 项目/对话 段头同款,2026-07 用户定稿。 */}