From df382df8348761730b2e99a8285953ec74e6e8f2 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 22:06:05 +0800 Subject: [PATCH 01/54] fix: preserve composer attachments and require explicit dialog close --- web/src/components/NewSessionDialog.tsx | 4 - web/src/components/SessionControls.tsx | 28 +++--- web/src/components/StartSubSessionDialog.tsx | 2 +- web/test/components/NewSessionDialog.test.tsx | 9 +- web/test/components/SessionControls.test.tsx | 93 +++++++++++++++++++ .../components/StartSubSessionDialog.test.tsx | 20 ++++ 6 files changed, 132 insertions(+), 24 deletions(-) diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index 8756da4ed..9b063fe3a 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -322,7 +322,6 @@ export function NewSessionDialog({ }, [agentType]); const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !starting) onClose(); if (e.key === "Enter" && !starting) handleStart(); }; @@ -337,9 +336,6 @@ export function NewSessionDialog({ justifyContent: "center", zIndex: 9999, }} - onClick={(e) => { - if (e.target === e.currentTarget && !starting) onClose(); - }} onKeyDown={handleKey} role="dialog" > diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 123f6aef8..9782c9ebf 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -123,6 +123,12 @@ const INLINE_PASTE_TEXT_CHAR_LIMIT = 1200; type ComposerAttachment = { path: string; name: string }; +function buildComposerDraftScope(activeSession: SessionInfo | null, subSessionId?: string): string | null { + if (subSessionId && subSessionId.trim()) return `sub:${subSessionId.trim()}`; + if (activeSession?.name?.trim()) return `session:${activeSession.name.trim()}`; + return null; +} + function buildPastedTextFileName(now = new Date()): string { const compact = now.toISOString().replace(/[:.]/g, '-'); return `pasted-text-${compact}.txt`; @@ -532,8 +538,10 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on }, []); // Persist input draft across unmount/remount (sub-session minimize/restore) - const draftKey = activeSession ? `rcc_draft_${activeSession.name}` : null; - const attachmentDraftKey = activeSession ? `rcc_draft_attachments_${activeSession.name}` : null; + const composerDraftScope = buildComposerDraftScope(activeSession, subSessionId); + const draftKey = composerDraftScope ? `rcc_draft_${composerDraftScope}` : null; + const attachmentDraftKey = composerDraftScope ? `rcc_draft_attachments_${composerDraftScope}` : null; + const [hydratedAttachmentDraftKey, setHydratedAttachmentDraftKey] = useState(null); useEffect(() => { if (!draftKey || !divRef.current) return; const saved = sessionStorage.getItem(draftKey); @@ -548,6 +556,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on }, [draftKey]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { + setHydratedAttachmentDraftKey(null); if (!attachmentDraftKey) { setAttachments([]); attachmentDraftRef.current = []; @@ -556,28 +565,19 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on const saved = parseStoredComposerAttachments(sessionStorage.getItem(attachmentDraftKey)); setAttachments(saved); attachmentDraftRef.current = saved; - return () => { - try { - if (attachmentDraftRef.current.length > 0) { - sessionStorage.setItem(attachmentDraftKey, JSON.stringify(attachmentDraftRef.current)); - } - else sessionStorage.removeItem(attachmentDraftKey); - } catch { - /* ignore */ - } - }; + setHydratedAttachmentDraftKey(attachmentDraftKey); }, [attachmentDraftKey]); useEffect(() => { attachmentDraftRef.current = attachments; - if (!attachmentDraftKey) return; + if (!attachmentDraftKey || hydratedAttachmentDraftKey !== attachmentDraftKey) return; try { if (attachments.length > 0) sessionStorage.setItem(attachmentDraftKey, JSON.stringify(attachments)); else sessionStorage.removeItem(attachmentDraftKey); } catch { /* ignore */ } - }, [attachmentDraftKey, attachments]); + }, [attachmentDraftKey, attachments, hydratedAttachmentDraftKey]); useEffect(() => () => { if (sendWarningTimerRef.current) clearTimeout(sendWarningTimerRef.current); diff --git a/web/src/components/StartSubSessionDialog.tsx b/web/src/components/StartSubSessionDialog.tsx index 62996ac5a..82d4eef17 100644 --- a/web/src/components/StartSubSessionDialog.tsx +++ b/web/src/components/StartSubSessionDialog.tsx @@ -158,7 +158,7 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is : []; return ( -
{ if (e.target === e.currentTarget) onClose(); }}> +
New Sub-Session diff --git a/web/test/components/NewSessionDialog.test.tsx b/web/test/components/NewSessionDialog.test.tsx index 8134b0330..5379484e0 100644 --- a/web/test/components/NewSessionDialog.test.tsx +++ b/web/test/components/NewSessionDialog.test.tsx @@ -157,21 +157,20 @@ describe('NewSessionDialog', () => { })); }); - it('pressing Escape calls onClose', () => { + it('pressing Escape does not call onClose', () => { const onClose = vi.fn(); const { container } = render( false} />); const dialog = container.querySelector('[role="dialog"]')!; fireEvent.keyDown(dialog, { key: 'Escape' }); - expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); }); - it('clicking the backdrop calls onClose', () => { + it('clicking the backdrop does not call onClose', () => { const onClose = vi.fn(); const { container } = render( false} />); const backdrop = container.querySelector('[role="dialog"]')!; - // Simulate clicking the backdrop element itself (currentTarget === target) fireEvent.click(backdrop, { target: backdrop }); - expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); }); it('matches started events for non-ASCII project names deterministically', () => { diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 3dd1e2bd2..25f0ed07d 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -2554,6 +2554,99 @@ afterEach(() => { }); }); + it('restores uploaded attachment badges when switching back to the same sub-session', async () => { + uploadFileMock.mockResolvedValue({ attachment: { daemonPath: '/tmp/persisted-sub-attachment.txt' } }); + const ws = makeWs(); + const { rerender } = render( + , + ); + + const input = screen.getByRole('textbox') as HTMLDivElement; + fireEvent.paste(input, { + clipboardData: { + getData: (type: string) => type === 'text/plain' ? 'x'.repeat(1300) : '', + }, + }); + + await waitFor(() => { + expect(document.querySelector('.attachment-badge-name')?.textContent).toMatch(/^pasted-text-.*\.txt$/); + }); + const badgeName = document.querySelector('.attachment-badge-name')?.textContent ?? ''; + + rerender( + , + ); + + expect(document.querySelector('.attachment-badge-name')).toBeNull(); + + rerender( + , + ); + + await waitFor(() => { + expect(document.querySelector('.attachment-badge-name')?.textContent).toBe(badgeName); + }); + }); + + it('does not clear stored attachments when another control surface mounts for the same sub-session', async () => { + uploadFileMock.mockResolvedValue({ attachment: { daemonPath: '/tmp/shared-sub-attachment.txt' } }); + const ws = makeWs(); + const first = render( + , + ); + + const input = within(first.container).getByRole('textbox') as HTMLDivElement; + fireEvent.paste(input, { + clipboardData: { + getData: (type: string) => type === 'text/plain' ? 'x'.repeat(1300) : '', + }, + }); + + await waitFor(() => { + expect(first.container.querySelector('.attachment-badge-name')?.textContent).toMatch(/^pasted-text-.*\.txt$/); + }); + const badgeName = first.container.querySelector('.attachment-badge-name')?.textContent ?? ''; + + const second = render( + , + ); + + await waitFor(() => { + expect(second.container.querySelector('.attachment-badge-name')?.textContent).toBe(badgeName); + }); + expect(first.container.querySelector('.attachment-badge-name')?.textContent).toBe(badgeName); + }); + it('blocks oversized plain-text paste when upload context is unavailable', async () => { render( { expect(onStart).toHaveBeenCalledWith('codex-sdk', undefined, '/tmp', undefined, { thinking: 'high' }); }); + it('clicking the backdrop does not call onClose', () => { + const onClose = vi.fn(); + const { container } = render( + false} + getRemoteSessions={() => []} + refreshSessions={vi.fn()} + onStart={vi.fn()} + onClose={onClose} + />, + ); + + const backdrop = container.querySelector('.dialog-overlay') as HTMLElement | null; + expect(backdrop).not.toBeNull(); + fireEvent.click(backdrop!, { target: backdrop }); + expect(onClose).not.toHaveBeenCalled(); + }); + it('does not show CC preset controls for claude-code-sdk sub-sessions', () => { const onStart = vi.fn(); const ws = makeWs(); From 394b711636cf9d53608564906bbe50d3f224e331 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 22:22:55 +0800 Subject: [PATCH 02/54] Increase file preview limit to 5MB --- src/daemon/command-handler.ts | 127 +++++++++++++++++++++++++------- web/src/i18n/locales/en.json | 2 +- web/src/i18n/locales/es.json | 2 +- web/src/i18n/locales/ja.json | 2 +- web/src/i18n/locales/ko.json | 2 +- web/src/i18n/locales/ru.json | 2 +- web/src/i18n/locales/zh-CN.json | 2 +- web/src/i18n/locales/zh-TW.json | 2 +- 8 files changed, 107 insertions(+), 34 deletions(-) diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index d277280ee..5b8b401c3 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -3692,6 +3692,100 @@ async function handleFileSearch(cmd: Record, serverLink: Server } const FS_LIST_DEADLINE_MS = 10_000; +const FS_LIST_CACHE_TTL_MS = 5_000; + +interface FsLsSnapshot { + resolvedPath: string; + dirSignature: string; + entries: Array>; +} + +const fsListCache = new Map(); +const fsListInflight = new Map>(); +const fsListGenerations = new Map(); + +function getFsListCacheKey(realPath: string, includeFiles: boolean, includeMetadata: boolean): string { + return `${realPath}::${includeFiles ? 'files' : 'dirs'}::${includeMetadata ? 'meta' : 'plain'}`; +} + +async function loadFsListSnapshot(real: string, includeFiles: boolean, includeMetadata: boolean): Promise { + const dirents = await fsReaddir(real, { withFileTypes: true }); + const filtered = dirents.filter((d) => d.isDirectory() || (includeFiles && d.isFile())); + + const entries = await Promise.all(filtered.map(async (d) => { + const entry: Record = { name: d.name, path: nodePath.join(real, d.name), isDir: d.isDirectory(), hidden: d.name.startsWith('.') }; + if (includeMetadata && !d.isDirectory()) { + try { + const filePath = nodePath.join(real, d.name); + const fileStat = await fsStat(filePath); + entry.size = fileStat.size; + const ext = nodePath.extname(d.name).toLowerCase().slice(1); + entry.mime = MIME_MAP[ext] || undefined; + const handle = createProjectFileHandle(filePath, d.name, entry.mime as string | undefined, fileStat.size); + entry.downloadId = handle.id; + } catch { /* stat failed, skip metadata */ } + } + return entry; + })); + + entries.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + if (a.hidden !== b.hidden) return (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0); + return (a.name as string).localeCompare(b.name as string); + }); + + return { + resolvedPath: real, + dirSignature: await safeStatSignature(real), + entries, + }; +} + +async function getFsListSnapshot(real: string, includeFiles: boolean, includeMetadata: boolean): Promise { + const dirSignature = await safeStatSignature(real); + const cacheKey = getFsListCacheKey(real, includeFiles, includeMetadata); + const cached = fsListCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now() && cached.value.dirSignature === dirSignature) { + return cached.value; + } + + const generation = getResourceGeneration(fsListGenerations, real); + const inflightKey = `${cacheKey}::${generation}`; + const inflight = fsListInflight.get(inflightKey); + if (inflight) return await inflight; + + const promise = loadFsListSnapshot(real, includeFiles, includeMetadata) + .then(async (value) => { + const currentSignature = await safeStatSignature(real); + if (getResourceGeneration(fsListGenerations, real) === generation && currentSignature === value.dirSignature) { + fsListCache.set(cacheKey, { value, expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS }); + } + return value; + }) + .finally(() => { + fsListInflight.delete(inflightKey); + }); + fsListInflight.set(inflightKey, promise); + return await promise; +} + +function invalidateFsListCachesForPath(targetPath: string): void { + const realTarget = normalizeFsPath(targetPath); + bumpResourceGeneration(fsListGenerations, realTarget); + fsListCache.delete(getFsListCacheKey(realTarget, false, false)); + fsListCache.delete(getFsListCacheKey(realTarget, true, false)); + fsListCache.delete(getFsListCacheKey(realTarget, false, true)); + fsListCache.delete(getFsListCacheKey(realTarget, true, true)); + + const parent = nodePath.dirname(realTarget); + if (parent !== realTarget) { + bumpResourceGeneration(fsListGenerations, parent); + fsListCache.delete(getFsListCacheKey(parent, false, false)); + fsListCache.delete(getFsListCacheKey(parent, true, false)); + fsListCache.delete(getFsListCacheKey(parent, false, true)); + fsListCache.delete(getFsListCacheKey(parent, true, true)); + } +} async function handleFsList(cmd: Record, serverLink: ServerLink): Promise { const rawPath = cmd.path as string | undefined; @@ -3768,36 +3862,12 @@ async function handleFsListInner(resolved: string, rawPath: string, requestId: s return; } - const dirents = await fsReaddir(real, { withFileTypes: true }); - const filtered = dirents.filter((d) => d.isDirectory() || (includeFiles && d.isFile())); - - const entries = await Promise.all(filtered.map(async (d) => { - const entry: Record = { name: d.name, path: nodePath.join(real, d.name), isDir: d.isDirectory(), hidden: d.name.startsWith('.') }; - if (includeMetadata && !d.isDirectory()) { - try { - const filePath = nodePath.join(real, d.name); - const fileStat = await fsStat(filePath); - entry.size = fileStat.size; - const ext = nodePath.extname(d.name).toLowerCase().slice(1); - entry.mime = MIME_MAP[ext] || undefined; - // Generate a short-lived download handle - const handle = createProjectFileHandle(filePath, d.name, entry.mime as string | undefined, fileStat.size); - entry.downloadId = handle.id; - } catch { /* stat failed, skip metadata */ } - } - return entry; - })); - - entries.sort((a, b) => { - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; - if (a.hidden !== b.hidden) return (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0); - return (a.name as string).localeCompare(b.name as string); - }); + const snapshot = await getFsListSnapshot(real, includeFiles, includeMetadata); - try { serverLink.send({ type: 'fs.ls_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', entries }); } catch { /* ignore */ } + try { serverLink.send({ type: 'fs.ls_response', requestId, path: rawPath, resolvedPath: snapshot.resolvedPath, status: 'ok', entries: snapshot.entries }); } catch { /* ignore */ } } -const FS_READ_SIZE_LIMIT = 512 * 1024; // 512 KB +const FS_READ_SIZE_LIMIT = 5 * 1024 * 1024; // 5 MB interface FsReadSnapshot { path: string; @@ -4485,6 +4555,7 @@ async function handleFsMkdir(cmd: Record, serverLink: ServerLin const { mkdir } = await import('fs/promises'); await mkdir(resolved, { recursive: true }); const real = await fsRealpath(resolved); + invalidateFsListCachesForPath(real); try { serverLink.send({ type: 'fs.mkdir_response', requestId, path: rawPath, resolvedPath: real, status: 'ok' }); } catch { /* ignore */ } } catch (err) { try { serverLink.send({ type: 'fs.mkdir_response', requestId, path: rawPath, status: 'error', error: err instanceof Error ? err.message : String(err) }); } catch { /* ignore */ } @@ -4553,6 +4624,7 @@ async function handleFsWrite(cmd: Record, serverLink: ServerLin // Write the file await fsWriteFile(real, content, 'utf-8'); const newStats = await fsStat(real); + invalidateFsListCachesForPath(real); invalidateGitCachesForPath(real); try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', mtime: newStats.mtimeMs }); } catch { /* ignore */ } } catch (err) { @@ -4572,6 +4644,7 @@ async function handleFsWrite(cmd: Record, serverLink: ServerLin await fsWriteFile(resolved, content, 'utf-8'); const newStats = await fsStat(resolved); const real = await fsRealpath(resolved); + invalidateFsListCachesForPath(real); invalidateGitCachesForPath(real); try { serverLink.send({ type: 'fs.write_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', mtime: newStats.mtimeMs }); } catch { /* ignore */ } } catch (err) { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 59eda797d..2d3e74369 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -311,7 +311,7 @@ "timeout": "Request timed out", "timeout_detail": "Could not read directory. This may be caused by slow or unavailable network drives.", "preview_loading": "Loading…", - "preview_too_large": "File too large to preview (> 512 KB)", + "preview_too_large": "File too large to preview (> 5 MB)", "preview_error": "Preview unavailable", "preview_binary": "Binary file — preview unavailable", "mkdir_failed": "Failed to create folder", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index d1306bccd..ba711b3ba 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -311,7 +311,7 @@ "timeout": "Tiempo de espera agotado", "timeout_detail": "No se pudo leer el directorio. Puede ser causado por unidades de red lentas o no disponibles.", "preview_loading": "Cargando…", - "preview_too_large": "Archivo demasiado grande para previsualizar (> 512 KB)", + "preview_too_large": "Archivo demasiado grande para previsualizar (> 5 MB)", "preview_error": "Vista previa no disponible", "preview_binary": "Archivo binario — vista previa no disponible", "mkdir_failed": "No se pudo crear la carpeta", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index f99f0cc9b..ed92a2aae 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -311,7 +311,7 @@ "timeout": "リクエストがタイムアウトしました", "timeout_detail": "ディレクトリを読み取れませんでした。ネットワークドライブの遅延や利用不可が原因の可能性があります。", "preview_loading": "読み込み中…", - "preview_too_large": "ファイルが大きすぎてプレビューできません(> 512 KB)", + "preview_too_large": "ファイルが大きすぎてプレビューできません(> 5 MB)", "preview_error": "プレビュー不可", "preview_binary": "バイナリファイル — プレビュー不可", "mkdir_failed": "フォルダを作成できませんでした", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index 9da10c50c..ba78f3d5e 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -311,7 +311,7 @@ "timeout": "요청 시간 초과", "timeout_detail": "디렉토리를 읽을 수 없습니다. 느리거나 사용할 수 없는 네트워크 드라이브가 원인일 수 있습니다.", "preview_loading": "로딩 중…", - "preview_too_large": "파일이 너무 커서 미리볼 수 없습니다 (> 512 KB)", + "preview_too_large": "파일이 너무 커서 미리볼 수 없습니다 (> 5 MB)", "preview_error": "미리보기 불가", "preview_binary": "바이너리 파일 — 미리보기 불가", "mkdir_failed": "폴더를 만들 수 없습니다", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 4bf99a7a5..10341ffb5 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -311,7 +311,7 @@ "timeout": "Превышено время ожидания", "timeout_detail": "Не удалось прочитать каталог. Возможно, это вызвано медленными или недоступными сетевыми дисками.", "preview_loading": "Загрузка…", - "preview_too_large": "Файл слишком большой для предпросмотра (> 512 КБ)", + "preview_too_large": "Файл слишком большой для предпросмотра (> 5 МБ)", "preview_error": "Предпросмотр недоступен", "preview_binary": "Двоичный файл — предпросмотр недоступен", "mkdir_failed": "Не удалось создать папку", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 844e7b27f..65261568d 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -311,7 +311,7 @@ "timeout": "请求超时", "timeout_detail": "无法读取目录。可能是由网络驱动器缓慢或不可用引起的。", "preview_loading": "加载中…", - "preview_too_large": "文件过大,无法预览(> 512 KB)", + "preview_too_large": "文件过大,无法预览(> 5 MB)", "preview_error": "预览不可用", "preview_binary": "二进制文件 — 无法预览", "mkdir_failed": "创建文件夹失败", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index fd291b0ae..3f5bd09b0 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -311,7 +311,7 @@ "timeout": "請求逾時", "timeout_detail": "無法讀取目錄。可能是由網路磁碟緩慢或不可用所導致。", "preview_loading": "載入中…", - "preview_too_large": "檔案過大,無法預覽(> 512 KB)", + "preview_too_large": "檔案過大,無法預覽(> 5 MB)", "preview_error": "預覽不可用", "preview_binary": "二進位檔案 — 無法預覽", "mkdir_failed": "建立資料夾失敗", From 670634a68290d1cc8cae49e829a042ef599e4bbd Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 22:24:45 +0800 Subject: [PATCH 03/54] Cache file browser root snapshot --- test/daemon/fs-list.test.ts | 22 +++++ web/src/components/FileBrowser.tsx | 112 +++++++++++++++++++++-- web/test/components/FileBrowser.test.tsx | 22 +++++ 3 files changed, 150 insertions(+), 6 deletions(-) diff --git a/test/daemon/fs-list.test.ts b/test/daemon/fs-list.test.ts index eaf86e42e..0140b8ed3 100644 --- a/test/daemon/fs-list.test.ts +++ b/test/daemon/fs-list.test.ts @@ -19,9 +19,11 @@ const mockServerLink = { vi.mock('node:fs/promises', () => ({ readdir: vi.fn(), realpath: vi.fn(), + stat: vi.fn(), })); const mockReaddir = vi.mocked(fsp.readdir); const mockRealpath = vi.mocked(fsp.realpath); +const mockStat = vi.mocked(fsp.stat); // ── Pull the handler function out of command-handler indirectly ──────────── // We test via handleWebCommand to keep the test at the public API level. @@ -45,6 +47,7 @@ describe('fs.ls handler', () => { sent.length = 0; // Restore send implementation after clearAllMocks resets it mockServerLink.send.mockImplementation((msg: unknown) => { sent.push(msg); }); + mockStat.mockResolvedValue({ mtimeMs: 1, size: 0 } as any); }); afterEach(() => { @@ -171,6 +174,25 @@ describe('fs.ls handler', () => { expect(resp.entries.every((e: any) => e.isDir)).toBe(true); }); + it('reuses a hot directory listing cache for repeated requests', async () => { + const testDir = path.join(homedir(), 'cached-dir'); + mockRealpath.mockResolvedValue(testDir as unknown as string); + mockReaddir.mockResolvedValue([ + makeDirent('src', true), + makeDirent('README.md', false), + ] as unknown as fsp.Dirent[]); + + handleWebCommand({ type: 'fs.ls', path: testDir, requestId: 'req-cache-1', includeFiles: true }, mockServerLink as any); + await flushAsync(); + handleWebCommand({ type: 'fs.ls', path: testDir, requestId: 'req-cache-2', includeFiles: true }, mockServerLink as any); + await flushAsync(); + + expect(mockReaddir).toHaveBeenCalledTimes(1); + expect((sent[0] as any).status).toBe('ok'); + expect((sent[1] as any).status).toBe('ok'); + expect((sent[1] as any).entries.map((e: any) => e.name)).toEqual(['src', 'README.md']); + }); + it('includes files when includeFiles is true', async () => { const testDir = path.join(homedir(), 'test-dir'); mockRealpath.mockResolvedValue(testDir as unknown as string); diff --git a/web/src/components/FileBrowser.tsx b/web/src/components/FileBrowser.tsx index 41ae9432c..e153489dd 100644 --- a/web/src/components/FileBrowser.tsx +++ b/web/src/components/FileBrowser.tsx @@ -145,6 +145,90 @@ type FsNode = { isLoading?: boolean; }; +interface FileBrowserSnapshot { + savedAt: number; + currentLabel: string; + rootChildren: FsNode[]; +} + +const FILE_BROWSER_SNAPSHOT_TTL_MS = 5 * 60_000; +const FILE_BROWSER_SNAPSHOT_MAX_NODES = 400; +const FILE_BROWSER_SNAPSHOT_KEY_PREFIX = 'rcc_fb_snapshot_v1'; + +function buildFileBrowserSnapshotKey( + startPath: string, + includeFiles: boolean, + showHidden: boolean, + serverId?: string, +): string { + return [ + FILE_BROWSER_SNAPSHOT_KEY_PREFIX, + serverId || 'local', + includeFiles ? 'files' : 'dirs', + showHidden ? 'hidden' : 'visible', + startPath, + ].join(':'); +} + +function countFsNodes(nodes: readonly FsNode[]): number { + let count = 0; + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + count += 1; + if (node.children && node.children.length > 0) stack.push(...node.children); + } + return count; +} + +function loadFileBrowserSnapshot( + startPath: string, + includeFiles: boolean, + showHidden: boolean, + serverId?: string, +): FileBrowserSnapshot | null { + try { + const raw = localStorage.getItem(buildFileBrowserSnapshotKey(startPath, includeFiles, showHidden, serverId)); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== 'object') return null; + if (typeof parsed.savedAt !== 'number' || Date.now() - parsed.savedAt > FILE_BROWSER_SNAPSHOT_TTL_MS) return null; + if (typeof parsed.currentLabel !== 'string' || !Array.isArray(parsed.rootChildren)) return null; + return { + savedAt: parsed.savedAt, + currentLabel: parsed.currentLabel, + rootChildren: parsed.rootChildren as FsNode[], + }; + } catch { + return null; + } +} + +function saveFileBrowserSnapshot( + startPath: string, + includeFiles: boolean, + showHidden: boolean, + currentLabel: string, + rootChildren: FsNode[], + serverId?: string, +): void { + try { + if (countFsNodes(rootChildren) > FILE_BROWSER_SNAPSHOT_MAX_NODES) return; + const snapshot: FileBrowserSnapshot = { + savedAt: Date.now(), + currentLabel, + rootChildren, + }; + localStorage.setItem( + buildFileBrowserSnapshotKey(startPath, includeFiles, showHidden, serverId), + JSON.stringify(snapshot), + ); + } catch { + /* ignore */ + } +} + export type FileBrowserPreviewState = | { status: 'idle' } | { status: 'loading'; path: string } @@ -242,13 +326,19 @@ export function FileBrowser({ const isMulti = mode === 'file-multi'; const startPath = initialPath || '~'; + const initialTreeSnapshot = loadFileBrowserSnapshot(startPath, includeFiles, false, serverId); const [data, setData] = useState([ - { id: startPath, name: startPath, isDir: true, children: [] }, + { + id: startPath, + name: startPath, + isDir: true, + children: initialTreeSnapshot?.rootChildren ?? [], + }, ]); const [selectedPaths, setSelectedPaths] = useState>( () => new Set(highlightPath ? [highlightPath] : []), ); - const [currentLabel, setCurrentLabel] = useState(startPath); + const [currentLabel, setCurrentLabel] = useState(initialTreeSnapshot?.currentLabel ?? startPath); const [error, setError] = useState(null); const [showHidden, setShowHidden] = useState(false); const [preview, setPreview] = useState(() => initialPreview ?? { status: 'idle' }); @@ -337,6 +427,12 @@ export function FileBrowser({ }; }, []); + useEffect(() => { + const root = data[0]; + if (!root || root.isLoading || !root.children) return; + saveFileBrowserSnapshot(startPath, includeFiles, showHidden, currentLabel, root.children, serverId); + }, [currentLabel, data, includeFiles, serverId, showHidden, startPath]); + const getActivePreviewCycle = useCallback((path?: string): PendingPreviewRequest | null => { const active = activePreviewCycleRef.current; if (!active) return null; @@ -691,11 +787,13 @@ export function FileBrowser({ activePreviewCycleRef.current = null; for (const timer of timersRef.current.values()) clearTimeout(timer); timersRef.current.clear(); - setData([{ id: startPath, name: startPath, isDir: true, children: [] }]); + const cached = loadFileBrowserSnapshot(startPath, includeFiles, showHidden, serverId); + setData([{ id: startPath, name: startPath, isDir: true, children: cached?.rootChildren ?? [] }]); + setCurrentLabel(cached?.currentLabel ?? startPath); setError(null); } fetchDir(startPath); - }, [startPath, fetchDir]); + }, [fetchDir, includeFiles, serverId, showHidden, startPath]); useEffect(() => { if (!changesRootPath) return; @@ -840,9 +938,11 @@ export function FileBrowser({ // Reload tree when showHidden changes useEffect(() => { loadedRef.current.clear(); - setData([{ id: startPath, name: startPath, isDir: true, children: [] }]); + const cached = loadFileBrowserSnapshot(startPath, includeFiles, showHidden, serverId); + setData([{ id: startPath, name: startPath, isDir: true, children: cached?.rootChildren ?? [] }]); + setCurrentLabel(cached?.currentLabel ?? startPath); fetchDir(startPath); - }, [showHidden]); + }, [fetchDir, includeFiles, serverId, showHidden, startPath]); const toggleExpand = useCallback((nodeId: string) => { setExpandedPaths((prev) => { diff --git a/web/test/components/FileBrowser.test.tsx b/web/test/components/FileBrowser.test.tsx index 0e3999ab5..90d54fdce 100644 --- a/web/test/components/FileBrowser.test.tsx +++ b/web/test/components/FileBrowser.test.tsx @@ -244,6 +244,28 @@ describe('FileBrowser', () => { expect(getByText('documents')).toBeDefined(); }); + it('renders cached root entries immediately before refreshing live data', () => { + localStorage.setItem( + 'rcc_fb_snapshot_v1:local:dirs:visible:/home/user', + JSON.stringify({ + savedAt: Date.now(), + currentLabel: '/home/user', + rootChildren: [ + { id: '/home/user/projects', name: 'projects', isDir: true, children: [] }, + { id: '/home/user/documents', name: 'documents', isDir: true, children: [] }, + ], + }), + ); + const { ws, fsListDir } = makeWsFactory(); + const { getByText } = render( + , + ); + + expect(getByText('projects')).toBeDefined(); + expect(getByText('documents')).toBeDefined(); + expect(fsListDir).toHaveBeenCalledWith('/home/user', false, false); + }); + it('uses entry.path from a Windows drive root listing', async () => { const { ws, respond } = makeWsFactory(); render( From 3e4d1f5b718eb7dba3f4b10a35287797bd871faa Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 22:45:15 +0800 Subject: [PATCH 04/54] fix: centralize upload size limit --- shared/transport/file-transfer.ts | 4 ++-- web/src/components/SessionControls.tsx | 5 ++++- web/test/components/SessionControls.test.tsx | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/shared/transport/file-transfer.ts b/shared/transport/file-transfer.ts index 069f14aca..929d6593c 100644 --- a/shared/transport/file-transfer.ts +++ b/shared/transport/file-transfer.ts @@ -33,8 +33,8 @@ export interface PreviewMeta { // ── Phase 1 limits ──────────────────────────────────────────────────────────── export const FILE_TRANSFER_LIMITS = { - /** Maximum single file size in bytes (100 MB). */ - MAX_FILE_SIZE: 100 * 1024 * 1024, + /** Maximum single file size in bytes (200 MB). */ + MAX_FILE_SIZE: 200 * 1024 * 1024, /** Server waits this long for daemon upload ack (ms). */ UPLOAD_TIMEOUT_MS: 300_000, /** Server waits this long for daemon download response (ms). */ diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 9782c9ebf..247217962 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -44,6 +44,7 @@ import { type SessionSupervisionSnapshot, type SupervisionMode, } from '@shared/supervision-config.js'; +import { FILE_TRANSFER_LIMITS } from '@shared/transport/file-transfer.js'; interface Props { ws: WsClient | null; @@ -116,6 +117,8 @@ interface Props { onTransportConfigSaved?: (transportConfig: Record | null) => void; } +const MAX_UPLOAD_SIZE_MB = Math.round(FILE_TRANSFER_LIMITS.MAX_FILE_SIZE / (1024 * 1024)); + type MenuAction = 'restart' | 'new' | 'stop'; type ModelChoice = 'opus[1M]' | 'sonnet' | 'haiku'; @@ -1830,7 +1833,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on if (body.includes('daemon_offline')) { setUploadError(t('upload.daemon_offline')); } else if (body.includes('file_too_large')) { - setUploadError(t('upload.file_too_large', { max: 20 })); + setUploadError(t('upload.file_too_large', { max: MAX_UPLOAD_SIZE_MB })); } else { setUploadError(t('upload.upload_failed')); } diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 25f0ed07d..0ad239394 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -59,6 +59,9 @@ vi.mock('react-i18next', () => ({ if (key === 'upload.long_text_attached') { return `Large pasted text attached as ${String(opts?.name ?? '')}`; } + if (key === 'upload.file_too_large') { + return `File too large (max ${String(opts?.max ?? '')}MB)`; + } if (key === 'upload.long_text_requires_attachment') { return 'Paste is too large for inline input here. Upload it as a file instead.'; } From 487b7df14a27c14a1ecc292a6de43f05784fb68d Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 21 Apr 2026 23:31:02 +0800 Subject: [PATCH 05/54] fix: keep file browser list requests lightweight --- web/src/components/FileBrowser.tsx | 5 ++++- web/test/components/FileBrowser.test.tsx | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/web/src/components/FileBrowser.tsx b/web/src/components/FileBrowser.tsx index e153489dd..531f8e6df 100644 --- a/web/src/components/FileBrowser.tsx +++ b/web/src/components/FileBrowser.tsx @@ -458,7 +458,10 @@ export function FileBrowser({ setData((prev) => updateNode(prev, nodePath, { isLoading: true })); let requestId: string; try { - requestId = ws.fsListDir(nodePath, includeFiles, !!serverId); + // Keep the initial directory list lightweight. The tree currently only + // renders names/dir flags, so per-file metadata (size/mime/downloadId) + // just adds avoidable stat work on first open, especially on mobile. + requestId = ws.fsListDir(nodePath, includeFiles, false); } catch { setData((prev) => updateNode(prev, nodePath, { isLoading: false })); return; diff --git a/web/test/components/FileBrowser.test.tsx b/web/test/components/FileBrowser.test.tsx index 90d54fdce..f9ddea8c2 100644 --- a/web/test/components/FileBrowser.test.tsx +++ b/web/test/components/FileBrowser.test.tsx @@ -266,6 +266,22 @@ describe('FileBrowser', () => { expect(fsListDir).toHaveBeenCalledWith('/home/user', false, false); }); + it('keeps the initial list request lightweight even when downloads are enabled', () => { + const { ws, fsListDir } = makeWsFactory(); + render( + , + ); + + expect(fsListDir).toHaveBeenCalledWith('/home/user', true, false); + }); + it('uses entry.path from a Windows drive root listing', async () => { const { ws, respond } = makeWsFactory(); render( From ff33a7ece6803ef42e32f4aebe8ebe0ea5a70b81 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 00:56:45 +0800 Subject: [PATCH 06/54] fix: show tool timestamps without copying them --- web/src/components/ChatView.tsx | 9 ++++-- web/test/chat-view-tool-format.test.tsx | 21 +++++++++++++ web/test/components/ChatView.test.tsx | 39 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 65f5a93d0..87246ddc2 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -332,7 +332,7 @@ function buildViewItems(events: TimelineEvent[]): ViewItem[] { || summarizeToolInput((next.payload.detail as any)?.input, next.payload.detail); const input = inputText ? ` ${inputText}` : ''; const status = next.payload.error ? `✗ ${String(next.payload.error)}` : '✓'; - const output = !next.payload.error && next.payload.output ? String(next.payload.output) : undefined; + const output = !next.payload.error ? formatToolPayloadValue(next.payload.output) : undefined; consolidated.push({ ...ev, type: 'tool.call', @@ -1396,7 +1396,7 @@ function ToolCallGroup({ ) )} - {last && } + {last && } {expanded && middle.length > 0 && (
{toolOutput && (
@@ -1595,6 +1599,7 @@ const ChatEvent = memo(function ChatEvent({ ) : ( done )} + {showTime && {new Date(event.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}}
{detail && (
diff --git a/web/test/chat-view-tool-format.test.tsx b/web/test/chat-view-tool-format.test.tsx index 39b3c6620..c3b2862d6 100644 --- a/web/test/chat-view-tool-format.test.tsx +++ b/web/test/chat-view-tool-format.test.tsx @@ -151,6 +151,27 @@ describe('ChatView tool payload formatting', () => { expect(screen.getByText('output')).toBeDefined(); }); + it('shows a single timestamp on the final merged tool row', () => { + const events = [ + makeEvent({ + eventId: 'tool-group-call', + type: 'tool.call', + ts: 1_000, + payload: { tool: 'Read', input: { file_path: 'README.md' } }, + }), + makeEvent({ + eventId: 'tool-group-result', + type: 'tool.result', + ts: 2_000, + payload: { output: { path: '/tmp/README.md' } }, + }), + ]; + + const { container } = render(); + + expect(container.querySelectorAll('.chat-tool .chat-bubble-time')).toHaveLength(1); + }); + it('renders tool-call summary from detail.input when live payload.input is missing', () => { const events = [ makeEvent({ diff --git a/web/test/components/ChatView.test.tsx b/web/test/components/ChatView.test.tsx index 45937fb72..be9ced507 100644 --- a/web/test/components/ChatView.test.tsx +++ b/web/test/components/ChatView.test.tsx @@ -886,4 +886,43 @@ describe('ChatView', () => { if (hadTouchStart) (window as Window & { ontouchstart?: unknown }).ontouchstart = originalTouchStart; } }); + + it('copies the last tool event without the trailing timestamp from the context menu', async () => { + const hadTouchStart = 'ontouchstart' in window; + const originalTouchStart = (window as Window & { ontouchstart?: unknown }).ontouchstart; + if (hadTouchStart) delete (window as Window & { ontouchstart?: unknown }).ontouchstart; + try { + const { container, getByText } = render( + , + ); + + const toolEvents = container.querySelectorAll('.chat-event.chat-tool'); + const lastToolEvent = toolEvents[toolEvents.length - 1] as HTMLElement; + fireEvent.contextMenu(lastToolEvent, { clientX: 40, clientY: 40 }); + fireEvent.click(getByText('common.copy')); + + await waitFor(() => { + expect(clipboardWriteText).toHaveBeenCalledWith('/tmp/README.md'); + }); + } finally { + if (hadTouchStart) (window as Window & { ontouchstart?: unknown }).ontouchstart = originalTouchStart; + } + }); }); From 142bbc3add8ce213c87abd6ff8c1c6ed64eda3b5 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 08:04:39 +0800 Subject: [PATCH 07/54] fix: show history refresh spinner during http sync --- web/src/components/ChatView.tsx | 12 ++++++++++-- web/src/hooks/useTimeline.ts | 16 ++++++++++++++-- web/src/styles.css | 3 ++- web/test/components/ChatView.test.tsx | 13 +++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 87246ddc2..aa718c4b6 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -525,7 +525,7 @@ function findScrollParent(start: HTMLElement): HTMLElement { return start; } -export function ChatView({ events, loading, refreshing: _refreshing, loadingOlder, hasOlderHistory = true, onLoadOlder, sessionState, sessionId, onScrollBottomFn, preview, ws, onInsertPath, workdir, serverId, onQuote, agentType: _agentType, onResendFailed }: Props) { +export function ChatView({ events, loading, refreshing = false, loadingOlder, hasOlderHistory = true, onLoadOlder, sessionState, sessionId, onScrollBottomFn, preview, ws, onInsertPath, workdir, serverId, onQuote, agentType: _agentType, onResendFailed }: Props) { const { t } = useTranslation(); const scrollRef = useRef(null); const bottomRef = useRef(null); @@ -1059,8 +1059,16 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde ⊞ )} - {/* refreshing indicator removed — gap-fill is invisible to the user */}
+ {!preview && refreshing && ( +
+
+ )} {pinnedAboveViewport && lastSentUserMessage && (
(0); const seqRef = useRef(0); const replayRequestIdRef = useRef(null); @@ -450,11 +452,15 @@ export function useTimeline( useEffect(() => { if (!sessionId) { setLoading(false); + setHttpRefreshing(false); + httpBackfillInFlightRef.current = 0; resetOlderState(); return; } setRefreshing(false); + setHttpRefreshing(false); + httpBackfillInFlightRef.current = 0; resetOlderState(); setHasOlderHistory(true); @@ -804,6 +810,8 @@ export function useTimeline( if (ev.type === 'user.message' && (ev as { payload?: { pending?: boolean } }).payload?.pending) continue; if (typeof ev.ts === 'number' && (afterTs === undefined || ev.ts > afterTs)) afterTs = ev.ts; } + httpBackfillInFlightRef.current += 1; + setHttpRefreshing(true); void fetchTimelineHistoryHttp(serverId, backfillSessionId, { afterTs, limit: MAX_MEMORY_EVENTS, @@ -820,7 +828,11 @@ export function useTimeline( if (recovered.length === 0) return; mergeEvents(recovered); idbPutEvents(recovered); - }).catch(() => { /* opportunistic — WS path is primary; don't stamp cooldown */ }); + }).catch(() => { /* opportunistic — WS path is primary; don't stamp cooldown */ }) + .finally(() => { + httpBackfillInFlightRef.current = Math.max(0, httpBackfillInFlightRef.current - 1); + if (httpBackfillInFlightRef.current === 0) setHttpRefreshing(false); + }); }, delayMs); }, [serverId, sessionId, cacheKey, mergeEvents, idbPutEvents]); @@ -1172,7 +1184,7 @@ export function useTimeline( return { events, loading, - refreshing, + refreshing: refreshing || httpRefreshing, loadingOlder, hasOlderHistory, addOptimisticUserMessage, diff --git a/web/src/styles.css b/web/src/styles.css index 50b23c2a0..8f48a51ad 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -843,7 +843,8 @@ body { .chat-mode { display: inline-block; background: #1e1338; color: #a78bfa; padding: 2px 8px; border-radius: 4px; font-size: 11px; } .chat-system { text-align: center; color: #475569; font-size: 11px; padding: 4px 0; } .chat-loading { text-align: center; color: #475569; padding: 24px; } -.chat-refreshing { text-align: center; color: #475569; font-size: 11px; padding: 4px 0; flex-shrink: 0; } +.chat-refreshing { position: absolute; top: 8px; right: 10px; z-index: 4; width: 18px; height: 18px; border-radius: 999px; background: rgba(15, 23, 42, 0.88); border: 1px solid #334155; display: flex; align-items: center; justify-content: center; pointer-events: none; } +.chat-refreshing-spinner { width: 10px; height: 10px; border: 1.5px solid #475569; border-top-color: #60a5fa; border-radius: 50%; animation: chat-spinner-rotate 0.8s linear infinite; } .chat-thinking-bar { color: #94a3b8; font-size: 12px; padding: 6px 16px; background: #1e293b; border-top: 1px solid #334155; flex-shrink: 0; } .chat-thinking-dots { animation: thinking-pulse 1.2s ease-in-out infinite; letter-spacing: 2px; } .chat-thinking { padding: 2px 0; } diff --git a/web/test/components/ChatView.test.tsx b/web/test/components/ChatView.test.tsx index be9ced507..51170a87d 100644 --- a/web/test/components/ChatView.test.tsx +++ b/web/test/components/ChatView.test.tsx @@ -120,6 +120,19 @@ describe('ChatView', () => { }); }); + it('shows a small refreshing spinner while history gap-fill is in progress', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.chat-refreshing-spinner')).not.toBeNull(); + }); + it('forces the main chat view to follow streamed updates with the same timestamp', async () => { const initialEvents = [ { From 74c21d607a4e75780a668dd50ee97210b6b9a6c7 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 08:55:41 +0800 Subject: [PATCH 08/54] fix: always backfill timeline history on session open --- web/src/hooks/useTimeline.ts | 120 ++++----------- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/es.json | 1 + web/src/i18n/locales/ja.json | 1 + web/src/i18n/locales/ko.json | 1 + web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/zh-CN.json | 1 + web/src/i18n/locales/zh-TW.json | 1 + web/test/use-timeline-http-backfill.test.ts | 158 ++------------------ 9 files changed, 44 insertions(+), 241 deletions(-) diff --git a/web/src/hooks/useTimeline.ts b/web/src/hooks/useTimeline.ts index 9d4912352..2d4f40e39 100644 --- a/web/src/hooks/useTimeline.ts +++ b/web/src/hooks/useTimeline.ts @@ -45,31 +45,9 @@ const eventsCache = new Map(); const eventsCacheAccess = new Map(); const cacheListeners = new Map void>>(); -// Cross-hook-instance, cross-mount memo of the last time the HTTP backfill -// for a given `cacheKey` (server+session scope) completed. Consulted by the -// mount-time backfill path so that rapidly switching between the same two -// windows (open A → open B → open A again) doesn't re-hit the daemon store -// for every visit. Only updated on a SUCCESSFUL fetch — null/error responses -// leave the timestamp unchanged so the next mount retries promptly. The WS -// reconnect path deliberately bypasses this cooldown because a reconnect -// indicates a real connection gap where missed events are probable. -const lastHttpBackfillOkAt = new Map(); -const MOUNT_BACKFILL_COOLDOWN_MS = 60_000; - -/** - * Wipe all mount-backfill cooldown stamps. Called when the app has been - * backgrounded long enough that missed events become likely (mobile app - * resumed from background, laptop lid opened, browser tab restored after - * a long hide). Callers supply their own gate — the function itself is - * unconditional. - */ -function resetBackfillCooldowns(): void { - lastHttpBackfillOkAt.clear(); -} - /** * Custom DOM event fired when an ALREADY-MOUNTED timeline hook should force - * an immediate HTTP backfill, bypassing its mount-time cooldown. Triggers: + * an immediate HTTP backfill. Triggers: * * 1. Visibility returning from hidden (any duration). Typical case: user * opens the app from a push notification and lands on a session that @@ -88,12 +66,9 @@ function resetBackfillCooldowns(): void { export const ACTIVE_TIMELINE_REFRESH_EVENT = 'deck:active-timeline-refresh'; // On every visibility transition we record when the document went hidden; -// on the return-to-visible side we always emit a refresh request, and for -// long-hide gaps we ALSO wipe cooldowns so the next mount of any other -// session re-hits HTTP. Previously only the >=60s path did anything, which -// meant short-hide wake-ups (push-notification tap, lock-screen glance, -// alt-tab during typing) never surfaced newer messages until the user -// navigated away and back. +// on the return-to-visible side we always emit a refresh request so the +// mounted timeline for the active session can immediately pull any missed +// daemon-side events. // // Guard against non-browser environments (vitest node / SSR): // `document`/`window` may be undefined at import time. @@ -105,13 +80,7 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') { return; } // visible: notify the mounted timeline hook for the active session. - // Cooldown reset is restricted to long hides because it affects ALL - // cached sessions, not just the visible one. const wasHidden = hiddenAt !== null; - const hiddenMs = wasHidden ? Date.now() - (hiddenAt ?? 0) : 0; - if (wasHidden && hiddenMs >= MOUNT_BACKFILL_COOLDOWN_MS) { - resetBackfillCooldowns(); - } if (wasHidden) { try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* older browsers */ } } @@ -123,7 +92,6 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') { // stale relative to whatever landed in the meantime. window.addEventListener('pageshow', (ev) => { if ((ev as PageTransitionEvent).persisted) { - resetBackfillCooldowns(); try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* ignore */ } } }); @@ -339,7 +307,6 @@ export function __resetTimelineCacheForTests(): void { eventsCache.clear(); eventsCacheAccess.clear(); cacheListeners.clear(); - lastHttpBackfillOkAt.clear(); } export function __clearPersistedTimelineSnapshotsForTests(): void { @@ -355,15 +322,6 @@ export function __clearPersistedTimelineSnapshotsForTests(): void { } } -/** - * Test-only entry point for the same wipe the app does on long-hide / - * pageshow restore. Exposed so tests can verify the cooldown actually - * gets cleared without having to mock `document.visibilityState`. - */ -export function __resetBackfillCooldownsForTests(): void { - resetBackfillCooldowns(); -} - export function __getTimelineCacheKeysForTests(): string[] { return [...eventsCache.keys()]; } @@ -479,7 +437,7 @@ export function useTimeline( // was minimized/backgrounded since the memory cache can be stale. // Kept short (~200ms) because the UI is already visible; this is // strictly additive catch-up, merged by eventId. - fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); + fireHttpBackfillRef.current(200); return () => { cancelled = true; }; } @@ -495,7 +453,7 @@ export function useTimeline( setRefreshing(true); historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, MAX_MEMORY_EVENTS); } - fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); + fireHttpBackfillRef.current(200); } // 2. Already loaded this session — skip reload (prevents flash-of-empty on minimize/restore) @@ -509,7 +467,7 @@ export function useTimeline( // Same reasoning as path 1 — back-fill in the background so the // re-opened window is guaranteed to reflect authoritative daemon // state, not whatever the WS subscription happened to catch. - fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); + fireHttpBackfillRef.current(200); return () => { cancelled = true; }; } @@ -540,7 +498,7 @@ export function useTimeline( // Background HTTP backfill — IDB is authoritative only up to the // last time a WS event landed; if the user closed the tab mid-chat // and reopened later there may be a gap between IDB and daemon. - fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); + fireHttpBackfillRef.current(200); } else { epochRef.current = 0; seqRef.current = 0; @@ -551,18 +509,10 @@ export function useTimeline( } else { setLoading(false); } - // Cold load — no IDB cache, no memory cache. Skip the - // MOUNT_BACKFILL_COOLDOWN_MS gate: with zero cached events the UI - // is showing "No events yet", so a cooldown from a prior session's - // mount on this page (unrelated cacheKey can't trigger it, but a - // prior cold-mount of *this* session in the same page session can) - // would leave the user staring at an empty timeline until the next - // WS event. That's exactly the symptom users report after opening - // a chat via push notification — the mount effect runs inside - // React's render tick but ACTIVE_TIMELINE_REFRESH_EVENT dispatched - // by the notification handler can race with listener attachment. - // Passing cooldownMs=0 here guarantees the fetch actually fires. - fireHttpBackfillRef.current(200, { cooldownMs: 0 }); + // Cold load — no IDB cache, no memory cache. Still fire the same + // delayed HTTP backfill so an empty timeline can recover missed + // daemon-side events without waiting for a later reconnect. + fireHttpBackfillRef.current(200); } }; load().catch(() => {}); @@ -764,41 +714,30 @@ export function useTimeline( * history before this fires. * * Call sites: - * - Session mount / switch (`cooldownMs = 60_000`): "user just opened a - * window". If the previous backfill for this same session succeeded - * less than a minute ago — e.g. user is flicking A → B → A — don't - * rehit the daemon store; the freshly cached result is authoritative - * enough. - * - WS reconnect (`cooldownMs = 0`): covers the ~10–100ms subscribe-race - * window on the bridge where live events can be silently dropped. - * Reconnects imply a real connection gap, so they deliberately bypass - * the cooldown — missing events after a disconnect is exactly what - * this read exists to recover. + * - Session mount / switch (~200ms): cached history should render + * immediately, then be reconciled against authoritative daemon state. + * We intentionally do NOT apply a revisit cooldown here — a stale + * message list is worse than one extra lightweight HTTP read. + * - WS reconnect (~600ms): covers the ~10–100ms subscribe-race + * window on the bridge where live events can be silently dropped. + * Missing events after a disconnect is exactly what this read exists + * to recover. * * Safe to call when: * - `serverId` is unknown → skipped (self-hosted deploys require it). * - The user switches session mid-flight → the cacheKey-guard in the * timeout callback discards results for the old session. - * - Backfill returns zero events → cooldown stamp still recorded (the - * fetch confirmed "no gap"). - * - Backfill returns null / rejects → cooldown stamp is NOT recorded so - * the next attempt tries again promptly. + * - Backfill returns zero events → request still counts as complete and + * the spinner clears. + * - Backfill returns null / rejects → WS path remains primary; the next + * trigger simply tries again. */ - const fireHttpBackfill = useCallback((delayMs: number, opts?: { cooldownMs?: number }) => { + const fireHttpBackfill = useCallback((delayMs: number) => { if (!serverId || !sessionId) return; - const cooldownMs = opts?.cooldownMs ?? 0; const backfillSessionId = sessionId; const backfillCacheKey = cacheKey; setTimeout(() => { if (cacheKeyRef.current !== backfillCacheKey) return; - // Cooldown is enforced AT FIRE TIME (after the delay) rather than at - // call time so two back-to-back switches landing inside the delay - // window still observe the correct gap relative to the previous - // confirmed fetch. - if (backfillCacheKey && cooldownMs > 0) { - const lastOk = lastHttpBackfillOkAt.get(backfillCacheKey); - if (lastOk !== undefined && Date.now() - lastOk < cooldownMs) return; - } // Recompute the cursor at fire time, not call time — the UI may have // received fresh WS events during the delay window and we don't want // to redownload them. @@ -816,10 +755,7 @@ export function useTimeline( afterTs, limit: MAX_MEMORY_EVENTS, }).then((result) => { - if (!result) return; // null = transient failure, don't stamp cooldown - // Any non-null response (including zero-events "no gap") counts as - // confirmed-up-to-now and arms the cooldown. - if (backfillCacheKey) lastHttpBackfillOkAt.set(backfillCacheKey, Date.now()); + if (!result) return; if (result.events.length === 0) return; if (cacheKeyRef.current !== backfillCacheKey) return; const recovered = result.events.filter( @@ -828,7 +764,7 @@ export function useTimeline( if (recovered.length === 0) return; mergeEvents(recovered); idbPutEvents(recovered); - }).catch(() => { /* opportunistic — WS path is primary; don't stamp cooldown */ }) + }).catch(() => { /* opportunistic — WS path is primary */ }) .finally(() => { httpBackfillInFlightRef.current = Math.max(0, httpBackfillInFlightRef.current - 1); if (httpBackfillInFlightRef.current === 0) setHttpRefreshing(false); @@ -855,7 +791,7 @@ export function useTimeline( // each call, and `fireHttpBackfill` itself no-ops when either is unset. useEffect(() => { const handler = (): void => { - fireHttpBackfillRef.current(0, { cooldownMs: 0 }); + fireHttpBackfillRef.current(0); }; window.addEventListener(ACTIVE_TIMELINE_REFRESH_EVENT, handler); return () => window.removeEventListener(ACTIVE_TIMELINE_REFRESH_EVENT, handler); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2d3e74369..8cd75a8dc 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -34,6 +34,7 @@ "session_state": "Session {{state}}", "load_older": "Load older messages", "loading_older": "Loading...", + "refreshing_history": "Updating history…", "thinking_running": "Thinking · {{sec}}s", "thinking_done": "Thought for {{sec}}s", "show_file_panel": "Open file panel", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index ba711b3ba..547f484d8 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -34,6 +34,7 @@ "session_state": "Sesión {{state}}", "load_older": "Cargar mensajes anteriores", "loading_older": "Cargando...", + "refreshing_history": "Actualizando historial…", "thinking_running": "Pensando · {{sec}}s", "thinking_done": "Pensó {{sec}}s", "show_file_panel": "Abrir panel de archivos", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index ed92a2aae..90c5157ab 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -34,6 +34,7 @@ "session_state": "セッション {{state}}", "load_older": "古いメッセージを読み込む", "loading_older": "読み込み中...", + "refreshing_history": "履歴を更新中…", "thinking_running": "考え中 · {{sec}}s", "thinking_done": "{{sec}}s 考えた", "show_file_panel": "ファイルパネルを開く", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index ba78f3d5e..636078153 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -34,6 +34,7 @@ "session_state": "세션 {{state}}", "load_older": "이전 메시지 불러오기", "loading_older": "로딩 중...", + "refreshing_history": "기록을 업데이트하는 중…", "thinking_running": "생각 중 · {{sec}}s", "thinking_done": "{{sec}}s 동안 생각함", "show_file_panel": "파일 패널 열기", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 10341ffb5..0154e2113 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -34,6 +34,7 @@ "session_state": "Сессия {{state}}", "load_older": "Загрузить старые сообщения", "loading_older": "Загрузка...", + "refreshing_history": "Обновление истории…", "thinking_running": "Думаю · {{sec}}s", "thinking_done": "Думал {{sec}}s", "show_file_panel": "Открыть панель файлов", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 65261568d..6b933ddd0 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -34,6 +34,7 @@ "session_state": "会话 {{state}}", "load_older": "加载更早的消息", "loading_older": "加载中...", + "refreshing_history": "更新历史消息中…", "thinking_running": "思考中 · {{sec}}s", "thinking_done": "思考了 {{sec}}s", "show_file_panel": "打开文件面板", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 3f5bd09b0..fed281435 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -34,6 +34,7 @@ "session_state": "Session {{state}}", "load_older": "載入更早的訊息", "loading_older": "載入中...", + "refreshing_history": "更新歷史訊息中…", "thinking_running": "思考中 · {{sec}}s", "thinking_done": "思考了 {{sec}}s", "show_file_panel": "開啟檔案面板", diff --git a/web/test/use-timeline-http-backfill.test.ts b/web/test/use-timeline-http-backfill.test.ts index 26fa79f8a..7e26e850c 100644 --- a/web/test/use-timeline-http-backfill.test.ts +++ b/web/test/use-timeline-http-backfill.test.ts @@ -18,7 +18,6 @@ import { render, screen, cleanup, act, waitFor } from '@testing-library/preact'; import { h } from 'preact'; import type { ServerMessage, TimelineEvent, WsClient } from '../src/ws-client.js'; import { - __resetBackfillCooldownsForTests, __resetTimelineCacheForTests, ingestTimelineEventForCache, useTimeline, @@ -307,14 +306,13 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { }); }); - it('skips the mount-time backfill when the same session was successfully backfilled in the last 60 seconds', async () => { - // User flow this guards: flicking A → B → A inside a minute. - // The first A mount fires and records success; the second A mount - // sees the freshly-stamped cache entry and should NOT hit the HTTP - // path again. Saves a round-trip per window switch when navigating - // a lot between a small set of sessions. - const sessionName = `deck_http_backfill_cooldown_${Date.now()}`; - const serverId = `srv-cd-${Date.now()}`; + it('re-fires the mount-time backfill when revisiting the same session shortly after', async () => { + // Regression: cached history rendered instantly, but a 60s revisit + // cooldown meant re-entering the same session could keep showing stale + // messages without any HTTP reconciliation. Every mount must now re-fire + // the lightweight backfill. + const sessionName = `deck_http_backfill_revisit_${Date.now()}`; + const serverId = `srv-revisit-${Date.now()}`; fetchSpy.mockResolvedValue({ events: [], epoch: 1, hasMore: false, nextCursor: null }); @@ -343,7 +341,7 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { vi.useFakeTimers({ shouldAdvanceTime: true }); - // --- First mount: fires backfill and stamps the cooldown --- + // --- First mount: fires backfill --- const first = render(h(Probe)); await waitFor(() => { expect(screen.getByTestId('probe').textContent).toBe('mounted'); @@ -353,152 +351,14 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { first.unmount(); fetchSpy.mockClear(); - // --- Second mount, ~10 seconds later: well inside the 60s window --- + // --- Second mount, ~10 seconds later: still must refire --- await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); const second = render(h(Probe)); await waitFor(() => { expect(screen.getByTestId('probe').textContent).toBe('mounted'); }); await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - expect(fetchSpy).not.toHaveBeenCalled(); // cooldown skipped the network hit - second.unmount(); - - // --- Third mount, past the 60s threshold: backfill fires again --- - await act(async () => { await vi.advanceTimersByTimeAsync(61_000); }); - const third = render(h(Probe)); - await waitFor(() => { - expect(screen.getByTestId('probe').textContent).toBe('mounted'); - }); - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - third.unmount(); - }); - - it('app-reopen wipe (long-hide visibilitychange / pageshow restore) clears the cooldown so the next mount fires fresh', async () => { - // The same module-level wipe that the visibility listener performs - // when the document was hidden longer than the cooldown window. Any - // session whose cooldown was armed before the wipe must re-fire on - // its next mount so the reopened app catches up on missed events. - const sessionName = `deck_http_backfill_reopen_${Date.now()}`; - const serverId = `srv-reopen-${Date.now()}`; - - fetchSpy.mockResolvedValue({ events: [], epoch: 1, hasMore: false, nextCursor: null }); - - ingestTimelineEventForCache({ - eventId: `${sessionName}-seed`, - sessionId: sessionName, - ts: 1000, - epoch: 1, - seq: 1, - source: 'daemon', - confidence: 'high', - type: 'assistant.text', - payload: { text: 'seed' }, - }, serverId); - - const ws: WsClient = { - connected: true, - onMessage: () => () => {}, - sendTimelineHistoryRequest: vi.fn(() => 'history-reopen'), - } as unknown as WsClient; - - function Probe() { - useTimeline(sessionName, ws, serverId); - return h('div', { 'data-testid': 'probe' }, 'mounted'); - } - - vi.useFakeTimers({ shouldAdvanceTime: true }); - - // First mount: arms cooldown. - const first = render(h(Probe)); - await waitFor(() => { - expect(screen.getByTestId('probe').textContent).toBe('mounted'); - }); - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); expect(fetchSpy).toHaveBeenCalledTimes(1); - first.unmount(); - fetchSpy.mockClear(); - - // Inside cooldown (5s later): mount skips backfill. - await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); - const second = render(h(Probe)); - await waitFor(() => { - expect(screen.getByTestId('probe').textContent).toBe('mounted'); - }); - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - expect(fetchSpy).not.toHaveBeenCalled(); second.unmount(); - - // App was hidden long enough → wipe fires (simulated directly). - __resetBackfillCooldownsForTests(); - - // Mount again — cooldown cleared, backfill MUST fire even though - // we're still well inside the 60s window from the original arm. - const third = render(h(Probe)); - await waitFor(() => { - expect(screen.getByTestId('probe').textContent).toBe('mounted'); - }); - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - third.unmount(); - }); - - it('reconnect-path backfill bypasses the mount cooldown (gap recovery trumps rate limit)', async () => { - // Reconnects imply a real connection gap where live events may have - // been dropped. Suppressing the reconnect backfill to save a request - // would defeat its purpose — confirm it still fires even when a mount - // backfill just succeeded moments ago. - const sessionName = `deck_http_backfill_reconnect_bypass_${Date.now()}`; - const serverId = `srv-rb-${Date.now()}`; - - fetchSpy.mockResolvedValue({ events: [], epoch: 1, hasMore: false, nextCursor: null }); - - ingestTimelineEventForCache({ - eventId: `${sessionName}-seed`, - sessionId: sessionName, - ts: 1000, - epoch: 1, - seq: 1, - source: 'daemon', - confidence: 'high', - type: 'assistant.text', - payload: { text: 'seed' }, - }, serverId); - - let handler: ((msg: ServerMessage) => void) | null = null; - const ws: WsClient = { - connected: true, - onMessage: (next: (msg: ServerMessage) => void) => { - handler = next; - return () => { handler = null; }; - }, - sendTimelineHistoryRequest: vi.fn(() => 'history-rb'), - } as unknown as WsClient; - - function Probe() { - useTimeline(sessionName, ws, serverId); - return h('div', { 'data-testid': 'probe' }, 'mounted'); - } - - vi.useFakeTimers({ shouldAdvanceTime: true }); - render(h(Probe)); - await waitFor(() => { - expect(screen.getByTestId('probe').textContent).toBe('mounted'); - }); - - // Drain mount backfill (arms cooldown) then clear the spy. - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - fetchSpy.mockClear(); - - // Reconnect 5 seconds later — well inside the 60s mount cooldown. - await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); - await act(async () => { - handler?.({ type: 'session.event', event: 'connected', session: '', state: 'connected' } as ServerMessage); - }); - await act(async () => { await vi.advanceTimersByTimeAsync(650); }); - - // Reconnect bypasses the cooldown and fires anyway. - expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); From fc66daf9c24464a8d9432e64a60e153fe6bf8372 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 09:24:58 +0800 Subject: [PATCH 09/54] fix: throttle timeline backfill until app resume --- web/src/hooks/useTimeline.ts | 51 +++++++++++------ web/test/use-timeline-http-backfill.test.ts | 62 ++++++++++++++++++--- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/web/src/hooks/useTimeline.ts b/web/src/hooks/useTimeline.ts index 2d4f40e39..ff312d167 100644 --- a/web/src/hooks/useTimeline.ts +++ b/web/src/hooks/useTimeline.ts @@ -44,6 +44,12 @@ sharedDb.open().catch(() => {}); const eventsCache = new Map(); const eventsCacheAccess = new Map(); const cacheListeners = new Map void>>(); +const lastHttpBackfillOkAt = new Map(); +const MOUNT_BACKFILL_COOLDOWN_MS = 60_000; + +function resetBackfillCooldowns(): void { + lastHttpBackfillOkAt.clear(); +} /** * Custom DOM event fired when an ALREADY-MOUNTED timeline hook should force @@ -66,9 +72,9 @@ const cacheListeners = new Map void>>() export const ACTIVE_TIMELINE_REFRESH_EVENT = 'deck:active-timeline-refresh'; // On every visibility transition we record when the document went hidden; -// on the return-to-visible side we always emit a refresh request so the -// mounted timeline for the active session can immediately pull any missed -// daemon-side events. +// on the return-to-visible side we clear the mount cooldown and emit a +// refresh request so the mounted timeline for the active session can +// immediately pull any missed daemon-side events. // // Guard against non-browser environments (vitest node / SSR): // `document`/`window` may be undefined at import time. @@ -82,6 +88,7 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') { // visible: notify the mounted timeline hook for the active session. const wasHidden = hiddenAt !== null; if (wasHidden) { + resetBackfillCooldowns(); try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* older browsers */ } } hiddenAt = null; @@ -92,6 +99,7 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') { // stale relative to whatever landed in the meantime. window.addEventListener('pageshow', (ev) => { if ((ev as PageTransitionEvent).persisted) { + resetBackfillCooldowns(); try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* ignore */ } } }); @@ -307,6 +315,11 @@ export function __resetTimelineCacheForTests(): void { eventsCache.clear(); eventsCacheAccess.clear(); cacheListeners.clear(); + lastHttpBackfillOkAt.clear(); +} + +export function __resetBackfillCooldownsForTests(): void { + resetBackfillCooldowns(); } export function __clearPersistedTimelineSnapshotsForTests(): void { @@ -437,7 +450,7 @@ export function useTimeline( // was minimized/backgrounded since the memory cache can be stale. // Kept short (~200ms) because the UI is already visible; this is // strictly additive catch-up, merged by eventId. - fireHttpBackfillRef.current(200); + fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); return () => { cancelled = true; }; } @@ -453,7 +466,7 @@ export function useTimeline( setRefreshing(true); historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, MAX_MEMORY_EVENTS); } - fireHttpBackfillRef.current(200); + fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); } // 2. Already loaded this session — skip reload (prevents flash-of-empty on minimize/restore) @@ -467,7 +480,7 @@ export function useTimeline( // Same reasoning as path 1 — back-fill in the background so the // re-opened window is guaranteed to reflect authoritative daemon // state, not whatever the WS subscription happened to catch. - fireHttpBackfillRef.current(200); + fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); return () => { cancelled = true; }; } @@ -498,7 +511,7 @@ export function useTimeline( // Background HTTP backfill — IDB is authoritative only up to the // last time a WS event landed; if the user closed the tab mid-chat // and reopened later there may be a gap between IDB and daemon. - fireHttpBackfillRef.current(200); + fireHttpBackfillRef.current(200, { cooldownMs: MOUNT_BACKFILL_COOLDOWN_MS }); } else { epochRef.current = 0; seqRef.current = 0; @@ -512,7 +525,7 @@ export function useTimeline( // Cold load — no IDB cache, no memory cache. Still fire the same // delayed HTTP backfill so an empty timeline can recover missed // daemon-side events without waiting for a later reconnect. - fireHttpBackfillRef.current(200); + fireHttpBackfillRef.current(200, { cooldownMs: 0 }); } }; load().catch(() => {}); @@ -714,12 +727,12 @@ export function useTimeline( * history before this fires. * * Call sites: - * - Session mount / switch (~200ms): cached history should render - * immediately, then be reconciled against authoritative daemon state. - * We intentionally do NOT apply a revisit cooldown here — a stale - * message list is worse than one extra lightweight HTTP read. + * - Session mount / switch (~200ms): cached history renders immediately, + * then a background backfill reconciles against authoritative daemon + * state. Re-visits within 60 seconds reuse the previous successful + * result to avoid hammering HTTP while the app stays active. * - WS reconnect (~600ms): covers the ~10–100ms subscribe-race - * window on the bridge where live events can be silently dropped. + * window on the bridge where live events can be silently dropped. * Missing events after a disconnect is exactly what this read exists * to recover. * @@ -727,17 +740,22 @@ export function useTimeline( * - `serverId` is unknown → skipped (self-hosted deploys require it). * - The user switches session mid-flight → the cacheKey-guard in the * timeout callback discards results for the old session. - * - Backfill returns zero events → request still counts as complete and - * the spinner clears. + * - Backfill returns zero events → a successful no-gap result still arms + * the mount cooldown. * - Backfill returns null / rejects → WS path remains primary; the next * trigger simply tries again. */ - const fireHttpBackfill = useCallback((delayMs: number) => { + const fireHttpBackfill = useCallback((delayMs: number, opts?: { cooldownMs?: number }) => { if (!serverId || !sessionId) return; + const cooldownMs = opts?.cooldownMs ?? 0; const backfillSessionId = sessionId; const backfillCacheKey = cacheKey; setTimeout(() => { if (cacheKeyRef.current !== backfillCacheKey) return; + if (backfillCacheKey && cooldownMs > 0) { + const lastOk = lastHttpBackfillOkAt.get(backfillCacheKey); + if (lastOk !== undefined && Date.now() - lastOk < cooldownMs) return; + } // Recompute the cursor at fire time, not call time — the UI may have // received fresh WS events during the delay window and we don't want // to redownload them. @@ -756,6 +774,7 @@ export function useTimeline( limit: MAX_MEMORY_EVENTS, }).then((result) => { if (!result) return; + if (backfillCacheKey) lastHttpBackfillOkAt.set(backfillCacheKey, Date.now()); if (result.events.length === 0) return; if (cacheKeyRef.current !== backfillCacheKey) return; const recovered = result.events.filter( diff --git a/web/test/use-timeline-http-backfill.test.ts b/web/test/use-timeline-http-backfill.test.ts index 7e26e850c..e83072d4e 100644 --- a/web/test/use-timeline-http-backfill.test.ts +++ b/web/test/use-timeline-http-backfill.test.ts @@ -18,8 +18,10 @@ import { render, screen, cleanup, act, waitFor } from '@testing-library/preact'; import { h } from 'preact'; import type { ServerMessage, TimelineEvent, WsClient } from '../src/ws-client.js'; import { + __resetBackfillCooldownsForTests, __resetTimelineCacheForTests, ingestTimelineEventForCache, + ACTIVE_TIMELINE_REFRESH_EVENT, useTimeline, } from '../src/hooks/useTimeline.js'; @@ -306,11 +308,9 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { }); }); - it('re-fires the mount-time backfill when revisiting the same session shortly after', async () => { - // Regression: cached history rendered instantly, but a 60s revisit - // cooldown meant re-entering the same session could keep showing stale - // messages without any HTTP reconciliation. Every mount must now re-fire - // the lightweight backfill. + it('skips the mount-time backfill when revisiting the same session shortly after', async () => { + // Re-entering the same session while the app remains active should not + // hammer HTTP on every tap. The mount path stays cooldown-limited. const sessionName = `deck_http_backfill_revisit_${Date.now()}`; const serverId = `srv-revisit-${Date.now()}`; @@ -351,14 +351,62 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { first.unmount(); fetchSpy.mockClear(); - // --- Second mount, ~10 seconds later: still must refire --- + // --- Second mount, ~10 seconds later: should be skipped by cooldown --- await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); const second = render(h(Probe)); await waitFor(() => { expect(screen.getByTestId('probe').textContent).toBe('mounted'); }); await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); second.unmount(); }); + + it('app activation clears the mount cooldown and forces a fresh backfill for the active session', async () => { + const sessionName = `deck_http_backfill_resume_${Date.now()}`; + const serverId = `srv-resume-${Date.now()}`; + + fetchSpy.mockResolvedValue({ events: [], epoch: 1, hasMore: false, nextCursor: null }); + + ingestTimelineEventForCache({ + eventId: `${sessionName}-seed`, + sessionId: sessionName, + ts: 1000, + epoch: 1, + seq: 1, + source: 'daemon', + confidence: 'high', + type: 'assistant.text', + payload: { text: 'seed' }, + }, serverId); + + const ws: WsClient = { + connected: true, + onMessage: () => () => {}, + sendTimelineHistoryRequest: vi.fn(() => 'history-resume'), + } as unknown as WsClient; + + function Probe() { + useTimeline(sessionName, ws, serverId); + return h('div', { 'data-testid': 'probe' }, 'mounted'); + } + + vi.useFakeTimers({ shouldAdvanceTime: true }); + render(h(Probe)); + await waitFor(() => { + expect(screen.getByTestId('probe').textContent).toBe('mounted'); + }); + await act(async () => { await vi.advanceTimersByTimeAsync(250); }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockClear(); + + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + __resetBackfillCooldownsForTests(); + await act(async () => { + window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); + }); + await act(async () => { await vi.advanceTimersByTimeAsync(10); }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); }); From 7efd2f146fb3056446cd0efefb2afb25b48332f6 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 09:44:29 +0800 Subject: [PATCH 10/54] test: cover native resume timeline refresh chain --- web/src/app-resume-refresh.ts | 25 +++++++ web/src/app.tsx | 24 ++---- web/src/hooks/useTimeline.ts | 9 ++- web/test/app-resume-refresh.test.tsx | 105 +++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 web/src/app-resume-refresh.ts create mode 100644 web/test/app-resume-refresh.test.tsx diff --git a/web/src/app-resume-refresh.ts b/web/src/app-resume-refresh.ts new file mode 100644 index 000000000..dfd0c82f4 --- /dev/null +++ b/web/src/app-resume-refresh.ts @@ -0,0 +1,25 @@ +import { dispatchActiveTimelineRefresh } from './hooks/useTimeline.js'; + +export interface NativeAppStateApi { + addListener( + eventName: 'appStateChange', + listenerFunc: (state: { isActive: boolean }) => void, + ): Promise<{ remove: () => Promise | void }>; +} + +export async function installNativeAppResumeRefresh( + enabled: boolean, + reconnectNow: (force: boolean) => void, + appApi: NativeAppStateApi, +): Promise<() => void> { + if (!enabled) return () => {}; + const handle = await appApi.addListener('appStateChange', ({ isActive }) => { + if (!isActive) return; + reconnectNow(true); + dispatchActiveTimelineRefresh(); + }); + return () => { + const result = handle.remove(); + if (result && typeof (result as Promise).then === 'function') void result; + }; +} diff --git a/web/src/app.tsx b/web/src/app.tsx index b0d767b62..bddc550bd 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -86,7 +86,7 @@ import { mergeTransportPendingMessagesForRunningState, normalizeTransportPendingEntries, } from './transport-queue.js'; -import { ingestTimelineEventForCache, ACTIVE_TIMELINE_REFRESH_EVENT } from './hooks/useTimeline.js'; +import { ingestTimelineEventForCache } from './hooks/useTimeline.js'; import { getMobileKeyboardState } from './mobile-keyboard.js'; import { pickReadableSessionDisplay } from '@shared/session-display.js'; import { updateMainSessionLabel } from './session-label-api.js'; @@ -97,6 +97,7 @@ import { shouldResetSelectedServer, shouldShowInitialConnectingGate, } from './server-selection.js'; +import { installNativeAppResumeRefresh } from './app-resume-refresh.js'; const DashboardPage = lazy(() => import('./pages/DashboardPage.js').then((m) => ({ default: m.DashboardPage }))); const DiscussionsPage = lazy(() => import('./pages/DiscussionsPage.js').then((m) => ({ default: m.DiscussionsPage }))); @@ -1899,21 +1900,12 @@ export function App() { let removeAppStateListener: (() => void) | null = null; if (isNative()) { - void import('@capacitor/app').then(({ App }) => - App.addListener('appStateChange', ({ isActive }) => { - if (isActive) { - ws.reconnectNow(true); - // Native resume: WebView `visibilitychange` is unreliable on some - // iOS versions, so explicitly signal the active timeline to - // force-pull history. Safe to fire even when visibilitychange - // also fires — useTimeline's listener is idempotent (cooldownMs=0 - // but rate-limited by the 200ms setTimeout in fireHttpBackfill). - try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* ignore */ } - } - }).then((listener) => { - removeAppStateListener = () => { void listener.remove(); }; - }).catch(() => {}) - ).catch(() => {}); + void import('@capacitor/app') + .then(({ App }) => installNativeAppResumeRefresh(true, (force) => ws.reconnectNow(force), App)) + .then((cleanup) => { + removeAppStateListener = cleanup; + }) + .catch(() => {}); } return () => { diff --git a/web/src/hooks/useTimeline.ts b/web/src/hooks/useTimeline.ts index ff312d167..1c6aa459a 100644 --- a/web/src/hooks/useTimeline.ts +++ b/web/src/hooks/useTimeline.ts @@ -71,6 +71,11 @@ function resetBackfillCooldowns(): void { */ export const ACTIVE_TIMELINE_REFRESH_EVENT = 'deck:active-timeline-refresh'; +export function dispatchActiveTimelineRefresh(): void { + if (typeof window === 'undefined') return; + try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* ignore */ } +} + // On every visibility transition we record when the document went hidden; // on the return-to-visible side we clear the mount cooldown and emit a // refresh request so the mounted timeline for the active session can @@ -89,7 +94,7 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') { const wasHidden = hiddenAt !== null; if (wasHidden) { resetBackfillCooldowns(); - try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* older browsers */ } + dispatchActiveTimelineRefresh(); } hiddenAt = null; }; @@ -100,7 +105,7 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') { window.addEventListener('pageshow', (ev) => { if ((ev as PageTransitionEvent).persisted) { resetBackfillCooldowns(); - try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* ignore */ } + dispatchActiveTimelineRefresh(); } }); } diff --git a/web/test/app-resume-refresh.test.tsx b/web/test/app-resume-refresh.test.tsx new file mode 100644 index 000000000..9dc6e450a --- /dev/null +++ b/web/test/app-resume-refresh.test.tsx @@ -0,0 +1,105 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchSpy = vi.hoisted(() => vi.fn()); +vi.mock('../src/api.js', () => ({ fetchTimelineHistoryHttp: fetchSpy })); + +import { act, cleanup, render, screen, waitFor } from '@testing-library/preact'; +import { h } from 'preact'; +import type { WsClient } from '../src/ws-client.js'; +import { installNativeAppResumeRefresh } from '../src/app-resume-refresh.js'; +import { + __resetTimelineCacheForTests, + ingestTimelineEventForCache, + useTimeline, +} from '../src/hooks/useTimeline.js'; + +describe('native app resume refresh chain', () => { + beforeEach(() => { + __resetTimelineCacheForTests(); + cleanup(); + fetchSpy.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('appStateChange -> active timeline refresh -> HTTP backfill fires for the mounted session', async () => { + const sessionName = `deck_resume_chain_${Date.now()}`; + const serverId = `srv-${Date.now()}`; + const reconnectNow = vi.fn(); + const removeSpy = vi.fn(); + let appStateListener: ((state: { isActive: boolean }) => void) | null = null; + + fetchSpy.mockResolvedValue({ events: [], epoch: 1, hasMore: false, nextCursor: null }); + + ingestTimelineEventForCache({ + eventId: `${sessionName}-seed`, + sessionId: sessionName, + ts: 1000, + epoch: 1, + seq: 1, + source: 'daemon', + confidence: 'high', + type: 'assistant.text', + payload: { text: 'seed' }, + }, serverId); + + const ws: WsClient = { + connected: true, + onMessage: () => () => {}, + sendTimelineHistoryRequest: vi.fn(() => 'history-resume-chain'), + } as unknown as WsClient; + + function Probe() { + const { events } = useTimeline(sessionName, ws, serverId); + return h('div', { 'data-testid': 'probe' }, String(events.length)); + } + + vi.useFakeTimers({ shouldAdvanceTime: true }); + render(h(Probe)); + + await waitFor(() => { + expect(screen.getByTestId('probe').textContent).toBe('1'); + }); + + // Consume the mount-time backfill so the assertion below only counts + // the native resume path. + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + fetchSpy.mockClear(); + + const removeListener = await installNativeAppResumeRefresh( + true, + reconnectNow, + { + addListener: async (_eventName, listener) => { + appStateListener = listener; + return { remove: removeSpy }; + }, + }, + ); + + expect(appStateListener).not.toBeNull(); + + await act(async () => { + appStateListener?.({ isActive: true }); + await vi.advanceTimersByTimeAsync(10); + }); + + expect(reconnectNow).toHaveBeenCalledWith(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + serverId, + sessionName, + expect.objectContaining({ afterTs: 1000 }), + ); + + removeListener(); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); +}); From 1423e7c3662bec809490bb968b199fb3584ffd10 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 09:48:04 +0800 Subject: [PATCH 11/54] test: add e2e gate for active timeline refresh --- test/e2e/active-timeline-refresh.test.ts | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 test/e2e/active-timeline-refresh.test.ts diff --git a/test/e2e/active-timeline-refresh.test.ts b/test/e2e/active-timeline-refresh.test.ts new file mode 100644 index 000000000..b7c69c420 --- /dev/null +++ b/test/e2e/active-timeline-refresh.test.ts @@ -0,0 +1,34 @@ +/** + * E2E gate for the native-resume timeline refresh chain. + * + * The root e2e project does not own the web app's Preact/jsdom dependency + * graph, so the actual activation-chain test lives under `web/test/`. + * This wrapper runs that test under the web Vitest config as part of the + * existing `npm run test:e2e` workflow, so the e2e stage still fails if the + * browser-side resume -> HTTP backfill chain regresses. + */ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; + +function runWebActivationChainTest(): void { + const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + execFileSync( + npxBin, + ['vitest', 'run', 'test/app-resume-refresh.test.tsx'], + { + cwd: join(process.cwd(), 'web'), + stdio: 'inherit', + env: { + ...process.env, + CI: process.env.CI ?? '1', + }, + }, + ); +} + +describe('active timeline refresh e2e gate', () => { + it('passes the web activation-chain test under the real web test config', () => { + expect(runWebActivationChainTest).not.toThrow(); + }, 60_000); +}); From c4303642f7129e4b4254615b7da11cd903f93692 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 09:55:46 +0800 Subject: [PATCH 12/54] ci: install web deps before e2e wrapper --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba48e743e..cac0afec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,6 +242,8 @@ jobs: - name: Prime tmux server (ensures socket dir exists) run: tmux new-session -d -s init && tmux kill-session -t init - run: npm ci + - name: Install web deps (active timeline refresh e2e wrapper invokes web vitest) + run: ./scripts/ci-npm-ci.sh web - name: Run pipe-pane e2e tests run: npx vitest run test/e2e/pipe-pane-stream.test.ts - name: Run other e2e tests From ebf5c62727605d1260123099fe5a6835995805e1 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 10:04:54 +0800 Subject: [PATCH 13/54] Prefill CC preset defaults --- web/src/components/NewSessionDialog.tsx | 101 ++++++------------ web/src/components/StartSubSessionDialog.tsx | 53 +++++---- web/src/components/cc-preset-form.ts | 82 ++++++++++++++ web/test/components/NewSessionDialog.test.tsx | 31 ++++++ .../components/StartSubSessionDialog.test.tsx | 24 +++++ 5 files changed, 205 insertions(+), 86 deletions(-) create mode 100644 web/src/components/cc-preset-form.ts diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index 9b063fe3a..8c0b6c247 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -22,6 +22,13 @@ import { supportsDynamicTransportModels, } from "../hooks/useTransportModels.js"; import { QwenCodingPlanHint } from "./QwenCodingPlanHint.js"; +import { + buildCcPresetFromDraft, + createCcPresetDraftFromPreset, + createDefaultCcPresetDraft, + type CcPresetDraft, + type CcPresetEntry, +} from "./cc-preset-form.js"; const DEFAULT_SHELL_KEY = "default_shell"; // Fallback suggestions used only when the daemon probe returns an empty list @@ -74,29 +81,21 @@ export function NewSessionDialog({ const agentGroups = getSessionAgentGroups("new-session"); // CC env presets - const [ccPresets, setCcPresets] = useState< - Array<{ - name: string; - env: Record; - contextWindow?: number; - initMessage?: string; - }> - >([]); + const [ccPresets, setCcPresets] = useState([]); const [ccPreset, setCcPreset] = useState(""); const [ccInitPrompt, setCcInitPrompt] = useState(""); const [showPresetEditor, setShowPresetEditor] = useState(false); // New preset form + const defaultPresetDraft = createDefaultCcPresetDraft(); const [newPresetName, setNewPresetName] = useState(""); - const [newPresetBaseUrl, setNewPresetBaseUrl] = useState(""); + const [newPresetBaseUrl, setNewPresetBaseUrl] = useState(defaultPresetDraft.baseUrl); const [newPresetToken, setNewPresetToken] = useState(""); - const [newPresetModel, setNewPresetModel] = useState(""); - const [newPresetCtx, setNewPresetCtx] = useState("1000000"); + const [newPresetModel, setNewPresetModel] = useState(defaultPresetDraft.model); + const [newPresetCtx, setNewPresetCtx] = useState(defaultPresetDraft.contextWindow); const [newPresetCustomEnv, setNewPresetCustomEnv] = useState< Array<{ key: string; value: string }> - >([]); - const DEFAULT_INIT_MSG = - 'For web searches, use: curl -s "https://html.duckduckgo.com/html/?q=QUERY" | head -200. Replace QUERY with URL-encoded search terms.'; - const [newPresetInit, setNewPresetInit] = useState(DEFAULT_INIT_MSG); + >(defaultPresetDraft.customEnv); + const [newPresetInit, setNewPresetInit] = useState(defaultPresetDraft.initMessage); const fmtCtx = (v: string) => { const n = parseInt(v, 10); if (!n) return ""; @@ -105,6 +104,15 @@ export function NewSessionDialog({ if (n >= 1000) return `${(n / 1000).toFixed(0)}K`; return String(n); }; + const applyPresetDraft = (draft: CcPresetDraft) => { + setNewPresetName(draft.name); + setNewPresetBaseUrl(draft.baseUrl); + setNewPresetToken(draft.token); + setNewPresetModel(draft.model); + setNewPresetCtx(draft.contextWindow); + setNewPresetCustomEnv(draft.customEnv); + setNewPresetInit(draft.initMessage); + }; // OpenClaw-specific state const [ocMode, setOcMode] = useState("new"); @@ -853,23 +861,15 @@ export function NewSessionDialog({ : 1, }} onClick={() => { - const env: Record = { - ANTHROPIC_BASE_URL: newPresetBaseUrl.trim(), - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", - CLAUDE_CODE_ATTRIBUTION_HEADER: "0", - }; - if (newPresetToken.trim()) - env["ANTHROPIC_AUTH_TOKEN"] = newPresetToken.trim(); - if (newPresetModel.trim()) - env["ANTHROPIC_MODEL"] = newPresetModel.trim(); - for (const { key, value } of newPresetCustomEnv) { - if (key.trim()) env[key.trim()] = value; - } - const preset: any = { name: newPresetName.trim(), env }; - if (newPresetCtx) - preset.contextWindow = parseInt(newPresetCtx, 10); - if (newPresetInit.trim()) - preset.initMessage = newPresetInit.trim(); + const preset = buildCcPresetFromDraft({ + name: newPresetName, + baseUrl: newPresetBaseUrl, + token: newPresetToken, + model: newPresetModel, + contextWindow: newPresetCtx, + customEnv: newPresetCustomEnv, + initMessage: newPresetInit, + }); const updated = [ ...ccPresets.filter((p) => p.name !== preset.name), preset, @@ -878,13 +878,7 @@ export function NewSessionDialog({ try { ws?.send({ type: "cc.presets.save", presets: updated }); } catch {} - setNewPresetName(""); - setNewPresetBaseUrl(""); - setNewPresetToken(""); - setNewPresetModel(""); - setNewPresetCtx("1000000"); - setNewPresetInit(DEFAULT_INIT_MSG); - setNewPresetCustomEnv([]); + applyPresetDraft(createDefaultCcPresetDraft()); setCcPreset(preset.name); }} > @@ -937,34 +931,7 @@ export function NewSessionDialog({ fontSize: 11, }} onClick={() => { - setNewPresetName(p.name); - setNewPresetBaseUrl( - p.env["ANTHROPIC_BASE_URL"] ?? "", - ); - setNewPresetToken( - p.env["ANTHROPIC_AUTH_TOKEN"] ?? "", - ); - setNewPresetModel(p.env["ANTHROPIC_MODEL"] ?? ""); - setNewPresetCtx( - p.contextWindow - ? String(p.contextWindow) - : "1000000", - ); - setNewPresetInit( - p.initMessage ?? DEFAULT_INIT_MSG, - ); - const knownKeys = new Set([ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_MODEL", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", - "CLAUDE_CODE_ATTRIBUTION_HEADER", - ]); - setNewPresetCustomEnv( - Object.entries(p.env) - .filter(([k]) => !knownKeys.has(k)) - .map(([key, value]) => ({ key, value })), - ); + applyPresetDraft(createCcPresetDraftFromPreset(p)); }} > Edit diff --git a/web/src/components/StartSubSessionDialog.tsx b/web/src/components/StartSubSessionDialog.tsx index 82d4eef17..d85665083 100644 --- a/web/src/components/StartSubSessionDialog.tsx +++ b/web/src/components/StartSubSessionDialog.tsx @@ -10,6 +10,13 @@ import { getUserPref, saveUserPref } from '../api.js'; import { CLAUDE_SDK_EFFORT_LEVELS, CODEX_SDK_EFFORT_LEVELS, COPILOT_SDK_EFFORT_LEVELS, OPENCLAW_THINKING_LEVELS, QWEN_EFFORT_LEVELS, type TransportEffortLevel } from '@shared/effort-levels.js'; import { getSessionAgentGroups, getSessionAgentLabel, SESSION_AGENT_GROUP_LABEL_KEYS } from './session-agent-options.js'; import { QwenCodingPlanHint } from './QwenCodingPlanHint.js'; +import { + buildCcPresetFromDraft, + createCcPresetDraftFromPreset, + createDefaultCcPresetDraft, + type CcPresetEntry, + type CcPresetDraft, +} from './cc-preset-form.js'; const CURSOR_HEADLESS_MODEL_SUGGESTIONS = ['gpt-5.2'] as const; const COPILOT_SDK_MODEL_SUGGESTIONS = ['gpt-5.4', 'gpt-5.4-mini'] as const; @@ -47,20 +54,29 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is const [ocSelectedSession, setOcSelectedSession] = useState(''); // CC env presets - const [ccPresets, setCcPresets] = useState; contextWindow?: number; initMessage?: string }>>([]); + const [ccPresets, setCcPresets] = useState([]); const [ccPreset, setCcPreset] = useState(''); const [ccInitPrompt, setCcInitPrompt] = useState(''); const [showPresetEditor, setShowPresetEditor] = useState(false); + const defaultPresetDraft = createDefaultCcPresetDraft(); const [newPresetName, setNewPresetName] = useState(''); - const [newPresetBaseUrl, setNewPresetBaseUrl] = useState(''); + const [newPresetBaseUrl, setNewPresetBaseUrl] = useState(defaultPresetDraft.baseUrl); const [newPresetToken, setNewPresetToken] = useState(''); - const [newPresetModel, setNewPresetModel] = useState(''); - const [newPresetCtx, setNewPresetCtx] = useState('1000000'); - const [newPresetCustomEnv, setNewPresetCustomEnv] = useState>([]); - const DEFAULT_INIT_MSG = 'For web searches, use: curl -s "https://html.duckduckgo.com/html/?q=QUERY" | head -200. Replace QUERY with URL-encoded search terms.'; - const [newPresetInit, setNewPresetInit] = useState(DEFAULT_INIT_MSG); + const [newPresetModel, setNewPresetModel] = useState(defaultPresetDraft.model); + const [newPresetCtx, setNewPresetCtx] = useState(defaultPresetDraft.contextWindow); + const [newPresetCustomEnv, setNewPresetCustomEnv] = useState>(defaultPresetDraft.customEnv); + const [newPresetInit, setNewPresetInit] = useState(defaultPresetDraft.initMessage); const [presetError, setPresetError] = useState(''); const fmtCtx = (v: string) => { const n = parseInt(v, 10); if (!n) return ''; if (n >= 1000000) return `${(n/1000000).toFixed(n%1000000===0?0:1)}M`; if (n >= 1000) return `${(n/1000).toFixed(0)}K`; return String(n); }; + const applyPresetDraft = (draft: CcPresetDraft) => { + setNewPresetName(draft.name); + setNewPresetBaseUrl(draft.baseUrl); + setNewPresetToken(draft.token); + setNewPresetModel(draft.model); + setNewPresetCtx(draft.contextWindow); + setNewPresetCustomEnv(draft.customEnv); + setNewPresetInit(draft.initMessage); + }; // Remote sessions come from the provider status hook (pushed on connect, cached in DB) const ocRemoteSessions = getRemoteSessions('openclaw'); @@ -377,17 +393,19 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is
@@ -398,10 +416,7 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is {p.name} {p.env['ANTHROPIC_MODEL'] ?? ''}
diff --git a/web/src/components/cc-preset-form.ts b/web/src/components/cc-preset-form.ts new file mode 100644 index 000000000..ce58c207b --- /dev/null +++ b/web/src/components/cc-preset-form.ts @@ -0,0 +1,82 @@ +export interface CcPresetEntry { + name: string; + env: Record; + contextWindow?: number; + initMessage?: string; +} + +export interface CcPresetDraft { + name: string; + baseUrl: string; + token: string; + model: string; + contextWindow: string; + customEnv: Array<{ key: string; value: string }>; + initMessage: string; +} + +export const DEFAULT_CC_PRESET_BASE_URL = 'https://api.minimax.io/anthropic'; +export const DEFAULT_CC_PRESET_MODEL = 'MiniMax-M2.7'; +export const DEFAULT_CC_PRESET_CONTEXT_WINDOW = '1000000'; +export const DEFAULT_CC_PRESET_INIT_MSG = + 'For web searches, use: curl -s "https://html.duckduckgo.com/html/?q=QUERY" | head -200. Replace QUERY with URL-encoded search terms.'; + +const DEFAULT_CC_PRESET_CUSTOM_ENV_TEMPLATE = Object.freeze([ + { key: 'API_TIMEOUT_MS', value: '3000000' }, + { key: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' }, +]); + +const INLINE_ENV_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_ATTRIBUTION_HEADER', +]); + +function cloneCustomEnv(items: ReadonlyArray<{ key: string; value: string }>): Array<{ key: string; value: string }> { + return items.map((item) => ({ key: item.key, value: item.value })); +} + +export function createDefaultCcPresetDraft(): CcPresetDraft { + return { + name: '', + baseUrl: DEFAULT_CC_PRESET_BASE_URL, + token: '', + model: DEFAULT_CC_PRESET_MODEL, + contextWindow: DEFAULT_CC_PRESET_CONTEXT_WINDOW, + customEnv: cloneCustomEnv(DEFAULT_CC_PRESET_CUSTOM_ENV_TEMPLATE), + initMessage: DEFAULT_CC_PRESET_INIT_MSG, + }; +} + +export function createCcPresetDraftFromPreset(preset: CcPresetEntry): CcPresetDraft { + return { + name: preset.name, + baseUrl: preset.env.ANTHROPIC_BASE_URL ?? DEFAULT_CC_PRESET_BASE_URL, + token: preset.env.ANTHROPIC_AUTH_TOKEN ?? '', + model: preset.env.ANTHROPIC_MODEL ?? DEFAULT_CC_PRESET_MODEL, + contextWindow: preset.contextWindow ? String(preset.contextWindow) : DEFAULT_CC_PRESET_CONTEXT_WINDOW, + customEnv: Object.entries(preset.env) + .filter(([key]) => !INLINE_ENV_KEYS.has(key)) + .map(([key, value]) => ({ key, value })), + initMessage: preset.initMessage ?? DEFAULT_CC_PRESET_INIT_MSG, + }; +} + +export function buildCcPresetFromDraft(draft: CcPresetDraft): CcPresetEntry { + const env: Record = { + ANTHROPIC_BASE_URL: draft.baseUrl.trim(), + CLAUDE_CODE_ATTRIBUTION_HEADER: '0', + }; + if (draft.token.trim()) env.ANTHROPIC_AUTH_TOKEN = draft.token.trim(); + if (draft.model.trim()) env.ANTHROPIC_MODEL = draft.model.trim(); + for (const { key, value } of draft.customEnv) { + if (key.trim()) env[key.trim()] = value; + } + + const preset: CcPresetEntry = { name: draft.name.trim(), env }; + const contextWindow = parseInt(draft.contextWindow, 10); + if (contextWindow) preset.contextWindow = contextWindow; + if (draft.initMessage.trim()) preset.initMessage = draft.initMessage.trim(); + return preset; +} diff --git a/web/test/components/NewSessionDialog.test.tsx b/web/test/components/NewSessionDialog.test.tsx index 5379484e0..2954a9f48 100644 --- a/web/test/components/NewSessionDialog.test.tsx +++ b/web/test/components/NewSessionDialog.test.tsx @@ -25,6 +25,7 @@ import { NewSessionDialog } from '../../src/components/NewSessionDialog.js'; const makeWs = () => ({ sendSessionCommand: vi.fn(), + send: vi.fn(), connected: true, onMessage: vi.fn().mockReturnValue(() => {}), subSessionDetectShells: vi.fn(), @@ -300,6 +301,36 @@ describe('NewSessionDialog', () => { })); }); + it('saves qwen presets with the new default env template', async () => { + const ws = makeWs(); + render( false} />); + + const agentTypeSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement; + agentTypeSelect.value = 'qwen'; + fireEvent.input(agentTypeSelect, { target: { value: agentTypeSelect.value } }); + await waitFor(() => expect(screen.getByText('api_provider')).toBeDefined()); + + fireEvent.click(screen.getByText('api_provider_add_edit')); + fireEvent.input(screen.getByPlaceholderText('e.g. MiniMax'), { target: { value: 'MiniMax' } }); + fireEvent.click(screen.getByRole('button', { name: /save preset/i })); + + expect(ws.send).toHaveBeenCalledWith({ + type: 'cc.presets.save', + presets: [ + expect.objectContaining({ + name: 'MiniMax', + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + API_TIMEOUT_MS: '3000000', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_ATTRIBUTION_HEADER: '0', + }), + }), + ], + }); + }); + it('includes thinking level when starting qwen', async () => { const ws = makeWs(); render( false} />); diff --git a/web/test/components/StartSubSessionDialog.test.tsx b/web/test/components/StartSubSessionDialog.test.tsx index 54c8d7afd..a1952ea02 100644 --- a/web/test/components/StartSubSessionDialog.test.tsx +++ b/web/test/components/StartSubSessionDialog.test.tsx @@ -227,6 +227,30 @@ describe('StartSubSessionDialog', () => { }); }); + it('prefills default qwen preset values instead of leaving placeholders only', async () => { + render( + false} + getRemoteSessions={() => []} + refreshSessions={vi.fn()} + onStart={vi.fn()} + onClose={vi.fn()} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /qwen/i })); + await waitFor(() => expect(screen.getByText('api_provider')).toBeDefined()); + fireEvent.click(screen.getByRole('button', { name: /api_provider_add_edit/i })); + + expect(screen.getByDisplayValue('https://api.minimax.io/anthropic')).toBeDefined(); + expect(screen.getByDisplayValue('MiniMax-M2.7')).toBeDefined(); + expect(screen.getByDisplayValue('API_TIMEOUT_MS')).toBeDefined(); + expect(screen.getByDisplayValue('3000000')).toBeDefined(); + expect(screen.getByDisplayValue('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')).toBeDefined(); + }); + it('passes thinking level for qwen sub-sessions', () => { const onStart = vi.fn(); render( From c0992da89a1f3aa4161d5a250c84cf09b897e09e Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 10:15:03 +0800 Subject: [PATCH 14/54] fix: prefer final codex web search query --- test/agent/codex-sdk-provider.test.ts | 40 ++++++++++++++++ web/src/components/ChatView.tsx | 61 ++++++++++++++++++++++--- web/test/chat-view-tool-format.test.tsx | 39 ++++++++++++++++ 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/test/agent/codex-sdk-provider.test.ts b/test/agent/codex-sdk-provider.test.ts index df1e0f587..46d6fad68 100644 --- a/test/agent/codex-sdk-provider.test.ts +++ b/test/agent/codex-sdk-provider.test.ts @@ -555,6 +555,46 @@ describe('CodexSdkProvider', () => { expect(detail.meta?.actionType).toBe('other'); }); + it('surfaces the final WebSearch query on completion even if started emitted only a generic fallback', async () => { + const provider = new CodexSdkProvider(); + await provider.connect({ binaryPath: 'codex' }); + await provider.createSession({ sessionKey: 'route-websearch-late-query', cwd: '/tmp/project' }); + + const tools: Array<{ status: string; input: unknown; detail?: unknown }> = []; + provider.onToolCall((_, tool) => tools.push({ status: tool.status, input: tool.input, detail: tool.detail })); + + await provider.send('route-websearch-late-query', 'search'); + const child = childProcessMock.children[0]; + child.emits({ + method: 'item/started', + params: { threadId: 'thread-1', turnId: 'turn-1', item: { id: 'ws-late', type: 'webSearch', action: { type: 'other' } } }, + }); + child.emits({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + id: 'ws-late', + type: 'webSearch', + query: 'apple stock today', + action: { type: 'search', query: 'apple stock today' }, + }, + }, + }); + child.emits({ method: 'turn/completed', params: { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed', error: null } } }); + await flush(); + + expect(tools).toHaveLength(2); + expect(tools[0].status).toBe('running'); + expect(tools[0].input).toEqual({ query: '(other)' }); + expect(tools[1].status).toBe('complete'); + expect(tools[1].input).toEqual({ query: 'apple stock today' }); + const detail = tools[1].detail as { summary?: string; input?: Record }; + expect(detail.summary).toBe('apple stock today'); + expect(detail.input).toEqual({ query: 'apple stock today', action: { type: 'search', query: 'apple stock today' } }); + }); + it('applies thinking level to subsequent Codex SDK turns', async () => { const provider = new CodexSdkProvider(); await provider.connect({ binaryPath: 'codex' }); diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index aa718c4b6..f89f5c089 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -242,6 +242,48 @@ function summarizeToolInput( return formatToolPayloadValue(rawRecord.input); } +function isGenericWebSearchLabel(value: string | undefined): boolean { + if (!value) return false; + return /^\((?:other|open_page|find_in_page|search|web_search)\)$/i.test(value.trim()); +} + +function pickMergedToolInput( + toolName: string, + callInput: string, + resultInput: string, +): string { + if (toolName === 'WebSearch' && resultInput) { + if (!callInput || isGenericWebSearchLabel(callInput)) return resultInput; + } + return callInput || resultInput; +} + +function pickMergedToolDetailInput( + toolName: string, + callDetail: unknown, + resultDetail: unknown, +): unknown { + const callInput = summarizeToolInput(undefined, callDetail); + const resultInput = summarizeToolInput((resultDetail as any)?.input, resultDetail); + if (toolName === 'WebSearch' && resultInput) { + if (!callInput || isGenericWebSearchLabel(callInput)) return (resultDetail as any)?.input; + } + return (callDetail as any)?.input ?? (resultDetail as any)?.input; +} + +function pickMergedToolDetailMeta( + toolName: string, + callDetail: unknown, + resultDetail: unknown, +): unknown { + const callInput = summarizeToolInput(undefined, callDetail); + const resultInput = summarizeToolInput((resultDetail as any)?.input, resultDetail); + if (toolName === 'WebSearch' && resultInput) { + if (!callInput || isGenericWebSearchLabel(callInput)) return (resultDetail as any)?.meta ?? (callDetail as any)?.meta; + } + return (callDetail as any)?.meta ?? (resultDetail as any)?.meta; +} + function formatToolDetailJson(value: unknown): string | null { if (value == null) return null; if (typeof value === 'string') return value; @@ -328,8 +370,9 @@ function buildViewItems(events: TimelineEvent[]): ViewItem[] { const toolName = String(ev.payload.tool ?? 'tool'); // tool.call from transport SDK may have no input yet (streamed incrementally). // Fall back to the result's detail.input which has the complete args. - const inputText = summarizeToolInput(ev.payload.input, ev.payload.detail) - || summarizeToolInput((next.payload.detail as any)?.input, next.payload.detail); + const callInput = summarizeToolInput(ev.payload.input, ev.payload.detail); + const resultInput = summarizeToolInput((next.payload.detail as any)?.input, next.payload.detail); + const inputText = pickMergedToolInput(toolName, callInput, resultInput); const input = inputText ? ` ${inputText}` : ''; const status = next.payload.error ? `✗ ${String(next.payload.error)}` : '✓'; const output = !next.payload.error ? formatToolPayloadValue(next.payload.output) : undefined; @@ -1558,18 +1601,22 @@ const ChatEvent = memo(function ChatEvent({ } case 'tool.call': { + const toolName = String(event.payload.tool ?? 'tool'); const callDetail = event.payload._callDetail ?? event.payload.detail; const resultDetail = event.payload._resultDetail; const shouldShowTime = showTime || event.payload._merged === true; // Fall back to result detail for input — transport SDK tool.call may arrive without input - const toolInput = summarizeToolInput(event.payload.input, callDetail) - || summarizeToolInput((resultDetail as any)?.input, resultDetail); + const callInput = summarizeToolInput(event.payload.input, callDetail); + const resultInput = summarizeToolInput((resultDetail as any)?.input, resultDetail); + const toolInput = pickMergedToolInput(toolName, callInput, resultInput); + const detailInput = pickMergedToolDetailInput(toolName, callDetail, resultDetail); + const detailMeta = pickMergedToolDetailMeta(toolName, callDetail, resultDetail); const toolOutput = event.payload._output ? String(event.payload._output) : undefined; return (
{'>'} - {String(event.payload.tool ?? 'tool')} + {toolName} {toolInput && {' '}{splitPathsAndUrls(toolInput, onPathClick, undefined, onDownload)}} {shouldShowTime && {new Date(event.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}}
@@ -1581,9 +1628,9 @@ const ChatEvent = memo(function ChatEvent({ {(callDetail || resultDetail) && (
{t('chat.tool_detail_toggle')} - + - +
)} diff --git a/web/test/chat-view-tool-format.test.tsx b/web/test/chat-view-tool-format.test.tsx index c3b2862d6..d4e4256ad 100644 --- a/web/test/chat-view-tool-format.test.tsx +++ b/web/test/chat-view-tool-format.test.tsx @@ -151,6 +151,45 @@ describe('ChatView tool payload formatting', () => { expect(screen.getByText('output')).toBeDefined(); }); + it('prefers the completed WebSearch query over a generic started-state fallback label', () => { + const events = [ + makeEvent({ + eventId: 'transport-tool:test:websearch-late:call', + type: 'tool.call', + payload: { + tool: 'WebSearch', + input: { query: '(other)' }, + detail: { + kind: 'webSearch', + summary: '(other)', + input: { query: '(other)', action: { type: 'other' } }, + meta: { actionType: 'other' }, + }, + }, + }), + makeEvent({ + eventId: 'transport-tool:test:websearch-late:result', + type: 'tool.result', + payload: { + detail: { + kind: 'webSearch', + summary: 'apple stock today', + input: { query: 'apple stock today', action: { type: 'search', query: 'apple stock today' } }, + meta: { actionType: 'search' }, + }, + }, + }), + ]; + + render(); + + expect(screen.getByText('WebSearch')).toBeDefined(); + expect(screen.getAllByText(/apple stock today/).length).toBeGreaterThan(0); + fireEvent.click(screen.getByText('details')); + expect(screen.getByText('input')).toBeDefined(); + expect(screen.queryByText(/\(other\)/)).toBeNull(); + }); + it('shows a single timestamp on the final merged tool row', () => { const events = [ makeEvent({ From dda8d60cfed1d3dd6d4768a80ac618b2db3b945d Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 10:27:54 +0800 Subject: [PATCH 15/54] Add compatible API preset model discovery --- shared/cc-presets.ts | 32 +++ src/agent/session-manager.ts | 48 ++-- src/daemon/cc-presets.ts | 206 ++++++++++++--- src/daemon/command-handler.ts | 86 +++++- src/daemon/session-list.ts | 38 ++- test/daemon/cc-presets.test.ts | 35 ++- test/daemon/session-list.test.ts | 47 ++++ web/src/components/NewSessionDialog.tsx | 246 +++++++++++++++--- web/src/components/StartSubSessionDialog.tsx | 166 +++++++++--- web/src/components/cc-preset-form.ts | 26 +- web/src/ws-client.ts | 6 +- web/test/components/NewSessionDialog.test.tsx | 64 ++++- .../components/StartSubSessionDialog.test.tsx | 12 +- 13 files changed, 841 insertions(+), 171 deletions(-) create mode 100644 shared/cc-presets.ts diff --git a/shared/cc-presets.ts b/shared/cc-presets.ts new file mode 100644 index 000000000..43eaefe32 --- /dev/null +++ b/shared/cc-presets.ts @@ -0,0 +1,32 @@ +export const CC_PRESET_MSG = { + LIST: 'cc.presets.list', + LIST_RESPONSE: 'cc.presets.list_response', + SAVE: 'cc.presets.save', + SAVE_RESPONSE: 'cc.presets.save_response', + DISCOVER_MODELS: 'cc.presets.discover_models', + DISCOVER_MODELS_RESPONSE: 'cc.presets.discover_models_response', +} as const; + +export type CcPresetTransportMode = + | 'qwen-compatible-api' + | 'claude-cli-preset'; + +export type CcPresetAuthType = 'anthropic'; + +export interface CcPresetModelInfo { + id: string; + name?: string; +} + +export interface CcPreset { + name: string; + env: Record; + contextWindow?: number; + initMessage?: string; + transportMode?: CcPresetTransportMode; + authType?: CcPresetAuthType; + availableModels?: CcPresetModelInfo[]; + defaultModel?: string; + lastDiscoveredAt?: number; + modelDiscoveryError?: string; +} diff --git a/src/agent/session-manager.ts b/src/agent/session-manager.ts index 66125ad74..660a60b30 100644 --- a/src/agent/session-manager.ts +++ b/src/agent/session-manager.ts @@ -1227,14 +1227,9 @@ export async function restoreTransportSessions(providerId: string): Promise 0 && !availableQwenModels.includes(effectiveRequestedModel))) { + effectiveRequestedModel = presetConfig.model ?? availableQwenModels[0] ?? effectiveRequestedModel; } transportSettings = presetConfig.settings; // Override the qwen CLI's built-in "I am Qwen Code" identity with the @@ -1440,27 +1435,22 @@ export async function launchTransportSession(opts: LaunchOpts): Promise { }); const contextBootstrap = await resolveRuntimeContextBootstrap(); runtime.setContextBootstrapResolver(resolveRuntimeContextBootstrap); - if (agentType === 'qwen') { - const qwenRuntime = await getQwenRuntimeConfig().catch(() => null); - qwenAuthType = qwenRuntime?.authType; - qwenAuthLimit = qwenRuntime?.authLimit; - availableQwenModels = qwenRuntime?.availableModels ?? []; - if (effectiveCcPreset) { - const { getQwenPresetTransportConfig } = await import('../daemon/cc-presets.js'); - const presetConfig = await getQwenPresetTransportConfig(effectiveCcPreset); - transportEnv = { ...(transportEnv ?? {}), ...presetConfig.env }; - // Preset is authoritative — its model overrides any stored/requested - // model, and we restrict the available list so the fallback below can't - // revert to the OAuth placeholder (`coder-model`). We're spawning qwen - // with `--auth-type anthropic` against a BYO API key, so the OAuth tier - // labels ("Free", "No longer available") don't apply — clear them. - if (presetConfig.model) { - requestedTransportModel = presetConfig.model; - availableQwenModels = [presetConfig.model]; - } - presetContextWindow = presetConfig.contextWindow; - if (presetConfig.settings) transportSettings = presetConfig.settings; - if (presetConfig.systemPrompt) transportSystemPrompt = presetConfig.systemPrompt; + if (agentType === 'qwen') { + const qwenRuntime = await getQwenRuntimeConfig().catch(() => null); + qwenAuthType = qwenRuntime?.authType; + qwenAuthLimit = qwenRuntime?.authLimit; + availableQwenModels = qwenRuntime?.availableModels ?? []; + if (effectiveCcPreset) { + const { getQwenPresetTransportConfig } = await import('../daemon/cc-presets.js'); + const presetConfig = await getQwenPresetTransportConfig(effectiveCcPreset); + transportEnv = { ...(transportEnv ?? {}), ...presetConfig.env }; + if (presetConfig.availableModels?.length) availableQwenModels = presetConfig.availableModels; + if (!requestedTransportModel || (availableQwenModels.length > 0 && !availableQwenModels.includes(requestedTransportModel))) { + requestedTransportModel = presetConfig.model ?? availableQwenModels[0] ?? requestedTransportModel; + } + presetContextWindow = presetConfig.contextWindow; + if (presetConfig.settings) transportSettings = presetConfig.settings; + if (presetConfig.systemPrompt) transportSystemPrompt = presetConfig.systemPrompt; qwenAuthType = QWEN_AUTH_TYPES.API_KEY; qwenAuthLimit = undefined; } diff --git a/src/daemon/cc-presets.ts b/src/daemon/cc-presets.ts index c88dd856b..0e1964c03 100644 --- a/src/daemon/cc-presets.ts +++ b/src/daemon/cc-presets.ts @@ -10,19 +10,11 @@ import { promises as fs } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import type { CcPreset, CcPresetModelInfo } from '../../shared/cc-presets.js'; import logger from '../util/logger.js'; const PRESETS_PATH = join(homedir(), '.imcodes', 'cc-presets.json'); -export interface CcPreset { - name: string; - env: Record; - /** Context window size for this model (e.g. 200000, 1000000). Used for UI progress bar accuracy. */ - contextWindow?: number; - /** Message injected into the session after launch (e.g. search instructions for non-Anthropic providers). */ - initMessage?: string; -} - let cachedPresets: CcPreset[] | null = null; /** ccSessionId → contextWindow (set when preset env is resolved for a session). */ @@ -36,11 +28,66 @@ const MODEL_ALIASES = [ 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ]; +function normalizePresetModel(raw: unknown): CcPresetModelInfo | null { + if (typeof raw === 'string') { + const id = raw.trim(); + return id ? { id } : null; + } + if (!raw || typeof raw !== 'object') return null; + const record = raw as Record; + const id = typeof record.id === 'string' ? record.id.trim() : ''; + if (!id) return null; + const name = typeof record.name === 'string' ? record.name.trim() : ''; + return name ? { id, name } : { id }; +} + +function normalizePreset(raw: unknown): CcPreset | null { + if (!raw || typeof raw !== 'object') return null; + const record = raw as Record; + const name = typeof record.name === 'string' ? record.name.trim() : ''; + if (!name) return null; + const envRecord = record.env && typeof record.env === 'object' + ? Object.entries(record.env as Record).reduce>((acc, [key, value]) => { + if (typeof value === 'string') acc[key] = value; + return acc; + }, {}) + : {}; + const availableModels = Array.isArray(record.availableModels) + ? record.availableModels + .map((item) => normalizePresetModel(item)) + .filter((item): item is CcPresetModelInfo => item !== null) + : undefined; + const defaultModel = typeof record.defaultModel === 'string' + ? record.defaultModel.trim() + : ''; + return { + name, + env: envRecord, + ...(typeof record.contextWindow === 'number' ? { contextWindow: record.contextWindow } : {}), + ...(typeof record.initMessage === 'string' ? { initMessage: record.initMessage } : {}), + ...(record.transportMode === 'qwen-compatible-api' || record.transportMode === 'claude-cli-preset' + ? { transportMode: record.transportMode } + : {}), + ...(record.authType === 'anthropic' ? { authType: record.authType } : {}), + ...(availableModels?.length ? { availableModels } : {}), + ...(defaultModel ? { defaultModel } : {}), + ...(typeof record.lastDiscoveredAt === 'number' ? { lastDiscoveredAt: record.lastDiscoveredAt } : {}), + ...(typeof record.modelDiscoveryError === 'string' ? { modelDiscoveryError: record.modelDiscoveryError } : {}), + }; +} + +function normalizePresets(raw: unknown): CcPreset[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => normalizePreset(item)) + .filter((item): item is CcPreset => item !== null); +} + export async function loadPresets(): Promise { if (cachedPresets) return cachedPresets; try { const raw = await fs.readFile(PRESETS_PATH, 'utf8'); - cachedPresets = JSON.parse(raw) as CcPreset[]; + cachedPresets = normalizePresets(JSON.parse(raw)); return cachedPresets; } catch { cachedPresets = []; @@ -49,8 +96,8 @@ export async function loadPresets(): Promise { } export async function savePresets(presets: CcPreset[]): Promise { - cachedPresets = presets; - await fs.writeFile(PRESETS_PATH, JSON.stringify(presets, null, 2), 'utf8'); + cachedPresets = normalizePresets(presets); + await fs.writeFile(PRESETS_PATH, JSON.stringify(cachedPresets, null, 2), 'utf8'); } function normalizePresetName(name: string): string { @@ -63,6 +110,20 @@ export async function getPreset(name: string): Promise { return presets.find((p) => normalizePresetName(p.name) === normalized); } +export function getPresetEffectiveModel(preset: Pick): string | undefined { + const model = preset.defaultModel?.trim() || preset.env['ANTHROPIC_MODEL']?.trim() || ''; + return model || undefined; +} + +export function getPresetAvailableModelIds(preset: Pick): string[] { + const discovered = preset.availableModels + ?.map((item) => item.id.trim()) + .filter(Boolean) ?? []; + if (discovered.length > 0) return [...new Set(discovered)]; + const fallback = getPresetEffectiveModel(preset); + return fallback ? [fallback] : []; +} + /** * Resolve a preset name to env vars ready for session launch. * Auto-fills MODEL_ALIASES from ANTHROPIC_MODEL if set. @@ -76,6 +137,8 @@ export async function resolvePresetEnv(presetName: string, ccSessionId?: string) if (env['ANTHROPIC_AUTH_TOKEN'] && !env['ANTHROPIC_API_KEY']) { env['ANTHROPIC_API_KEY'] = env['ANTHROPIC_AUTH_TOKEN']; } + const effectiveModel = getPresetEffectiveModel(preset); + if (effectiveModel) env['ANTHROPIC_MODEL'] = effectiveModel; // Auto-fill model aliases from ANTHROPIC_MODEL if (env['ANTHROPIC_MODEL']) { for (const alias of MODEL_ALIASES) { @@ -100,7 +163,7 @@ export async function getPresetTransportOverrides(presetName: string): Promise<{ const preset = await getPreset(presetName); if (!preset) return {}; const env = await resolvePresetEnv(presetName); - const configuredModel = env['ANTHROPIC_MODEL']?.trim() || undefined; + const configuredModel = getPresetEffectiveModel(preset); const configuredBaseUrl = env['ANTHROPIC_BASE_URL']?.trim() || undefined; const runtimeFacts = [ `Authoritative runtime fact: this session is using the Claude Code preset "${preset.name}".`, @@ -122,6 +185,7 @@ export async function getQwenPresetTransportConfig(presetName: string): Promise< env: Record; settings?: Record; model?: string; + availableModels?: string[]; systemPrompt?: string; contextWindow?: number; }> { @@ -129,7 +193,8 @@ export async function getQwenPresetTransportConfig(presetName: string): Promise< if (!preset) return { env: {} }; const resolvedEnv = await resolvePresetEnv(presetName); - const model = resolvedEnv['ANTHROPIC_MODEL']?.trim() || undefined; + const availableModels = getPresetAvailableModelIds(preset); + const model = getPresetEffectiveModel(preset) ?? availableModels[0]; const baseUrl = resolvedEnv['ANTHROPIC_BASE_URL']?.trim() || undefined; const apiKey = resolvedEnv['ANTHROPIC_API_KEY']?.trim() || resolvedEnv['ANTHROPIC_AUTH_TOKEN']?.trim() @@ -150,7 +215,8 @@ export async function getQwenPresetTransportConfig(presetName: string): Promise< } if (model) env['ANTHROPIC_MODEL'] = model; - const settings: Record | undefined = (baseUrl && apiKey && model) + const providerModels = availableModels.length > 0 ? availableModels : (model ? [model] : []); + const settings: Record | undefined = (baseUrl && apiKey && providerModels.length > 0) ? { security: { auth: { @@ -158,24 +224,22 @@ export async function getQwenPresetTransportConfig(presetName: string): Promise< }, }, model: { - name: model, + name: model ?? providerModels[0], }, modelProviders: { - anthropic: [ - { - id: model, - name: preset.name, - envKey: 'ANTHROPIC_API_KEY', - baseUrl, - ...(preset.contextWindow - ? { - generationConfig: { - contextWindowSize: preset.contextWindow, - }, - } - : {}), - }, - ], + anthropic: providerModels.map((providerModelId) => ({ + id: providerModelId, + name: preset.availableModels?.find((item) => item.id === providerModelId)?.name?.trim() || providerModelId, + envKey: 'ANTHROPIC_API_KEY', + baseUrl, + ...(preset.contextWindow + ? { + generationConfig: { + contextWindowSize: preset.contextWindow, + }, + } + : {}), + })), }, } : undefined; @@ -201,11 +265,91 @@ export async function getQwenPresetTransportConfig(presetName: string): Promise< env, ...(settings ? { settings } : {}), ...(model ? { model } : {}), + ...(availableModels.length ? { availableModels } : {}), ...(runtimeFacts ? { systemPrompt: runtimeFacts } : {}), ...(preset.contextWindow ? { contextWindow: preset.contextWindow } : {}), }; } +function getDiscoveryCandidates(baseUrl: string): string[] { + const trimmed = baseUrl.trim().replace(/\/+$/, ''); + if (!trimmed) return []; + const candidates = new Set(); + if (trimmed.endsWith('/models')) { + candidates.add(trimmed); + } else { + candidates.add(`${trimmed}/models`); + if (!/\/v\d+(?:$|\/)/.test(trimmed)) candidates.add(`${trimmed}/v1/models`); + } + return [...candidates]; +} + +function parseDiscoveredModels(payload: unknown): CcPresetModelInfo[] { + const record = payload && typeof payload === 'object' ? payload as Record : {}; + const rawModels = Array.isArray(record.data) + ? record.data + : Array.isArray(record.models) + ? record.models + : []; + const seen = new Set(); + const models: CcPresetModelInfo[] = []; + for (const item of rawModels) { + if (!item || typeof item !== 'object') continue; + const model = item as Record; + const id = typeof model.id === 'string' ? model.id.trim() : ''; + if (!id || seen.has(id)) continue; + const displayName = typeof model.display_name === 'string' + ? model.display_name.trim() + : typeof model.name === 'string' + ? model.name.trim() + : ''; + seen.add(id); + models.push(displayName ? { id, name: displayName } : { id }); + } + return models; +} + +export async function discoverPresetModels(preset: CcPreset): Promise<{ + availableModels: CcPresetModelInfo[]; + defaultModel?: string; + endpoint: string; +}> { + const env = { ...preset.env }; + const baseUrl = env['ANTHROPIC_BASE_URL']?.trim() || ''; + const apiKey = env['ANTHROPIC_API_KEY']?.trim() || env['ANTHROPIC_AUTH_TOKEN']?.trim() || ''; + if (!baseUrl) throw new Error('Preset is missing ANTHROPIC_BASE_URL'); + if (!apiKey) throw new Error('Preset is missing ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN'); + + let lastError: Error | null = null; + for (const endpoint of getDiscoveryCandidates(baseUrl)) { + try { + const response = await fetch(endpoint, { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + accept: 'application/json', + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`.trim()); + } + const payload = await response.json() as unknown; + const availableModels = parseDiscoveredModels(payload); + if (availableModels.length === 0) { + throw new Error('No models returned by compatible API'); + } + const existingModel = getPresetEffectiveModel(preset); + const defaultModel = availableModels.some((item) => item.id === existingModel) + ? existingModel + : (availableModels[0]?.id ?? undefined); + return { availableModels, defaultModel, endpoint }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + throw lastError ?? new Error('Failed to discover models'); +} + /** Default init message for non-Anthropic providers (no native web search). */ const DEFAULT_INIT_MESSAGE = 'For web searches, use: curl -s "https://html.duckduckgo.com/html/?q=QUERY" | head -200. Replace QUERY with URL-encoded search terms.'; diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index 5b8b401c3..382f859a1 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -60,6 +60,7 @@ import { getClaudeSdkRuntimeConfig, normalizeClaudeSdkModelForProvider } from '. import { getCodexRuntimeConfig } from '../agent/codex-runtime-config.js'; import { P2P_TERMINAL_RUN_STATUSES } from '../../shared/p2p-status.js'; import { DAEMON_MSG } from '../../shared/daemon-events.js'; +import { CC_PRESET_MSG, type CcPreset } from '../../shared/cc-presets.js'; import { MEMORY_WS } from '../../shared/memory-ws.js'; import { P2P_CONFIG_ERROR, P2P_CONFIG_MSG } from '../../shared/p2p-config-events.js'; import { DAEMON_COMMAND_TYPES } from '../../shared/daemon-command-types.js'; @@ -1094,12 +1095,15 @@ export function handleWebCommand(msg: unknown, serverLink: ServerLink): void { case 'p2p.status': void handleP2pStatus(cmd, serverLink); break; - case 'cc.presets.list': + case CC_PRESET_MSG.LIST: void handleCcPresetsList(serverLink); break; - case 'cc.presets.save': + case CC_PRESET_MSG.SAVE: void handleCcPresetsSave(cmd, serverLink); break; + case CC_PRESET_MSG.DISCOVER_MODELS: + void handleCcPresetsDiscoverModels(cmd, serverLink); + break; case SHARED_CONTEXT_RUNTIME_CONFIG_MSG.APPLY: void handleSharedContextRuntimeConfigApply(cmd); break; @@ -4842,16 +4846,88 @@ export async function listProviderSessions(providerId: string): Promise { const { loadPresets } = await import('./cc-presets.js'); const presets = await loadPresets(); - serverLink.send({ type: 'cc.presets.list_response', presets }); + serverLink.send({ type: CC_PRESET_MSG.LIST_RESPONSE, presets }); } async function handleCcPresetsSave(cmd: Record, serverLink: ServerLink): Promise { - const presets = cmd.presets as Array<{ name: string; env: Record }> | undefined; + const presets = cmd.presets as CcPreset[] | undefined; if (!presets) return; const { savePresets, invalidateCache } = await import('./cc-presets.js'); invalidateCache(); await savePresets(presets); - serverLink.send({ type: 'cc.presets.save_response', ok: true }); + serverLink.send({ type: CC_PRESET_MSG.SAVE_RESPONSE, ok: true }); +} + +async function handleCcPresetsDiscoverModels(cmd: Record, serverLink: ServerLink): Promise { + const requestId = typeof cmd.requestId === 'string' ? cmd.requestId : undefined; + const presetName = typeof cmd.presetName === 'string' ? cmd.presetName.trim() : ''; + if (!presetName) { + serverLink.send({ + type: CC_PRESET_MSG.DISCOVER_MODELS_RESPONSE, + ...(requestId ? { requestId } : {}), + presetName, + ok: false, + error: 'presetName is required', + }); + return; + } + + const { discoverPresetModels, loadPresets, savePresets, getPreset } = await import('./cc-presets.js'); + const presets = await loadPresets(); + const preset = await getPreset(presetName); + if (!preset) { + serverLink.send({ + type: CC_PRESET_MSG.DISCOVER_MODELS_RESPONSE, + ...(requestId ? { requestId } : {}), + presetName, + ok: false, + error: `Preset "${presetName}" not found`, + }); + return; + } + + const normalizedName = preset.name.trim().toLowerCase(); + try { + const discovered = await discoverPresetModels(preset); + const updatedPreset: CcPreset = { + ...preset, + transportMode: preset.transportMode ?? 'qwen-compatible-api', + authType: preset.authType ?? 'anthropic', + availableModels: discovered.availableModels, + ...(discovered.defaultModel ? { defaultModel: discovered.defaultModel } : {}), + lastDiscoveredAt: Date.now(), + modelDiscoveryError: undefined, + }; + await savePresets(presets.map((item) => ( + item.name.trim().toLowerCase() === normalizedName ? updatedPreset : item + ))); + serverLink.send({ + type: CC_PRESET_MSG.DISCOVER_MODELS_RESPONSE, + ...(requestId ? { requestId } : {}), + presetName: updatedPreset.name, + ok: true, + preset: updatedPreset, + models: discovered.availableModels, + endpoint: discovered.endpoint, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const updatedPreset: CcPreset = { + ...preset, + modelDiscoveryError: message, + }; + await savePresets(presets.map((item) => ( + item.name.trim().toLowerCase() === normalizedName ? updatedPreset : item + ))); + serverLink.send({ + type: CC_PRESET_MSG.DISCOVER_MODELS_RESPONSE, + ...(requestId ? { requestId } : {}), + presetName: updatedPreset.name, + ok: false, + error: message, + preset: updatedPreset, + }); + } } async function handleSharedContextRuntimeConfigApply(cmd: Record): Promise { diff --git a/src/daemon/session-list.ts b/src/daemon/session-list.ts index 1b8b29959..ac27b67c3 100644 --- a/src/daemon/session-list.ts +++ b/src/daemon/session-list.ts @@ -149,13 +149,16 @@ export async function buildSessionList(): Promise { // a synchronous .map() callback. The preset model takes priority over // qwenRuntime available models for display so preset sessions (e.g. MiniMax) // show the correct model even when qwenRuntime hasn't loaded yet. - const presetModelBySession = new Map(); + const presetModelBySession = new Map(); if (needsQwenHydration) { - const { getPreset } = await import('./cc-presets.js'); + const { getPreset, getPresetAvailableModelIds, getPresetEffectiveModel } = await import('./cc-presets.js'); for (const s of sessions) { if (s.agentType === 'qwen' && s.ccPreset) { const preset = await getPreset(s.ccPreset); - presetModelBySession.set(s.name, preset?.env?.['ANTHROPIC_MODEL']?.trim() || undefined); + presetModelBySession.set(s.name, { + defaultModel: preset ? getPresetEffectiveModel(preset) : undefined, + availableModels: preset ? getPresetAvailableModelIds(preset) : [], + }); } } } @@ -224,7 +227,9 @@ export async function buildSessionList(): Promise { // No longer available". Non-preset qwen sessions keep the OAuth-derived // tier labels so users see the real state of their CLI auth. const presetActive = !!s.ccPreset; - const presetModel = presetModelBySession.get(s.name); + const presetConfig = presetModelBySession.get(s.name); + const presetModel = presetConfig?.defaultModel; + const presetModels = presetConfig?.availableModels ?? []; const qwenAuthType = presetActive ? QWEN_AUTH_TYPES.API_KEY @@ -232,18 +237,25 @@ export async function buildSessionList(): Promise { const qwenAuthLimit = presetActive ? undefined : (s.qwenAuthLimit ?? qwenRuntime?.authLimit); - const qwenAvailableModels = presetActive && presetModel - ? [presetModel] + const qwenAvailableModels = presetActive + ? (presetModels.length + ? presetModels + : (s.qwenAvailableModels?.length + ? s.qwenAvailableModels + : (qwenRuntime?.availableModels?.length ? qwenRuntime.availableModels : undefined))) : (s.qwenAvailableModels?.length ? s.qwenAvailableModels : (qwenRuntime?.availableModels?.length ? qwenRuntime.availableModels : undefined)); - const qwenModel = presetModel ?? s.qwenModel ?? qwenAvailableModels?.[0]; - // modelDisplay: prefer preset's pinned model, then session's existing - // modelDisplay, then the effective qwenModel. This ensures the preset - // model (MiniMax-M2.7) displays correctly even when qwenRuntime's - // availableModels hasn't loaded yet or the session was restored from - // persisted state without the preset context. - const displayModel = presetModel ?? s.modelDisplay ?? qwenModel; + const qwenModel = presetActive + ? ((s.qwenModel && qwenAvailableModels?.includes(s.qwenModel)) + ? s.qwenModel + : (presetModel ?? qwenAvailableModels?.[0] ?? s.qwenModel)) + : (s.qwenModel ?? qwenAvailableModels?.[0]); + // For preset-backed sessions, keep a valid user-selected model visible. + // Fall back to the preset default only when the stored selection is stale. + const displayModel = presetActive + ? (qwenModel ?? presetModel ?? s.modelDisplay) + : (s.modelDisplay ?? qwenModel); const displayMetadata = getQwenDisplayMetadata({ model: displayModel, authType: qwenAuthType, diff --git a/test/daemon/cc-presets.test.ts b/test/daemon/cc-presets.test.ts index b46c69265..2bbdc29d1 100644 --- a/test/daemon/cc-presets.test.ts +++ b/test/daemon/cc-presets.test.ts @@ -86,7 +86,7 @@ describe('cc presets', () => { anthropic: [ { id: 'MiniMax-M2.7', - name: 'minimax', + name: 'MiniMax-M2.7', envKey: 'ANTHROPIC_API_KEY', baseUrl: 'https://api.minimax.io/anthropic', generationConfig: { @@ -104,4 +104,37 @@ describe('cc presets', () => { expect(result.systemPrompt).toContain('https://api.minimax.io/anthropic'); expect(result.systemPrompt).toMatch(/not running on Qwen/i); }); + + it('uses discovered compatible-api models when building qwen transport config', async () => { + const { savePresets, getQwenPresetTransportConfig } = await import('../../src/daemon/cc-presets.js'); + + await savePresets([ + { + name: 'minimax', + env: { + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }, + defaultModel: 'MiniMax-M2.7', + availableModels: [ + { id: 'MiniMax-M2.7', name: 'MiniMax M2.7' }, + { id: 'MiniMax-Text-01' }, + ], + }, + ]); + + const result = await getQwenPresetTransportConfig('minimax'); + expect(result.model).toBe('MiniMax-M2.7'); + expect(result.availableModels).toEqual(['MiniMax-M2.7', 'MiniMax-Text-01']); + expect(result.settings).toMatchObject({ + model: { name: 'MiniMax-M2.7' }, + modelProviders: { + anthropic: [ + expect.objectContaining({ id: 'MiniMax-M2.7', name: 'MiniMax M2.7' }), + expect.objectContaining({ id: 'MiniMax-Text-01', name: 'MiniMax-Text-01' }), + ], + }, + }); + }); }); diff --git a/test/daemon/session-list.test.ts b/test/daemon/session-list.test.ts index 253f58d76..3ccc0e20b 100644 --- a/test/daemon/session-list.test.ts +++ b/test/daemon/session-list.test.ts @@ -158,6 +158,8 @@ describe('buildSessionList', () => { getPreset: vi.fn(async (name: string) => name === 'minimax' ? { name: 'minimax', env: { ANTHROPIC_MODEL: 'MiniMax-M2.7' } } : undefined), + getPresetEffectiveModel: vi.fn((preset: { env?: Record }) => preset.env?.ANTHROPIC_MODEL), + getPresetAvailableModelIds: vi.fn((preset: { env?: Record }) => preset.env?.ANTHROPIC_MODEL ? [preset.env.ANTHROPIC_MODEL] : []), })); const { buildSessionList } = await import('../../src/daemon/session-list.js'); @@ -175,6 +177,51 @@ describe('buildSessionList', () => { expect(sessions[0].quotaUsageLabel).toBeUndefined(); }); + it('preset-backed qwen sessions keep discovered model lists and active selected model', async () => { + const store = await import('../../src/store/session-store.js'); + store.upsertSession({ + name: 'deck_qwen_multi_brain', + projectName: 'demo', + role: 'brain', + agentType: 'qwen', + runtimeType: 'transport', + providerId: 'qwen', + providerSessionId: 'sid-preset-multi', + state: 'idle', + restarts: 0, + restartTimestamps: [], + createdAt: Date.now(), + updatedAt: Date.now(), + ccPreset: 'minimax', + qwenModel: 'MiniMax-Text-01', + qwenAvailableModels: ['coder-model'], + }); + + vi.doMock('../../src/daemon/cc-presets.js', () => ({ + getPreset: vi.fn(async () => ({ + name: 'minimax', + env: { ANTHROPIC_MODEL: 'MiniMax-M2.7' }, + defaultModel: 'MiniMax-M2.7', + availableModels: [ + { id: 'MiniMax-M2.7', name: 'MiniMax M2.7' }, + { id: 'MiniMax-Text-01' }, + ], + })), + getPresetEffectiveModel: vi.fn((preset: { defaultModel?: string; env?: Record }) => preset.defaultModel ?? preset.env?.ANTHROPIC_MODEL), + getPresetAvailableModelIds: vi.fn((preset: { availableModels?: Array<{ id: string }> }) => preset.availableModels?.map((item) => item.id) ?? []), + })); + + const { buildSessionList } = await import('../../src/daemon/session-list.js'); + const sessions = await buildSessionList(); + expect(sessions[0]).toMatchObject({ + qwenAuthType: 'api-key', + qwenAvailableModels: ['MiniMax-M2.7', 'MiniMax-Text-01'], + qwenModel: 'MiniMax-Text-01', + modelDisplay: 'MiniMax-Text-01', + planLabel: 'BYO', + }); + }); + it('preserves the session transportConfig snapshot in the list surface', async () => { const store = await import('../../src/store/session-store.js'); store.upsertSession({ diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index 8c0b6c247..80eab1de9 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -29,6 +29,7 @@ import { type CcPresetDraft, type CcPresetEntry, } from "./cc-preset-form.js"; +import { CC_PRESET_MSG } from "@shared/cc-presets.js"; const DEFAULT_SHELL_KEY = "default_shell"; // Fallback suggestions used only when the daemon probe returns an empty list @@ -96,6 +97,11 @@ export function NewSessionDialog({ Array<{ key: string; value: string }> >(defaultPresetDraft.customEnv); const [newPresetInit, setNewPresetInit] = useState(defaultPresetDraft.initMessage); + const [newPresetAvailableModels, setNewPresetAvailableModels] = useState( + defaultPresetDraft.availableModels, + ); + const [presetError, setPresetError] = useState(""); + const [discoveringPreset, setDiscoveringPreset] = useState(false); const fmtCtx = (v: string) => { const n = parseInt(v, 10); if (!n) return ""; @@ -112,7 +118,35 @@ export function NewSessionDialog({ setNewPresetCtx(draft.contextWindow); setNewPresetCustomEnv(draft.customEnv); setNewPresetInit(draft.initMessage); + setNewPresetAvailableModels(draft.availableModels); + }; + const buildCurrentPresetDraft = (): CcPresetDraft => ({ + name: newPresetName, + baseUrl: newPresetBaseUrl, + token: newPresetToken, + model: newPresetModel, + contextWindow: newPresetCtx, + customEnv: newPresetCustomEnv, + initMessage: newPresetInit, + availableModels: newPresetAvailableModels, + }); + const persistPresetDraft = (): CcPresetEntry => { + const preset = buildCcPresetFromDraft(buildCurrentPresetDraft()); + const updated = [...ccPresets.filter((p) => p.name !== preset.name), preset]; + setCcPresets(updated); + try { + ws?.send({ type: CC_PRESET_MSG.SAVE, presets: updated }); + } catch {} + return preset; }; + const selectedCcPreset = useMemo( + () => ccPresets.find((preset) => preset.name === ccPreset), + [ccPreset, ccPresets], + ); + const qwenPresetModels = useMemo( + () => selectedCcPreset?.availableModels?.map((item) => item.id) ?? [], + [selectedCcPreset], + ); // OpenClaw-specific state const [ocMode, setOcMode] = useState("new"); @@ -147,9 +181,27 @@ export function NewSessionDialog({ } } // Listen for CC presets response - if (msg.type === "cc.presets.list_response") { + if (msg.type === CC_PRESET_MSG.LIST_RESPONSE) { setCcPresets((msg as any).presets ?? []); } + if (msg.type === CC_PRESET_MSG.DISCOVER_MODELS_RESPONSE) { + setDiscoveringPreset(false); + if (msg.preset) { + setCcPresets((current) => [ + ...current.filter((preset) => preset.name !== msg.preset?.name), + msg.preset, + ]); + if (newPresetName.trim().toLowerCase() === msg.preset.name.trim().toLowerCase()) { + applyPresetDraft(createCcPresetDraftFromPreset(msg.preset)); + } + if (ccPreset === msg.preset.name || !ccPreset) setCcPreset(msg.preset.name); + const nextModel = msg.preset.defaultModel + ?? msg.preset.availableModels?.[0]?.id + ?? msg.preset.env.ANTHROPIC_MODEL; + if (nextModel) setRequestedModel(nextModel); + } + setPresetError(msg.ok ? "" : (msg.error ?? "Failed to discover models")); + } // Listen for openclaw remote session list response const raw = msg as unknown as Record; if (raw["type"] === "openclaw.sessions_response") { @@ -160,7 +212,7 @@ export function NewSessionDialog({ }); ws.subSessionDetectShells?.(); try { - ws.send({ type: "cc.presets.list" }); + ws.send({ type: CC_PRESET_MSG.LIST }); } catch { /* ws may not support send in test */ } @@ -271,7 +323,9 @@ export function NewSessionDialog({ if (ccInitPrompt.trim() && agentType === "claude-code") extra.ccInitPrompt = ccInitPrompt.trim(); if ( - (agentType === "copilot-sdk" || agentType === "cursor-headless") && + (agentType === "copilot-sdk" + || agentType === "cursor-headless" + || agentType === "qwen") && requestedModel.trim() ) { extra.requestedModel = requestedModel.trim(); @@ -311,7 +365,9 @@ export function NewSessionDialog({ : []; const supportsCcPreset = agentType === "claude-code" || agentType === "qwen"; const supportsModelSelection = - agentType === "copilot-sdk" || agentType === "cursor-headless"; + agentType === "copilot-sdk" + || agentType === "cursor-headless" + || (agentType === "qwen" && !!selectedCcPreset); const dynamicModelsAgentType = supportsDynamicTransportModels(agentType) ? agentType : null; @@ -320,15 +376,35 @@ export function NewSessionDialog({ if (transportModels.models.length > 0) { return transportModels.models.map((m) => m.id); } + if (agentType === "qwen") { + return qwenPresetModels.length > 0 + ? qwenPresetModels + : (selectedCcPreset?.defaultModel ? [selectedCcPreset.defaultModel] : []); + } if (agentType === "copilot-sdk") return [...COPILOT_SDK_MODEL_FALLBACK]; if (agentType === "cursor-headless") return [...CURSOR_HEADLESS_MODEL_FALLBACK]; return [] as string[]; - }, [transportModels.models, agentType]); + }, [transportModels.models, agentType, qwenPresetModels, selectedCcPreset]); useEffect(() => { setThinking("high"); }, [agentType]); + useEffect(() => { + if (agentType !== "qwen") return; + const fallbackModel = + selectedCcPreset?.defaultModel ?? selectedCcPreset?.env.ANTHROPIC_MODEL ?? ""; + if (modelSuggestions.length === 0) { + if (!requestedModel && fallbackModel) setRequestedModel(fallbackModel); + return; + } + if (!requestedModel || !modelSuggestions.includes(requestedModel)) { + setRequestedModel( + modelSuggestions.includes(fallbackModel) ? fallbackModel : modelSuggestions[0], + ); + } + }, [agentType, modelSuggestions, requestedModel, selectedCcPreset]); + const handleKey = (e: KeyboardEvent) => { if (e.key === "Enter" && !starting) handleStart(); }; @@ -503,22 +579,38 @@ export function NewSessionDialog({ {supportsModelSelection && (
- - setRequestedModel((e.target as HTMLInputElement).value) - } - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellcheck={false} - data-lpignore="true" - data-1p-ignore - /> + {agentType === "qwen" && modelSuggestions.length > 0 ? ( + + ) : ( + + setRequestedModel((e.target as HTMLInputElement).value) + } + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellcheck={false} + data-lpignore="true" + data-1p-ignore + /> + )} {modelSuggestions.length > 0 && ( {modelSuggestions.map((model) => ( @@ -540,7 +632,7 @@ export function NewSessionDialog({ alignItems: "center", }} > - {t("new_session.api_provider")} + {agentType === "qwen" ? "Compatible API (via Qwen)" : t("new_session.api_provider")}
))} + {newPresetAvailableModels.length > 0 && ( +
+
+ Discovered Models +
+ +
+ )}
+ {presetError && ( +
+ {presetError} +
+ )} + {/* Existing presets — edit/delete */} {ccPresets.length > 0 && ( @@ -932,6 +1093,7 @@ export function NewSessionDialog({ }} onClick={() => { applyPresetDraft(createCcPresetDraftFromPreset(p)); + setPresetError(p.modelDiscoveryError ?? ""); }} > Edit @@ -952,7 +1114,7 @@ export function NewSessionDialog({ setCcPresets(updated); try { ws?.send({ - type: "cc.presets.save", + type: CC_PRESET_MSG.SAVE, presets: updated, }); } catch {} diff --git a/web/src/components/StartSubSessionDialog.tsx b/web/src/components/StartSubSessionDialog.tsx index d85665083..2556a5261 100644 --- a/web/src/components/StartSubSessionDialog.tsx +++ b/web/src/components/StartSubSessionDialog.tsx @@ -1,7 +1,7 @@ /** * StartSubSessionDialog — choose type (cc/cc-sdk/codex/codex-sdk/opencode/gemini/qwen/shell/openclaw) and launch a sub-session. */ -import { useState, useEffect } from 'preact/hooks'; +import { useState, useEffect, useMemo } from 'preact/hooks'; import { useTranslation } from 'react-i18next'; import type { WsClient } from '../ws-client.js'; import type { RemoteSession } from '../hooks/useProviderStatus.js'; @@ -17,6 +17,7 @@ import { type CcPresetEntry, type CcPresetDraft, } from './cc-preset-form.js'; +import { CC_PRESET_MSG } from '@shared/cc-presets.js'; const CURSOR_HEADLESS_MODEL_SUGGESTIONS = ['gpt-5.2'] as const; const COPILOT_SDK_MODEL_SUGGESTIONS = ['gpt-5.4', 'gpt-5.4-mini'] as const; @@ -66,7 +67,9 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is const [newPresetCtx, setNewPresetCtx] = useState(defaultPresetDraft.contextWindow); const [newPresetCustomEnv, setNewPresetCustomEnv] = useState>(defaultPresetDraft.customEnv); const [newPresetInit, setNewPresetInit] = useState(defaultPresetDraft.initMessage); + const [newPresetAvailableModels, setNewPresetAvailableModels] = useState(defaultPresetDraft.availableModels); const [presetError, setPresetError] = useState(''); + const [discoveringPreset, setDiscoveringPreset] = useState(false); const fmtCtx = (v: string) => { const n = parseInt(v, 10); if (!n) return ''; if (n >= 1000000) return `${(n/1000000).toFixed(n%1000000===0?0:1)}M`; if (n >= 1000) return `${(n/1000).toFixed(0)}K`; return String(n); }; const applyPresetDraft = (draft: CcPresetDraft) => { setNewPresetName(draft.name); @@ -76,7 +79,33 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is setNewPresetCtx(draft.contextWindow); setNewPresetCustomEnv(draft.customEnv); setNewPresetInit(draft.initMessage); + setNewPresetAvailableModels(draft.availableModels); }; + const buildCurrentPresetDraft = (): CcPresetDraft => ({ + name: newPresetName, + baseUrl: newPresetBaseUrl, + token: newPresetToken, + model: newPresetModel, + contextWindow: newPresetCtx, + customEnv: newPresetCustomEnv, + initMessage: newPresetInit, + availableModels: newPresetAvailableModels, + }); + const persistPresetDraft = (): CcPresetEntry => { + const preset = buildCcPresetFromDraft(buildCurrentPresetDraft()); + const updated = [...ccPresets.filter((p) => p.name !== preset.name), preset]; + setCcPresets(updated); + try { ws?.send({ type: CC_PRESET_MSG.SAVE, presets: updated }); } catch {} + return preset; + }; + const selectedCcPreset = useMemo( + () => ccPresets.find((preset) => preset.name === ccPreset), + [ccPreset, ccPresets], + ); + const qwenPresetModels = useMemo( + () => selectedCcPreset?.availableModels?.map((item) => item.id) ?? [], + [selectedCcPreset], + ); // Remote sessions come from the provider status hook (pushed on connect, cached in DB) const ocRemoteSessions = getRemoteSessions('openclaw'); @@ -100,14 +129,32 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is setDetectingShells(false); setShellBin((prev) => (msg.shells.includes(prev) ? prev : (msg.shells[0] ?? prev))); } - if (msg.type === 'cc.presets.list_response') { + if (msg.type === CC_PRESET_MSG.LIST_RESPONSE) { setCcPresets((msg as any).presets ?? []); } + if (msg.type === CC_PRESET_MSG.DISCOVER_MODELS_RESPONSE) { + setDiscoveringPreset(false); + if (msg.preset) { + setCcPresets((current) => [ + ...current.filter((preset) => preset.name !== msg.preset?.name), + msg.preset, + ]); + if (newPresetName.trim().toLowerCase() === msg.preset.name.trim().toLowerCase()) { + applyPresetDraft(createCcPresetDraftFromPreset(msg.preset)); + } + if (ccPreset === msg.preset.name || !ccPreset) setCcPreset(msg.preset.name); + const nextModel = msg.preset.defaultModel + ?? msg.preset.availableModels?.[0]?.id + ?? msg.preset.env.ANTHROPIC_MODEL; + if (nextModel) setRequestedModel(nextModel); + } + setPresetError(msg.ok ? '' : (msg.error ?? 'Failed to discover models')); + } }); setDetectingShells(true); ws.subSessionDetectShells(); - try { ws.send({ type: 'cc.presets.list' }); } catch {} + try { ws.send({ type: CC_PRESET_MSG.LIST }); } catch {} return unsub; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ws]); @@ -123,6 +170,18 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is setThinking('high'); }, [type]); + useEffect(() => { + if (type !== 'qwen') return; + const fallbackModel = selectedCcPreset?.defaultModel ?? selectedCcPreset?.env.ANTHROPIC_MODEL ?? ''; + if (qwenPresetModels.length === 0) { + if (!requestedModel && fallbackModel) setRequestedModel(fallbackModel); + return; + } + if (!requestedModel || !qwenPresetModels.includes(requestedModel)) { + setRequestedModel(qwenPresetModels.includes(fallbackModel) ? fallbackModel : qwenPresetModels[0]); + } + }, [type, qwenPresetModels, requestedModel, selectedCcPreset]); + const handleStart = () => { const desc = description.trim() || undefined; if (type === 'script') { @@ -149,7 +208,7 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is if (desc) extra.description = desc; if (ccPreset && (type === 'claude-code' || type === 'qwen')) extra.ccPreset = ccPreset; if (ccInitPrompt.trim() && type === 'claude-code') extra.ccInitPrompt = ccInitPrompt.trim(); - if ((type === 'copilot-sdk' || type === 'cursor-headless') && requestedModel.trim()) extra.requestedModel = requestedModel.trim(); + if ((type === 'copilot-sdk' || type === 'cursor-headless' || type === 'qwen') && requestedModel.trim()) extra.requestedModel = requestedModel.trim(); if (type === 'claude-code-sdk' || type === 'codex-sdk' || type === 'copilot-sdk' || type === 'qwen') extra.thinking = thinking; onStart(type, selectedShell, cwd || undefined, label || undefined, Object.keys(extra).length > 0 ? extra : undefined); }; @@ -166,12 +225,14 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is ? OPENCLAW_THINKING_LEVELS : []; const supportsCcPreset = type === 'claude-code' || type === 'qwen'; - const supportsModelSelection = type === 'copilot-sdk' || type === 'cursor-headless'; + const supportsModelSelection = type === 'copilot-sdk' || type === 'cursor-headless' || (type === 'qwen' && !!selectedCcPreset); const modelSuggestions = type === 'copilot-sdk' - ? COPILOT_SDK_MODEL_SUGGESTIONS + ? [...COPILOT_SDK_MODEL_SUGGESTIONS] : type === 'cursor-headless' - ? CURSOR_HEADLESS_MODEL_SUGGESTIONS - : []; + ? [...CURSOR_HEADLESS_MODEL_SUGGESTIONS] + : type === 'qwen' + ? (qwenPresetModels.length > 0 ? qwenPresetModels : (selectedCcPreset?.defaultModel ? [selectedCcPreset.defaultModel] : [])) + : []; return (
@@ -340,7 +401,7 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is <>
- {t('new_session.api_provider')} + {type === 'qwen' ? 'Compatible API (via Qwen)' : t('new_session.api_provider')} @@ -348,7 +409,7 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is {ccPresets.length > 0 ? ( ) : !showPresetEditor && (
{t('new_session.api_provider_default')}
@@ -370,6 +431,16 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is set((e.target as HTMLInputElement).value)} style={{ width: '100%', fontSize: 11 }} />
))} + {newPresetAvailableModels.length > 0 && ( +
+
Discovered Models
+ +
+ )}
Context Window{newPresetCtx && {fmtCtx(newPresetCtx)}}
setNewPresetCtx((e.target as HTMLInputElement).value)} style={{ width: '100%', fontSize: 11 }} /> @@ -393,32 +464,48 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is
+ {ccPresets.length > 0 && (
{ccPresets.map((p) => (
- {p.name} {p.env['ANTHROPIC_MODEL'] ?? ''} + {p.name} {p.defaultModel ?? p.env['ANTHROPIC_MODEL'] ?? ''}
- +
))} @@ -456,15 +543,28 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is {supportsModelSelection && (
{t('session.supervision.model')}
- setRequestedModel((e.target as HTMLInputElement).value)} - style={{ width: '100%' }} - /> + {type === 'qwen' && modelSuggestions.length > 0 ? ( + + ) : ( + setRequestedModel((e.target as HTMLInputElement).value)} + style={{ width: '100%' }} + /> + )} {modelSuggestions.length > 0 && ( {modelSuggestions.map((model) => ( diff --git a/web/src/components/cc-preset-form.ts b/web/src/components/cc-preset-form.ts index ce58c207b..5a27e3544 100644 --- a/web/src/components/cc-preset-form.ts +++ b/web/src/components/cc-preset-form.ts @@ -1,9 +1,6 @@ -export interface CcPresetEntry { - name: string; - env: Record; - contextWindow?: number; - initMessage?: string; -} +import type { CcPreset, CcPresetModelInfo } from '@shared/cc-presets.js'; + +export type CcPresetEntry = CcPreset; export interface CcPresetDraft { name: string; @@ -13,6 +10,7 @@ export interface CcPresetDraft { contextWindow: string; customEnv: Array<{ key: string; value: string }>; initMessage: string; + availableModels: CcPresetModelInfo[]; } export const DEFAULT_CC_PRESET_BASE_URL = 'https://api.minimax.io/anthropic'; @@ -46,6 +44,7 @@ export function createDefaultCcPresetDraft(): CcPresetDraft { contextWindow: DEFAULT_CC_PRESET_CONTEXT_WINDOW, customEnv: cloneCustomEnv(DEFAULT_CC_PRESET_CUSTOM_ENV_TEMPLATE), initMessage: DEFAULT_CC_PRESET_INIT_MSG, + availableModels: [], }; } @@ -54,29 +53,38 @@ export function createCcPresetDraftFromPreset(preset: CcPresetEntry): CcPresetDr name: preset.name, baseUrl: preset.env.ANTHROPIC_BASE_URL ?? DEFAULT_CC_PRESET_BASE_URL, token: preset.env.ANTHROPIC_AUTH_TOKEN ?? '', - model: preset.env.ANTHROPIC_MODEL ?? DEFAULT_CC_PRESET_MODEL, + model: preset.defaultModel ?? preset.env.ANTHROPIC_MODEL ?? DEFAULT_CC_PRESET_MODEL, contextWindow: preset.contextWindow ? String(preset.contextWindow) : DEFAULT_CC_PRESET_CONTEXT_WINDOW, customEnv: Object.entries(preset.env) .filter(([key]) => !INLINE_ENV_KEYS.has(key)) .map(([key, value]) => ({ key, value })), initMessage: preset.initMessage ?? DEFAULT_CC_PRESET_INIT_MSG, + availableModels: preset.availableModels ?? [], }; } export function buildCcPresetFromDraft(draft: CcPresetDraft): CcPresetEntry { + const model = draft.model.trim(); const env: Record = { ANTHROPIC_BASE_URL: draft.baseUrl.trim(), CLAUDE_CODE_ATTRIBUTION_HEADER: '0', }; if (draft.token.trim()) env.ANTHROPIC_AUTH_TOKEN = draft.token.trim(); - if (draft.model.trim()) env.ANTHROPIC_MODEL = draft.model.trim(); + if (model) env.ANTHROPIC_MODEL = model; for (const { key, value } of draft.customEnv) { if (key.trim()) env[key.trim()] = value; } - const preset: CcPresetEntry = { name: draft.name.trim(), env }; + const preset: CcPresetEntry = { + name: draft.name.trim(), + env, + transportMode: 'qwen-compatible-api', + authType: 'anthropic', + }; const contextWindow = parseInt(draft.contextWindow, 10); if (contextWindow) preset.contextWindow = contextWindow; if (draft.initMessage.trim()) preset.initMessage = draft.initMessage.trim(); + if (draft.availableModels.length > 0) preset.availableModels = draft.availableModels; + if (model) preset.defaultModel = model; return preset; } diff --git a/web/src/ws-client.ts b/web/src/ws-client.ts index 891e1f6c6..b49dcce8f 100644 --- a/web/src/ws-client.ts +++ b/web/src/ws-client.ts @@ -9,6 +9,7 @@ import { REPO_MSG } from '@shared/repo-types.js'; import { DAEMON_MSG } from '@shared/daemon-events.js'; import { P2P_CONFIG_MSG } from '@shared/p2p-config-events.js'; import { TRANSPORT_MSG } from '@shared/transport-events.js'; +import { CC_PRESET_MSG, type CcPreset, type CcPresetModelInfo } from '@shared/cc-presets.js'; import { MEMORY_WS } from '@shared/memory-ws.js'; import { MSG_COMMAND_FAILED, @@ -78,8 +79,9 @@ export type ServerMessage = | { type: 'p2p.status_response'; runId?: string; run?: any; runs?: any[] } | { type: 'p2p.list_discussions_response'; discussions: Array<{ id: string; fileName: string; path?: string; preview: string; mtime: number }> } | { type: 'p2p.read_discussion_response'; id?: string; requestId?: string; content?: string; error?: string } - | { type: 'cc.presets.list_response'; presets: Array<{ name: string; env: Record; contextWindow?: number }> } - | { type: 'cc.presets.save_response'; ok: boolean } + | { type: typeof CC_PRESET_MSG.LIST_RESPONSE; presets: CcPreset[] } + | { type: typeof CC_PRESET_MSG.SAVE_RESPONSE; ok: boolean } + | { type: typeof CC_PRESET_MSG.DISCOVER_MODELS_RESPONSE; requestId?: string; presetName: string; ok: boolean; preset?: CcPreset; models?: CcPresetModelInfo[]; endpoint?: string; error?: string } | FsGitDiffResponse | FsWriteResponse | FsMkdirResponse diff --git a/web/test/components/NewSessionDialog.test.tsx b/web/test/components/NewSessionDialog.test.tsx index 2954a9f48..6b80c9788 100644 --- a/web/test/components/NewSessionDialog.test.tsx +++ b/web/test/components/NewSessionDialog.test.tsx @@ -271,7 +271,12 @@ describe('NewSessionDialog', () => { handler({ type: 'cc.presets.list_response', presets: [ - { name: 'MiniMax', env: { ANTHROPIC_MODEL: 'MiniMax-M2.7' } }, + { + name: 'MiniMax', + env: { ANTHROPIC_MODEL: 'MiniMax-M2.7' }, + defaultModel: 'MiniMax-M2.7', + availableModels: [{ id: 'MiniMax-M2.7' }, { id: 'MiniMax-Text-01' }], + }, ], }); return () => {}; @@ -282,7 +287,7 @@ describe('NewSessionDialog', () => { const agentTypeSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement; agentTypeSelect.value = 'qwen'; fireEvent.input(agentTypeSelect, { target: { value: agentTypeSelect.value } }); - await waitFor(() => expect(screen.getByText('api_provider')).toBeDefined()); + await waitFor(() => expect(screen.getByText('Compatible API (via Qwen)')).toBeDefined()); expect(screen.getByText('qwen_provider_selected_hint')).toBeDefined(); fireEvent.input(screen.getByPlaceholderText('my-project'), { target: { value: 'my-app' } }); fireEvent.input(screen.getByPlaceholderText('~/projects/my-project'), { target: { value: '~/projects/my-app' } }); @@ -297,6 +302,7 @@ describe('NewSessionDialog', () => { expect(ws.sendSessionCommand).toHaveBeenCalledWith('start', expect.objectContaining({ agentType: 'qwen', ccPreset: 'MiniMax', + requestedModel: 'MiniMax-M2.7', thinking: 'high', })); }); @@ -308,7 +314,7 @@ describe('NewSessionDialog', () => { const agentTypeSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement; agentTypeSelect.value = 'qwen'; fireEvent.input(agentTypeSelect, { target: { value: agentTypeSelect.value } }); - await waitFor(() => expect(screen.getByText('api_provider')).toBeDefined()); + await waitFor(() => expect(screen.getByText('Compatible API (via Qwen)')).toBeDefined()); fireEvent.click(screen.getByText('api_provider_add_edit')); fireEvent.input(screen.getByPlaceholderText('e.g. MiniMax'), { target: { value: 'MiniMax' } }); @@ -319,6 +325,9 @@ describe('NewSessionDialog', () => { presets: [ expect.objectContaining({ name: 'MiniMax', + transportMode: 'qwen-compatible-api', + authType: 'anthropic', + defaultModel: 'MiniMax-M2.7', env: expect.objectContaining({ ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', ANTHROPIC_MODEL: 'MiniMax-M2.7', @@ -331,6 +340,55 @@ describe('NewSessionDialog', () => { }); }); + it('applies discovered preset models and uses the updated default model for qwen', async () => { + const ws = makeWs(); + let onMessage: ((msg: unknown) => void) | undefined; + ws.onMessage.mockImplementation((handler: (msg: unknown) => void) => { + onMessage = handler; + handler({ + type: 'cc.presets.list_response', + presets: [], + }); + return () => {}; + }); + + render( false} />); + + const agentTypeSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement; + fireEvent.input(agentTypeSelect, { target: { value: 'qwen' } }); + fireEvent.click(screen.getByText('api_provider_add_edit')); + fireEvent.input(screen.getByPlaceholderText('e.g. MiniMax'), { target: { value: 'MiniMax' } }); + fireEvent.input(screen.getByPlaceholderText('your-api-key'), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /discover models/i })); + onMessage?.({ + type: 'cc.presets.discover_models_response', + ok: true, + presetName: 'MiniMax', + preset: { + name: 'MiniMax', + env: { + ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic', + ANTHROPIC_AUTH_TOKEN: 'secret', + ANTHROPIC_MODEL: 'MiniMax-M2.7', + }, + defaultModel: 'MiniMax-Text-01', + availableModels: [{ id: 'MiniMax-M2.7' }, { id: 'MiniMax-Text-01' }], + }, + }); + + fireEvent.input(screen.getByPlaceholderText('my-project'), { target: { value: 'my-app' } }); + fireEvent.input(screen.getByPlaceholderText('~/projects/my-project'), { target: { value: '~/projects/my-app' } }); + fireEvent.click(screen.getByRole('button', { name: /start/i })); + + await waitFor(() => { + expect(ws.sendSessionCommand).toHaveBeenCalledWith('start', expect.objectContaining({ + agentType: 'qwen', + ccPreset: 'MiniMax', + requestedModel: 'MiniMax-Text-01', + })); + }); + }); + it('includes thinking level when starting qwen', async () => { const ws = makeWs(); render( false} />); diff --git a/web/test/components/StartSubSessionDialog.test.tsx b/web/test/components/StartSubSessionDialog.test.tsx index a1952ea02..d8c76e103 100644 --- a/web/test/components/StartSubSessionDialog.test.tsx +++ b/web/test/components/StartSubSessionDialog.test.tsx @@ -193,7 +193,12 @@ describe('StartSubSessionDialog', () => { handler({ type: 'cc.presets.list_response', presets: [ - { name: 'MiniMax', env: { ANTHROPIC_MODEL: 'MiniMax-M2.7' } }, + { + name: 'MiniMax', + env: { ANTHROPIC_MODEL: 'MiniMax-M2.7' }, + defaultModel: 'MiniMax-M2.7', + availableModels: [{ id: 'MiniMax-M2.7' }, { id: 'MiniMax-Text-01' }], + }, ], }); return () => {}; @@ -212,7 +217,7 @@ describe('StartSubSessionDialog', () => { ); fireEvent.click(screen.getByRole('button', { name: /qwen/i })); - await waitFor(() => expect(screen.getByText('api_provider')).toBeDefined()); + await waitFor(() => expect(screen.getByText('Compatible API (via Qwen)')).toBeDefined()); expect(screen.getByText('qwen_provider_selected_hint')).toBeDefined(); const presetSelect = (screen.getAllByRole('combobox') as HTMLSelectElement[]) .find((select) => Array.from(select.options).some((option) => option.value === 'MiniMax')); @@ -223,6 +228,7 @@ describe('StartSubSessionDialog', () => { expect(onStart).toHaveBeenCalledWith('qwen', undefined, '/tmp', undefined, { ccPreset: 'MiniMax', + requestedModel: 'MiniMax-M2.7', thinking: 'high', }); }); @@ -241,7 +247,7 @@ describe('StartSubSessionDialog', () => { ); fireEvent.click(screen.getByRole('button', { name: /qwen/i })); - await waitFor(() => expect(screen.getByText('api_provider')).toBeDefined()); + await waitFor(() => expect(screen.getByText('Compatible API (via Qwen)')).toBeDefined()); fireEvent.click(screen.getByRole('button', { name: /api_provider_add_edit/i })); expect(screen.getByDisplayValue('https://api.minimax.io/anthropic')).toBeDefined(); From 390db1628e5b3878414bfa5ab6efb9a6489a211c Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 10:48:50 +0800 Subject: [PATCH 16/54] fix: make mobile timeline refresh recover missed events --- web/src/app-resume-refresh.ts | 4 +- web/src/hooks/useTimeline.ts | 48 ++++++++----- web/test/app-resume-refresh.test.tsx | 79 ++++++++++++++++++++- web/test/use-timeline-http-backfill.test.ts | 10 ++- 4 files changed, 119 insertions(+), 22 deletions(-) diff --git a/web/src/app-resume-refresh.ts b/web/src/app-resume-refresh.ts index dfd0c82f4..7094a2015 100644 --- a/web/src/app-resume-refresh.ts +++ b/web/src/app-resume-refresh.ts @@ -1,4 +1,4 @@ -import { dispatchActiveTimelineRefresh } from './hooks/useTimeline.js'; +import { requestActiveTimelineRefresh } from './hooks/useTimeline.js'; export interface NativeAppStateApi { addListener( @@ -16,7 +16,7 @@ export async function installNativeAppResumeRefresh( const handle = await appApi.addListener('appStateChange', ({ isActive }) => { if (!isActive) return; reconnectNow(true); - dispatchActiveTimelineRefresh(); + requestActiveTimelineRefresh({ resetCooldowns: true }); }); return () => { const result = handle.remove(); diff --git a/web/src/hooks/useTimeline.ts b/web/src/hooks/useTimeline.ts index 1c6aa459a..46b0fb70b 100644 --- a/web/src/hooks/useTimeline.ts +++ b/web/src/hooks/useTimeline.ts @@ -76,6 +76,18 @@ export function dispatchActiveTimelineRefresh(): void { try { window.dispatchEvent(new CustomEvent(ACTIVE_TIMELINE_REFRESH_EVENT)); } catch { /* ignore */ } } +export function requestActiveTimelineRefresh(options?: { resetCooldowns?: boolean }): void { + if (typeof window === 'undefined') return; + if (options?.resetCooldowns) resetBackfillCooldowns(); + dispatchActiveTimelineRefresh(); + const fireLater = (): void => dispatchActiveTimelineRefresh(); + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => window.requestAnimationFrame(fireLater)); + return; + } + window.setTimeout(fireLater, 32); +} + // On every visibility transition we record when the document went hidden; // on the return-to-visible side we clear the mount cooldown and emit a // refresh request so the mounted timeline for the active session can @@ -115,6 +127,7 @@ const MAX_HISTORY_EVENTS = 2000; const MAX_CACHED_SESSIONS = 12; const MAX_TOTAL_CACHED_EVENTS = 12_000; const ECHO_WINDOW_MS = 500; +const TIMELINE_HISTORY_AFTER_TS_OVERLAP_MS = 1; // Dedup window for user.message from JSONL vs web-UI-sent: JSONL watcher polls every 2s, // so the same message can arrive twice (once from command-handler, once from JSONL). // 5s is enough to catch the JSONL delay without hiding legitimate repeated messages. @@ -340,6 +353,19 @@ export function __clearPersistedTimelineSnapshotsForTests(): void { } } +function getTimelineHistoryAfterTs(events: TimelineEvent[]): number | undefined { + let maxTs: number | undefined; + for (const ev of events) { + // Pending optimistic bubbles carry `ts = Date.now()` from the client + // clock — exclude them so a skewed client clock can't accidentally + // filter out legitimately-missed server events. + if (ev.type === 'user.message' && (ev as { payload?: { pending?: boolean } }).payload?.pending) continue; + if (typeof ev.ts === 'number' && (maxTs === undefined || ev.ts > maxTs)) maxTs = ev.ts; + } + if (maxTs === undefined) return undefined; + return Math.max(0, maxTs - TIMELINE_HISTORY_AFTER_TS_OVERLAP_MS); +} + export function __getTimelineCacheKeysForTests(): string[] { return [...eventsCache.keys()]; } @@ -764,14 +790,7 @@ export function useTimeline( // Recompute the cursor at fire time, not call time — the UI may have // received fresh WS events during the delay window and we don't want // to redownload them. - let afterTs: number | undefined; - for (const ev of eventsRef.current) { - // Pending optimistic bubbles carry `ts = Date.now()` from the client - // clock — exclude them so a skewed client clock can't accidentally - // filter out legitimately-missed server events. - if (ev.type === 'user.message' && (ev as { payload?: { pending?: boolean } }).payload?.pending) continue; - if (typeof ev.ts === 'number' && (afterTs === undefined || ev.ts > afterTs)) afterTs = ev.ts; - } + const afterTs = getTimelineHistoryAfterTs(eventsRef.current); httpBackfillInFlightRef.current += 1; setHttpRefreshing(true); void fetchTimelineHistoryHttp(serverId, backfillSessionId, { @@ -802,6 +821,7 @@ export function useTimeline( // mount effect to re-run on every render. const fireHttpBackfillRef = useRef(fireHttpBackfill); fireHttpBackfillRef.current = fireHttpBackfill; + const lastActiveRefreshAtRef = useRef(0); // Force-refresh the active session when the app comes back to the // foreground or a push-notification is tapped. Listener is intentionally @@ -815,6 +835,9 @@ export function useTimeline( // each call, and `fireHttpBackfill` itself no-ops when either is unset. useEffect(() => { const handler = (): void => { + const now = Date.now(); + if (now - lastActiveRefreshAtRef.current < 250) return; + lastActiveRefreshAtRef.current = now; fireHttpBackfillRef.current(0); }; window.addEventListener(ACTIVE_TIMELINE_REFRESH_EVENT, handler); @@ -1049,14 +1072,7 @@ export function useTimeline( if (msg.type === 'session.event' && (msg as { event: string }).event === 'connected') { if (ws && sessionId) { const current = eventsRef.current; - let afterTs: number | undefined; - for (const ev of current) { - // Pending optimistic bubbles carry `ts = Date.now()` from the - // client clock — exclude them so a skewed client clock can't - // accidentally filter out legitimately-missed server events. - if (ev.type === 'user.message' && (ev as { payload?: { pending?: boolean } }).payload?.pending) continue; - if (typeof ev.ts === 'number' && (afterTs === undefined || ev.ts > afterTs)) afterTs = ev.ts; - } + const afterTs = getTimelineHistoryAfterTs(current); historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, MAX_MEMORY_EVENTS, afterTs); // Fire HTTP backfill with a ~600ms delay to let the bridge's async diff --git a/web/test/app-resume-refresh.test.tsx b/web/test/app-resume-refresh.test.tsx index 9dc6e450a..607c73323 100644 --- a/web/test/app-resume-refresh.test.tsx +++ b/web/test/app-resume-refresh.test.tsx @@ -88,7 +88,7 @@ describe('native app resume refresh chain', () => { await act(async () => { appStateListener?.({ isActive: true }); - await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(100); }); expect(reconnectNow).toHaveBeenCalledWith(true); @@ -96,10 +96,85 @@ describe('native app resume refresh chain', () => { expect(fetchSpy).toHaveBeenCalledWith( serverId, sessionName, - expect.objectContaining({ afterTs: 1000 }), + expect.objectContaining({ afterTs: 999 }), ); removeListener(); expect(removeSpy).toHaveBeenCalledTimes(1); }); + + it('native resume still refreshes after the timeline remounts in the same resume window', async () => { + const sessionName = `deck_resume_remount_${Date.now()}`; + const serverId = `srv-remount-${Date.now()}`; + const reconnectNow = vi.fn(); + let appStateListener: ((state: { isActive: boolean }) => void) | null = null; + + fetchSpy.mockResolvedValue({ events: [], epoch: 1, hasMore: false, nextCursor: null }); + + ingestTimelineEventForCache({ + eventId: `${sessionName}-seed`, + sessionId: sessionName, + ts: 1000, + epoch: 1, + seq: 1, + source: 'daemon', + confidence: 'high', + type: 'assistant.text', + payload: { text: 'seed' }, + }, serverId); + + const ws: WsClient = { + connected: true, + onMessage: () => () => {}, + sendTimelineHistoryRequest: vi.fn(() => 'history-resume-remount'), + } as unknown as WsClient; + + function Probe() { + const { events } = useTimeline(sessionName, ws, serverId); + return h('div', { 'data-testid': 'probe' }, String(events.length)); + } + + vi.useFakeTimers({ shouldAdvanceTime: true }); + const first = render(h(Probe)); + await waitFor(() => { + expect(screen.getByTestId('probe').textContent).toBe('1'); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + first.unmount(); + fetchSpy.mockClear(); + + await installNativeAppResumeRefresh( + true, + reconnectNow, + { + addListener: async (_eventName, listener) => { + appStateListener = listener; + return { remove: vi.fn() }; + }, + }, + ); + + await act(async () => { + appStateListener?.({ isActive: true }); + }); + + render(h(Probe)); + await waitFor(() => { + expect(screen.getByTestId('probe').textContent).toBe('1'); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(reconnectNow).toHaveBeenCalledWith(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + serverId, + sessionName, + expect.objectContaining({ afterTs: 999 }), + ); + }); }); diff --git a/web/test/use-timeline-http-backfill.test.ts b/web/test/use-timeline-http-backfill.test.ts index e83072d4e..08f947a29 100644 --- a/web/test/use-timeline-http-backfill.test.ts +++ b/web/test/use-timeline-http-backfill.test.ts @@ -103,6 +103,12 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { handler?.({ type: 'session.event', event: 'connected', session: '', state: 'connected' } as ServerMessage); }); + expect(ws.sendTimelineHistoryRequest).toHaveBeenCalledWith( + sessionName, + 300, + 7499, + ); + // Before the delay expires, backfill should not have fired. expect(fetchSpy).not.toHaveBeenCalled(); @@ -119,7 +125,7 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { expect(fetchSpy).toHaveBeenCalledWith( serverId, sessionName, - expect.objectContaining({ afterTs: 7500 }), + expect.objectContaining({ afterTs: 7499 }), ); await waitFor(() => { @@ -299,7 +305,7 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { expect(fetchSpy).toHaveBeenCalledWith( serverId, sessionName, - expect.objectContaining({ afterTs: 6000 }), + expect.objectContaining({ afterTs: 5999 }), ); // Recovered event merged into the rendered view. From fca6782879f6da499afd656168bc7dad468c04d9 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 10:49:36 +0800 Subject: [PATCH 17/54] Fix qwen preset e2e mocks --- test/e2e/qwen-transport-flow.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/e2e/qwen-transport-flow.test.ts b/test/e2e/qwen-transport-flow.test.ts index f419b15d8..f59e842f6 100644 --- a/test/e2e/qwen-transport-flow.test.ts +++ b/test/e2e/qwen-transport-flow.test.ts @@ -172,6 +172,7 @@ vi.mock('../../src/daemon/cc-presets.js', () => ({ OPENAI_API_KEY: 'test-token', }, model: 'MiniMax-M2.7', + availableModels: ['MiniMax-M2.7'], contextWindow: 200000, settings: { security: { auth: { selectedType: 'anthropic' } }, @@ -191,8 +192,15 @@ vi.mock('../../src/daemon/cc-presets.js', () => ({ getPreset: vi.fn(async (presetName: string) => presetName === 'MiniMax' ? ({ name: 'MiniMax', env: { ANTHROPIC_MODEL: 'MiniMax-M2.7' }, + defaultModel: 'MiniMax-M2.7', + availableModels: [{ id: 'MiniMax-M2.7', name: 'minimax' }], contextWindow: 200000, }) : null), + getPresetEffectiveModel: vi.fn((preset: { defaultModel?: string; env?: Record }) => preset.defaultModel ?? preset.env?.ANTHROPIC_MODEL), + getPresetAvailableModelIds: vi.fn((preset: { availableModels?: Array<{ id: string }>; defaultModel?: string; env?: Record }) => { + const discovered = preset.availableModels?.map((item) => item.id) ?? []; + return discovered.length > 0 ? discovered : (preset.defaultModel ?? preset.env?.ANTHROPIC_MODEL ? [preset.defaultModel ?? String(preset.env?.ANTHROPIC_MODEL)] : []); + }), getCachedPresetContextWindow: vi.fn((presetName: string) => presetName === 'MiniMax' ? 200000 : undefined), })); From 957aa5f10438b60bc606b6c8091854113f9aa9ab Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 12:22:43 +0800 Subject: [PATCH 18/54] Stabilize timeline reconnect afterTs test --- web/test/use-timeline-cache.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/test/use-timeline-cache.test.ts b/web/test/use-timeline-cache.test.ts index f8795204f..c2dc2a450 100644 --- a/web/test/use-timeline-cache.test.ts +++ b/web/test/use-timeline-cache.test.ts @@ -730,7 +730,8 @@ describe('useTimeline global cache bounds', () => { const sendTimelineHistoryRequest = vi.fn(() => 'history-reconnect'); // Seed the shared cache so the hook mounts with known events — the - // most recent has ts=5000, which should become afterTs on reconnect. + // most recent has ts=5000, so reconnect should request from 4999 to keep + // a 1ms overlap and avoid dropping an event on the boundary. ingestTimelineEventForCache({ eventId: `${sessionName}-ingest-1`, sessionId: sessionName, @@ -778,12 +779,12 @@ describe('useTimeline global cache bounds', () => { sendTimelineHistoryRequest.mockClear(); // Simulate browser WS reconnect. useTimeline should now gap-fill using - // afterTs = max ts of currently-rendered events (5000). + // afterTs = max ts of currently-rendered events minus the 1ms overlap. await act(async () => { handler?.({ type: 'session.event', event: 'connected', session: '', state: 'connected' } as ServerMessage); }); expect(sendTimelineHistoryRequest).toHaveBeenCalledTimes(1); - expect(sendTimelineHistoryRequest).toHaveBeenCalledWith(sessionName, 300, 5000); + expect(sendTimelineHistoryRequest).toHaveBeenCalledWith(sessionName, 300, 4999); }); }); From ff653c39b469c9e1dd61130ddb6f2729f55f38e1 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 15:14:59 +0800 Subject: [PATCH 19/54] feat: add postgres timeline text-tail cache --- .../043_session_text_tail_cache.sql | 11 + server/src/db/queries.ts | 152 ++++++++++ server/src/routes/watch.ts | 25 +- server/src/ws/bridge.ts | 13 +- server/test/bridge.test.ts | 62 ++++- server/test/db.integration.test.ts | 259 +++++++++++++++++- server/test/watch-routes.test.ts | 50 ++++ web/src/api.ts | 39 +++ web/src/hooks/useTimeline.ts | 50 +++- web/test/app-resume-refresh.test.tsx | 8 +- web/test/use-timeline-cache.test.ts | 198 +++++++++++++ web/test/use-timeline-http-backfill.test.ts | 8 +- 12 files changed, 864 insertions(+), 11 deletions(-) create mode 100644 server/src/db/migrations/043_session_text_tail_cache.sql diff --git a/server/src/db/migrations/043_session_text_tail_cache.sql b/server/src/db/migrations/043_session_text_tail_cache.sql new file mode 100644 index 000000000..13fceb7ac --- /dev/null +++ b/server/src/db/migrations/043_session_text_tail_cache.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS session_text_tail_cache ( + server_id TEXT NOT NULL, + session_name TEXT NOT NULL, + events JSONB NOT NULL DEFAULT '[]'::jsonb, + latest_ts BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (server_id, session_name) +); + +CREATE INDEX IF NOT EXISTS idx_session_text_tail_cache_updated_at + ON session_text_tail_cache (updated_at DESC); diff --git a/server/src/db/queries.ts b/server/src/db/queries.ts index 483b25d19..640317137 100644 --- a/server/src/db/queries.ts +++ b/server/src/db/queries.ts @@ -96,6 +96,113 @@ export interface QuickData { phrases: string[]; } +export const SESSION_TEXT_TAIL_CACHE_LIMIT = 50; + +export interface SessionTextTailCacheItem { + eventId: string; + ts: number; + type: 'user.message' | 'assistant.text'; + text: string; + source?: string; + confidence?: string; +} + +interface DbSessionTextTailCacheRow { + server_id: string; + session_name: string; + events: SessionTextTailCacheItem[] | string | null; + latest_ts: number | null; + updated_at: Date; +} + +interface ClassifiedSessionTextTailEvent { + sessionName: string; + item: SessionTextTailCacheItem; +} + +function normalizeSessionTextTailText(text: unknown): string | null { + if (typeof text !== 'string') return null; + const trimmed = text.trim(); + return trimmed || null; +} + +function isSessionTextTailType(type: unknown): type is SessionTextTailCacheItem['type'] { + return type === 'user.message' || type === 'assistant.text'; +} + +function parseSessionTextTailCacheEvents( + raw: SessionTextTailCacheItem[] | string | null | undefined, +): SessionTextTailCacheItem[] { + let parsed: unknown = raw; + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + return []; + } + } + if (!Array.isArray(parsed)) return []; + const items: SessionTextTailCacheItem[] = []; + for (const entry of parsed) { + if (!entry || typeof entry !== 'object') return []; + const row = entry as Record; + if ( + typeof row.eventId !== 'string' + || typeof row.ts !== 'number' + || !isSessionTextTailType(row.type) + || typeof row.text !== 'string' + ) { + return []; + } + const text = normalizeSessionTextTailText(row.text); + if (!text) return []; + const item: SessionTextTailCacheItem = { + eventId: row.eventId, + ts: row.ts, + type: row.type, + text, + }; + if (typeof row.source === 'string' && row.source.trim()) item.source = row.source.trim(); + if (typeof row.confidence === 'string' && row.confidence.trim()) item.confidence = row.confidence.trim(); + items.push(item); + } + return items; +} + +function mergeSessionTextTailCacheEvents( + existing: SessionTextTailCacheItem[], + incoming: SessionTextTailCacheItem, +): SessionTextTailCacheItem[] { + const deduped = new Map(); + for (const item of existing) deduped.set(item.eventId, item); + deduped.set(incoming.eventId, incoming); + const merged = [...deduped.values()].sort((a, b) => { + if (a.ts !== b.ts) return a.ts - b.ts; + return a.eventId.localeCompare(b.eventId); + }); + return merged.length > SESSION_TEXT_TAIL_CACHE_LIMIT + ? merged.slice(merged.length - SESSION_TEXT_TAIL_CACHE_LIMIT) + : merged; +} + +export function classifySessionTextTailEvent(rawEvent: Record): ClassifiedSessionTextTailEvent | null { + const sessionName = typeof rawEvent.sessionId === 'string' ? rawEvent.sessionId : null; + const eventId = typeof rawEvent.eventId === 'string' ? rawEvent.eventId : null; + const ts = typeof rawEvent.ts === 'number' ? rawEvent.ts : null; + const type = isSessionTextTailType(rawEvent.type) ? rawEvent.type : null; + const payload = rawEvent.payload && typeof rawEvent.payload === 'object' + ? rawEvent.payload as Record + : null; + if (!sessionName || !eventId || ts === null || !type || !payload) return null; + if (type === 'assistant.text' && payload.streaming === true) return null; + const text = normalizeSessionTextTailText(payload.text); + if (!text) return null; + const item: SessionTextTailCacheItem = { eventId, ts, type, text }; + if (typeof rawEvent.source === 'string' && rawEvent.source.trim()) item.source = rawEvent.source.trim(); + if (typeof rawEvent.confidence === 'string' && rawEvent.confidence.trim()) item.confidence = rawEvent.confidence.trim(); + return { sessionName, item }; +} + // ── Users ───────────────────────────────────────────────────────────────── export async function createUser(db: Database, id: string): Promise { @@ -542,6 +649,51 @@ export async function updateSession( ); } +export async function upsertSessionTextTailCacheEvent( + db: Database, + serverId: string, + rawEvent: Record, +): Promise { + const classified = classifySessionTextTailEvent(rawEvent); + if (!classified) return; + await db.transaction(async (tx) => { + const row = await tx.queryOne>( + `SELECT events + FROM session_text_tail_cache + WHERE server_id = $1 AND session_name = $2 + FOR UPDATE`, + [serverId, classified.sessionName], + ); + const existing = parseSessionTextTailCacheEvents(row?.events ?? null); + const events = mergeSessionTextTailCacheEvents(existing, classified.item); + const latestTs = events.length > 0 ? events[events.length - 1]!.ts : null; + await tx.execute( + `INSERT INTO session_text_tail_cache (server_id, session_name, events, latest_ts, updated_at) + VALUES ($1, $2, $3::jsonb, $4, NOW()) + ON CONFLICT (server_id, session_name) + DO UPDATE SET events = EXCLUDED.events, latest_ts = EXCLUDED.latest_ts, updated_at = NOW()`, + [serverId, classified.sessionName, JSON.stringify(events), latestTs], + ); + }); +} + +export async function getSessionTextTailCache( + db: Database, + serverId: string, + sessionName: string, +): Promise { + const row = await db.queryOne>( + `SELECT events + FROM session_text_tail_cache + WHERE server_id = $1 AND session_name = $2`, + [serverId, sessionName], + ); + const events = parseSessionTextTailCacheEvents(row?.events ?? null); + return events.length > SESSION_TEXT_TAIL_CACHE_LIMIT + ? events.slice(events.length - SESSION_TEXT_TAIL_CACHE_LIMIT) + : events; +} + // ── Quick data ──────────────────────────────────────────────────────────── const EMPTY_QUICK_DATA: QuickData = { history: [], sessionHistory: {}, commands: [], phrases: [] }; diff --git a/server/src/routes/watch.ts b/server/src/routes/watch.ts index 8b3c92072..310830d1f 100644 --- a/server/src/routes/watch.ts +++ b/server/src/routes/watch.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import type { Env } from '../env.js'; -import { getServersByUserId, getDbSessionsByServer, getSubSessionsByServer, getUserPref } from '../db/queries.js'; +import { getServersByUserId, getDbSessionsByServer, getSubSessionsByServer, getUserPref, getSessionTextTailCache } from '../db/queries.js'; import { requireAuth, resolveServerRole } from '../security/authorization.js'; import { WsBridge } from '../ws/bridge.js'; import { IMCODES_POD_HEADER } from '../../../shared/http-header-names.js'; @@ -356,3 +356,26 @@ watchRoutes.get('/server/:id/timeline/history/full', requireAuth(), async (c) => return c.json({ error: 'relay_failed' }, 502); } }); + +watchRoutes.get('/server/:id/timeline/text-tail', requireAuth(), async (c) => { + const userId = c.get('userId' as never) as string; + const serverId = c.req.param('id')!; + const role = await resolveServerRole(c.env.DB, serverId, userId); + if (role === 'none') return c.json({ error: 'forbidden' }, 403); + + const sessionName = c.req.query('sessionName')?.trim(); + if (!sessionName) return c.json({ error: 'session_name_required' }, 400); + + try { + const events = await getSessionTextTailCache(c.env.DB, serverId, sessionName); + c.header(IMCODES_POD_HEADER, getPodIdentity()); + return c.json({ sessionName, events }); + } catch (err) { + logger.warn({ + serverId, + sessionName, + err: err instanceof Error ? err.message : String(err), + }, 'timeline.text-tail failed'); + return c.json({ error: 'cache_read_failed' }, 500); + } +}); diff --git a/server/src/ws/bridge.ts b/server/src/ws/bridge.ts index 097ce4c91..63d56ed80 100644 --- a/server/src/ws/bridge.ts +++ b/server/src/ws/bridge.ts @@ -51,7 +51,7 @@ import { type PreviewWsOpenedMessage, } from '../../../shared/preview-types.js'; import { LocalWebPreviewRegistry } from '../preview/registry.js'; -import { updateServerHeartbeat, updateServerStatus, upsertDiscussion, insertDiscussionRound, createSubSession, updateSubSession, upsertOrchestrationRun, updateProviderStatus, clearProviderStatus, updateProviderRemoteSessions } from '../db/queries.js'; +import { updateServerHeartbeat, updateServerStatus, upsertDiscussion, insertDiscussionRound, createSubSession, updateSubSession, upsertOrchestrationRun, updateProviderStatus, clearProviderStatus, updateProviderRemoteSessions, upsertSessionTextTailCacheEvent } from '../db/queries.js'; import logger from '../util/logger.js'; import { pickReadableSessionDisplay } from '../../../shared/session-display.js'; import { isKnownTestSessionLike } from '../../../shared/test-session-guard.js'; @@ -968,12 +968,17 @@ export class WsBridge { // ── Timeline events: session-scoped ─────────────────────────────────────── if (type === 'timeline.event') { - const sessionId = (msg.event as Record | undefined)?.sessionId as string | undefined; - if (!sessionId) { + const rawEvent = msg.event as Record | undefined; + const sessionId = rawEvent?.sessionId as string | undefined; + if (!rawEvent || !sessionId) { logger.warn({ serverId: this.serverId }, 'timeline.event missing sessionId — discarded'); return; } - this.ingestRecentTextFromTimelineEvent(msg.event as Record); + this.ingestRecentTextFromTimelineEvent(rawEvent); + if (this.db) { + void upsertSessionTextTailCacheEvent(this.db, this.serverId, rawEvent) + .catch((err) => logger.warn({ err, serverId: this.serverId, sessionId }, 'Failed to update session_text_tail_cache')); + } this.sendToSessionSubscribers(sessionId, JSON.stringify(msg)); return; } diff --git a/server/test/bridge.test.ts b/server/test/bridge.test.ts index 6e202264c..c533b1a1e 100644 --- a/server/test/bridge.test.ts +++ b/server/test/bridge.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { EventEmitter } from 'node:events'; import { WsBridge } from '../src/ws/bridge.js'; +import * as dbQueries from '../src/db/queries.js'; // ── Mock WebSocket ───────────────────────────────────────────────────────────── @@ -49,13 +50,15 @@ function packFrame(sessionName: string, payload: Buffer): Buffer { // ── Mock DB ──────────────────────────────────────────────────────────────────── function makeDb(tokenHash: string) { - return { + const db = { queryOne: async () => ({ token_hash: tokenHash }), query: async () => [], execute: async () => ({ changes: 1 }), exec: async () => {}, + transaction: async (fn: (tx: import('../src/db/client.js').Database) => Promise) => fn(db as unknown as import('../src/db/client.js').Database), close: () => {}, - } as unknown as import('../src/db/client.js').Database; + }; + return db as unknown as import('../src/db/client.js').Database; } // ── Mock crypto + push ───────────────────────────────────────────────────────── @@ -985,6 +988,28 @@ describe('WsBridge', () => { expect(browserA.sentStrings.length).toBeGreaterThan(0); expect(browserB.sentStrings.length).toBeGreaterThan(0); }); + + it('timeline.event still reaches subscribers when text-tail cache write fails', async () => { + const spy = vi.spyOn(dbQueries, 'upsertSessionTextTailCacheEvent').mockRejectedValueOnce(new Error('db down')); + const { daemonWs, browserA, browserB } = await setupTwoBrowsers(); + + daemonWs.emit('message', JSON.stringify({ + type: 'timeline.event', + event: { + sessionId: 'session-a', + eventId: 'tail-fail-1', + ts: 123, + type: 'assistant.text', + payload: { text: 'still delivered' }, + }, + })); + await flushAsync(); + + expect(browserA.sentStrings.some((msg) => msg.includes('tail-fail-1'))).toBe(true); + expect(browserB.sentStrings.length).toBe(0); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); }); // ── P0: default-deny — missing session identifier → discard, NOT broadcast ─ @@ -2637,5 +2662,38 @@ describe('WsBridge', () => { { eventId: 'e3', type: 'user.message', text: 'second', ts: 3 }, ]); }); + + it('fails open when session_text_tail_cache update throws', async () => { + const bridge = WsBridge.get(serverId); + const daemonWs = new MockWs(); + const db = makeDb('valid-hash') as import('../src/db/client.js').Database & { transaction: ReturnType }; + db.transaction = vi.fn(async () => { throw new Error('write failed'); }) as never; + const browserWs = new MockWs(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + bridge.handleDaemonConnection(daemonWs as never, db, {} as never); + daemonWs.emit('message', JSON.stringify({ type: 'auth', serverId, token: 't' })); + await flushAsync(); + + bridge.handleBrowserConnection(browserWs as never, 'user-1', db); + browserWs.emit('message', JSON.stringify({ type: 'terminal.subscribe', session: 'deck_proj_brain' })); + await flushAsync(); + browserWs.sent.length = 0; + + daemonWs.emit('message', JSON.stringify({ + type: 'timeline.event', + event: { + eventId: 'e1', + sessionId: 'deck_proj_brain', + ts: 1, + type: 'assistant.text', + payload: { text: 'still delivered' }, + }, + })); + await flushAsync(); + + expect(browserWs.sentStrings.some((msg) => msg.includes('"type":"timeline.event"'))).toBe(true); + expect(errorSpy).toHaveBeenCalled(); + }); }); }); diff --git a/server/test/db.integration.test.ts b/server/test/db.integration.test.ts index b67d555b1..7abb89170 100644 --- a/server/test/db.integration.test.ts +++ b/server/test/db.integration.test.ts @@ -52,6 +52,10 @@ import { getDiscussionsByServer, insertDiscussionRound, getDiscussionRounds, + classifySessionTextTailEvent, + getSessionTextTailCache, + upsertSessionTextTailCacheEvent, + SESSION_TEXT_TAIL_CACHE_LIMIT, upsertOrchestrationRun, getOrchestrationRunById, getActiveOrchestrationRuns, @@ -82,7 +86,7 @@ describe('runMigrations', () => { const tables = [ 'users', 'platform_identities', 'servers', 'channel_bindings', 'platform_bots', 'api_keys', 'refresh_tokens', 'idempotency_records', - 'auth_nonces', 'audit_log', 'pending_binds', 'sessions', 'cron_jobs', 'cron_executions', + 'auth_nonces', 'audit_log', 'pending_binds', 'sessions', 'session_text_tail_cache', 'cron_jobs', 'cron_executions', 'teams', 'team_members', 'push_subscriptions', ]; @@ -243,6 +247,16 @@ describe('runMigrations', () => { ); expect(idx?.indexname).toBe('idx_shared_context_projections_status'); }); + + it('session_text_tail_cache has updated_at index (migration 043)', async () => { + const idx = await db.queryOne<{ indexname: string }>( + `SELECT indexname FROM pg_indexes + WHERE tablename = 'session_text_tail_cache' + AND indexname = 'idx_session_text_tail_cache_updated_at'`, + [], + ); + expect(idx?.indexname).toBe('idx_session_text_tail_cache_updated_at'); + }); }); // ── 2. Database wrapper ───────────────────────────────────────────────────── @@ -274,6 +288,126 @@ describe('Database wrapper', () => { }); }); +describe('session_text_tail_cache', () => { + const serverId = `tail-srv-${Math.random().toString(36).slice(2)}`; + const userId = `tail-user-${Math.random().toString(36).slice(2)}`; + const sessionName = `deck_tail_${Math.random().toString(36).slice(2)}`; + + beforeAll(async () => { + await createUser(db, userId); + await createServer(db, serverId, userId, 'tail-server', 'hash-tail'); + }); + + it('classifies only completed non-empty text events', () => { + expect(classifySessionTextTailEvent({ + eventId: 'e-user', + sessionId: sessionName, + ts: 1, + type: 'user.message', + payload: { text: 'hello' }, + source: 'daemon', + confidence: 'high', + })).toEqual({ + sessionName, + item: { + eventId: 'e-user', + ts: 1, + type: 'user.message', + text: 'hello', + source: 'daemon', + confidence: 'high', + }, + }); + + expect(classifySessionTextTailEvent({ + eventId: 'e-stream', + sessionId: sessionName, + ts: 2, + type: 'assistant.text', + payload: { text: 'partial', streaming: true }, + })).toBeNull(); + + expect(classifySessionTextTailEvent({ + eventId: 'e-empty', + sessionId: sessionName, + ts: 3, + type: 'assistant.text', + payload: { text: ' ' }, + })).toBeNull(); + + expect(classifySessionTextTailEvent({ + eventId: 'e-tool', + sessionId: sessionName, + ts: 4, + type: 'tool.call', + payload: { text: 'nope' }, + })).toBeNull(); + }); + + it('overwrites by eventId and retains only the newest 50 cached entries', async () => { + await db.execute('DELETE FROM session_text_tail_cache WHERE server_id = $1 AND session_name = $2', [serverId, sessionName]); + + for (let i = 1; i <= SESSION_TEXT_TAIL_CACHE_LIMIT + 5; i++) { + await upsertSessionTextTailCacheEvent(db, serverId, { + eventId: `e-${i}`, + sessionId: sessionName, + ts: i, + type: i % 2 === 0 ? 'assistant.text' : 'user.message', + payload: { text: `message ${i}` }, + }); + } + + await upsertSessionTextTailCacheEvent(db, serverId, { + eventId: `e-${SESSION_TEXT_TAIL_CACHE_LIMIT + 5}`, + sessionId: sessionName, + ts: SESSION_TEXT_TAIL_CACHE_LIMIT + 5, + type: 'assistant.text', + payload: { text: 'updated latest message' }, + source: 'daemon', + confidence: 'high', + }); + + const events = await getSessionTextTailCache(db, serverId, sessionName); + expect(events).toHaveLength(SESSION_TEXT_TAIL_CACHE_LIMIT); + expect(events[0]?.eventId).toBe('e-6'); + expect(events.at(-1)).toEqual({ + eventId: `e-${SESSION_TEXT_TAIL_CACHE_LIMIT + 5}`, + ts: SESSION_TEXT_TAIL_CACHE_LIMIT + 5, + type: 'assistant.text', + text: 'updated latest message', + source: 'daemon', + confidence: 'high', + }); + }); + + it('treats malformed rows as empty and rebuilds from the current event', async () => { + const malformedSession = `${sessionName}-malformed`; + await db.execute('DELETE FROM session_text_tail_cache WHERE server_id = $1 AND session_name = $2', [serverId, malformedSession]); + await db.execute( + `INSERT INTO session_text_tail_cache (server_id, session_name, events, latest_ts, updated_at) + VALUES ($1, $2, $3::jsonb, $4, NOW())`, + [serverId, malformedSession, JSON.stringify({ bad: true }), 123], + ); + + await upsertSessionTextTailCacheEvent(db, serverId, { + eventId: 'e-rebuild', + sessionId: malformedSession, + ts: 999, + type: 'assistant.text', + payload: { text: 'rebuilt' }, + }); + + await expect(getSessionTextTailCache(db, serverId, malformedSession)).resolves.toEqual([ + { + eventId: 'e-rebuild', + ts: 999, + type: 'assistant.text', + text: 'rebuilt', + }, + ]); + }); +}); + // ── 3. ON CONFLICT ──────────────────────────────────────────────────────────── describe('ON CONFLICT', () => { @@ -593,6 +727,129 @@ describe('sessions', () => { }); }); +describe('session_text_tail_cache', () => { + let userId: string; + let serverId: string; + + beforeAll(async () => { + userId = 'tail-user-' + Math.random().toString(36).slice(2); + serverId = 'tail-srv-' + Math.random().toString(36).slice(2); + await createUser(db, userId); + await createServer(db, serverId, userId, 'tail-server', 'hash-tail'); + }); + + it('classifySessionTextTailEvent accepts non-empty user and completed assistant text only', () => { + expect(classifySessionTextTailEvent({ + sessionId: 'deck_proj_brain', + eventId: 'u1', + ts: 10, + type: 'user.message', + payload: { text: ' hello ' }, + source: 'daemon', + })).toEqual({ + sessionName: 'deck_proj_brain', + item: { eventId: 'u1', ts: 10, type: 'user.message', text: 'hello', source: 'daemon' }, + }); + expect(classifySessionTextTailEvent({ + sessionId: 'deck_proj_brain', + eventId: 'a1', + ts: 11, + type: 'assistant.text', + payload: { text: ' done ', streaming: false }, + confidence: 'high', + })).toEqual({ + sessionName: 'deck_proj_brain', + item: { eventId: 'a1', ts: 11, type: 'assistant.text', text: 'done', confidence: 'high' }, + }); + expect(classifySessionTextTailEvent({ + sessionId: 'deck_proj_brain', + eventId: 'a2', + ts: 12, + type: 'assistant.text', + payload: { text: 'stream', streaming: true }, + })).toBeNull(); + expect(classifySessionTextTailEvent({ + sessionId: 'deck_proj_brain', + eventId: 'x1', + ts: 13, + type: 'tool.call', + payload: { text: 'nope' }, + })).toBeNull(); + }); + + it('upsertSessionTextTailCacheEvent overwrites by eventId and returns ascending cached rows', async () => { + const sessionName = `deck_proj_tail_${Math.random().toString(36).slice(2)}`; + await upsertSessionTextTailCacheEvent(db, serverId, { + sessionId: sessionName, + eventId: 'dup-1', + ts: 100, + type: 'assistant.text', + payload: { text: 'first value' }, + }); + await upsertSessionTextTailCacheEvent(db, serverId, { + sessionId: sessionName, + eventId: 'dup-1', + ts: 110, + type: 'assistant.text', + payload: { text: 'final value' }, + confidence: 'high', + }); + await upsertSessionTextTailCacheEvent(db, serverId, { + sessionId: sessionName, + eventId: 'u-1', + ts: 90, + type: 'user.message', + payload: { text: 'older user' }, + }); + + const cached = await getSessionTextTailCache(db, serverId, sessionName); + expect(cached).toEqual([ + { eventId: 'u-1', ts: 90, type: 'user.message', text: 'older user' }, + { eventId: 'dup-1', ts: 110, type: 'assistant.text', text: 'final value', confidence: 'high' }, + ]); + }); + + it('treats malformed existing cache payloads as empty and rebuilds safely', async () => { + const sessionName = `deck_proj_malformed_${Math.random().toString(36).slice(2)}`; + await db.execute( + `INSERT INTO session_text_tail_cache (server_id, session_name, events, latest_ts, updated_at) + VALUES ($1, $2, $3::jsonb, $4, NOW())`, + [serverId, sessionName, JSON.stringify({ nope: true }), 1], + ); + + await upsertSessionTextTailCacheEvent(db, serverId, { + sessionId: sessionName, + eventId: 'rebuild-1', + ts: 200, + type: 'assistant.text', + payload: { text: 'rebuilt' }, + }); + + const cached = await getSessionTextTailCache(db, serverId, sessionName); + expect(cached).toEqual([ + { eventId: 'rebuild-1', ts: 200, type: 'assistant.text', text: 'rebuilt' }, + ]); + }); + + it('hard-retains only the newest 50 cached entries per session', async () => { + const sessionName = `deck_proj_limit_${Math.random().toString(36).slice(2)}`; + for (let i = 0; i < SESSION_TEXT_TAIL_CACHE_LIMIT + 5; i++) { + await upsertSessionTextTailCacheEvent(db, serverId, { + sessionId: sessionName, + eventId: `ev-${i}`, + ts: i, + type: i % 2 === 0 ? 'user.message' : 'assistant.text', + payload: { text: `msg-${i}` }, + }); + } + + const cached = await getSessionTextTailCache(db, serverId, sessionName); + expect(cached).toHaveLength(SESSION_TEXT_TAIL_CACHE_LIMIT); + expect(cached[0]?.eventId).toBe('ev-5'); + expect(cached.at(-1)?.eventId).toBe(`ev-${SESSION_TEXT_TAIL_CACHE_LIMIT + 4}`); + }); +}); + // ── 7. Quick data ──────────────────────────────────────────────────────────── describe('quick data', () => { diff --git a/server/test/watch-routes.test.ts b/server/test/watch-routes.test.ts index ef0fc9024..4c739d788 100644 --- a/server/test/watch-routes.test.ts +++ b/server/test/watch-routes.test.ts @@ -8,6 +8,7 @@ const mockGetServersByUserId = vi.fn(); const mockGetDbSessionsByServer = vi.fn(); const mockGetSubSessionsByServer = vi.fn(); const mockGetUserPref = vi.fn(); +const mockGetSessionTextTailCache = vi.fn(); const mockRequestTimelineHistory = vi.fn(); const mockGetRecentText = vi.fn(); const mockGetRecentTextForWatch = vi.fn(); @@ -30,6 +31,7 @@ vi.mock('../src/db/queries.js', () => ({ getDbSessionsByServer: (...args: unknown[]) => mockGetDbSessionsByServer(...args), getSubSessionsByServer: (...args: unknown[]) => mockGetSubSessionsByServer(...args), getUserPref: (...args: unknown[]) => mockGetUserPref(...args), + getSessionTextTailCache: (...args: unknown[]) => mockGetSessionTextTailCache(...args), getServerById: vi.fn(async () => ({ id: 'srv-1' })), })); @@ -91,6 +93,7 @@ describe('Watch routes', () => { mockGetDbSessionsByServer.mockResolvedValue([]); mockGetSubSessionsByServer.mockResolvedValue([]); mockGetUserPref.mockResolvedValue(null); + mockGetSessionTextTailCache.mockResolvedValue([]); mockGetRecentText.mockReturnValue([]); mockGetRecentTextForWatch.mockResolvedValue([]); mockGetActiveMainSessions.mockReturnValue([]); @@ -335,12 +338,56 @@ describe('Watch routes', () => { await expect(res.json()).resolves.toEqual({ error: 'daemon_offline' }); }); + it('GET /api/server/:id/timeline/text-tail returns cached entries', async () => { + mockGetSessionTextTailCache.mockResolvedValue([ + { eventId: 'e1', ts: 100, type: 'user.message', text: 'hi' }, + { eventId: 'e2', ts: 200, type: 'assistant.text', text: 'hello', source: 'daemon', confidence: 'high' }, + ]); + + const app = await buildTestApp(); + const res = await app.request('/api/server/srv-1/timeline/text-tail?sessionName=deck_proj_brain'); + + expect(res.status).toBe(200); + expect(res.headers.get(IMCODES_POD_HEADER)).toBe('pod-a'); + await expect(res.json()).resolves.toEqual({ + sessionName: 'deck_proj_brain', + events: [ + { eventId: 'e1', ts: 100, type: 'user.message', text: 'hi' }, + { eventId: 'e2', ts: 200, type: 'assistant.text', text: 'hello', source: 'daemon', confidence: 'high' }, + ], + }); + }); + + it('GET /api/server/:id/timeline/text-tail returns empty list when no cache exists', async () => { + mockGetSessionTextTailCache.mockResolvedValue([]); + + const app = await buildTestApp(); + const res = await app.request('/api/server/srv-1/timeline/text-tail?sessionName=deck_proj_brain'); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + sessionName: 'deck_proj_brain', + events: [], + }); + }); + + it('GET /api/server/:id/timeline/text-tail isolates cache read failures', async () => { + mockGetSessionTextTailCache.mockRejectedValue(new Error('db down')); + + const app = await buildTestApp(); + const res = await app.request('/api/server/srv-1/timeline/text-tail?sessionName=deck_proj_brain'); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: 'cache_read_failed' }); + }); + it('watch routes return 403 when the user has no access to the server', async () => { mockResolveServerRole.mockResolvedValue('none'); const app = await buildTestApp(); const sessionsRes = await app.request('/api/watch/sessions?serverId=srv-1'); const historyRes = await app.request('/api/server/srv-1/timeline/history?sessionName=deck_proj_brain'); + const tailRes = await app.request('/api/server/srv-1/timeline/text-tail?sessionName=deck_proj_brain'); const sendRes = await app.request('/api/server/srv-1/session/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -353,6 +400,9 @@ describe('Watch routes', () => { expect(historyRes.status).toBe(403); await expect(historyRes.json()).resolves.toEqual({ error: 'forbidden' }); + expect(tailRes.status).toBe(403); + await expect(tailRes.json()).resolves.toEqual({ error: 'forbidden' }); + expect(sendRes.status).toBe(403); await expect(sendRes.json()).resolves.toEqual({ error: 'forbidden', diff --git a/web/src/api.ts b/web/src/api.ts index 61edba2f2..ea1926d50 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -784,6 +784,45 @@ export async function fetchTimelineHistoryHttp( } } +export interface TimelineTextTailItem { + eventId: string; + ts: number; + type: 'user.message' | 'assistant.text'; + text: string; + source?: string; + confidence?: string; +} + +/** + * Fetch the PostgreSQL-backed recent text-tail cache for one session. + * + * This is a non-authoritative bootstrap path intended to surface the latest + * completed text messages quickly while the existing WS/full-history flow + * continues to reconcile authoritative state. + * + * Returns null (not throw) on expected transient failures so callers can fail + * open and continue with the normal timeline bootstrap. + */ +export async function fetchTimelineTextTailHttp( + serverId: string, + sessionName: string, +): Promise<{ events: TimelineTextTailItem[] } | null> { + const params = new URLSearchParams(); + params.set('sessionName', sessionName); + try { + const result = await apiFetch<{ sessionName: string; events: TimelineTextTailItem[] }>( + `/api/server/${encodeURIComponent(serverId)}/timeline/text-tail?${params.toString()}`, + { method: 'GET' }, + ); + return { + events: Array.isArray(result.events) ? result.events : [], + }; + } catch (err) { + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) throw err; + return null; + } +} + export async function deleteSubSession(serverId: string, subId: string): Promise { await apiFetch(`/api/server/${serverId}/sub-sessions/${subId}`, { method: 'DELETE' }); } diff --git a/web/src/hooks/useTimeline.ts b/web/src/hooks/useTimeline.ts index 46b0fb70b..98aec62d6 100644 --- a/web/src/hooks/useTimeline.ts +++ b/web/src/hooks/useTimeline.ts @@ -28,9 +28,10 @@ function localizedAckFailureReason(reason: AckFailureReason): string { import { useEffect, useRef, useState, useCallback } from 'preact/hooks'; import type { WsClient, TimelineEvent, ServerMessage } from '../ws-client.js'; +import type { TimelineConfidence, TimelineSource } from '../../../src/shared/timeline/types.js'; import { TimelineDB } from '../timeline-db.js'; import { mergeTimelineEvents, preferTimelineEvent } from '../../../src/shared/timeline/merge.js'; -import { fetchTimelineHistoryHttp } from '../api.js'; +import { fetchTimelineHistoryHttp, fetchTimelineTextTailHttp, type TimelineTextTailItem } from '../api.js'; // Singleton DB shared across all useTimeline instances — opened once at module load. // This avoids per-hook open() latency and ensures the DB is ready before any hook queries it. @@ -366,6 +367,30 @@ function getTimelineHistoryAfterTs(events: TimelineEvent[]): number | undefined return Math.max(0, maxTs - TIMELINE_HISTORY_AFTER_TS_OVERLAP_MS); } +function timelineEventFromTextTailItem(sessionId: string, item: TimelineTextTailItem): TimelineEvent | null { + if (typeof item.eventId !== 'string' || !item.eventId) return null; + if (typeof item.ts !== 'number' || !Number.isFinite(item.ts)) return null; + if (item.type !== 'user.message' && item.type !== 'assistant.text') return null; + if (typeof item.text !== 'string' || item.text.trim().length === 0) return null; + const source: TimelineSource = item.source === 'hook' || item.source === 'terminal-parse' || item.source === 'terminal-spinner' + ? item.source + : 'daemon'; + const confidence: TimelineConfidence = item.confidence === 'medium' || item.confidence === 'low' + ? item.confidence + : 'high'; + return { + eventId: item.eventId, + sessionId, + ts: item.ts, + epoch: 0, + seq: 0, + source, + confidence, + type: item.type, + payload: { text: item.text }, + }; +} + export function __getTimelineCacheKeysForTests(): string[] { return [...eventsCache.keys()]; } @@ -467,12 +492,31 @@ export function useTimeline( setHasOlderHistory(true); let cancelled = false; + let textTailStarted = false; + + const startTextTailBootstrap = (): void => { + if (textTailStarted || !serverId || !sessionId || !cacheKey) return; + textTailStarted = true; + const expectedCacheKey = cacheKey; + const expectedSessionId = sessionId; + void fetchTimelineTextTailHttp(serverId, expectedSessionId) + .then((result) => { + if (cancelled || cacheKeyRef.current !== expectedCacheKey || !result || result.events.length === 0) return; + const recovered = result.events + .map((item) => timelineEventFromTextTailItem(expectedSessionId, item)) + .filter((event): event is TimelineEvent => event !== null); + if (recovered.length === 0) return; + mergeEvents(recovered); + }) + .catch(() => { /* fail-open: authoritative history flow continues */ }); + }; // 1. Module-level memory cache — instant restore (e.g. window reopen) const memCached = getCachedEvents(cacheKey!); if (memCached && memCached.length > 0) { setEvents(memCached); setLoading(false); + startTextTailBootstrap(); if (wsConnected) { setRefreshing(true); historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, MAX_MEMORY_EVENTS); @@ -493,6 +537,7 @@ export function useTimeline( setCachedEvents(cacheKey!, localSnapshot); setEvents((prev) => (prev === localSnapshot ? prev : localSnapshot)); setLoading(false); + startTextTailBootstrap(); if (wsConnected) { setRefreshing(true); historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, MAX_MEMORY_EVENTS); @@ -503,6 +548,7 @@ export function useTimeline( // 2. Already loaded this session — skip reload (prevents flash-of-empty on minimize/restore) if (historyLoadedRef.current === cacheKey) { setLoading(false); + startTextTailBootstrap(); // Just request incremental updates if (wsConnected) { setRefreshing(true); @@ -535,6 +581,7 @@ export function useTimeline( setEvents((prev) => (prev === restored ? prev : restored)); setLoading(false); historyLoadedRef.current = cacheKeyRef.current; + startTextTailBootstrap(); if (wsConnected) { setRefreshing(true); historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, MAX_MEMORY_EVENTS); @@ -548,6 +595,7 @@ export function useTimeline( seqRef.current = 0; if (cancelled) return; setEvents([]); + startTextTailBootstrap(); if (wsConnected) { historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId); } else { diff --git a/web/test/app-resume-refresh.test.tsx b/web/test/app-resume-refresh.test.tsx index 607c73323..b38c27be2 100644 --- a/web/test/app-resume-refresh.test.tsx +++ b/web/test/app-resume-refresh.test.tsx @@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const fetchSpy = vi.hoisted(() => vi.fn()); -vi.mock('../src/api.js', () => ({ fetchTimelineHistoryHttp: fetchSpy })); +const fetchTextTailSpy = vi.hoisted(() => vi.fn()); +vi.mock('../src/api.js', () => ({ + fetchTimelineHistoryHttp: fetchSpy, + fetchTimelineTextTailHttp: fetchTextTailSpy, +})); import { act, cleanup, render, screen, waitFor } from '@testing-library/preact'; import { h } from 'preact'; @@ -21,6 +25,8 @@ describe('native app resume refresh chain', () => { __resetTimelineCacheForTests(); cleanup(); fetchSpy.mockReset(); + fetchTextTailSpy.mockReset(); + fetchTextTailSpy.mockResolvedValue(null); }); afterEach(() => { diff --git a/web/test/use-timeline-cache.test.ts b/web/test/use-timeline-cache.test.ts index c2dc2a450..a12d8d942 100644 --- a/web/test/use-timeline-cache.test.ts +++ b/web/test/use-timeline-cache.test.ts @@ -7,6 +7,12 @@ import { h } from 'preact'; import type { ServerMessage, TimelineEvent, WsClient } from '../src/ws-client.js'; import { TimelineDB } from '../src/timeline-db.js'; import { mergeTimelineEvents } from '../../src/shared/timeline/merge.js'; +const fetchHistorySpy = vi.hoisted(() => vi.fn()); +const fetchTextTailSpy = vi.hoisted(() => vi.fn()); +vi.mock('../src/api.js', () => ({ + fetchTimelineHistoryHttp: fetchHistorySpy, + fetchTimelineTextTailHttp: fetchTextTailSpy, +})); import { __clearPersistedTimelineSnapshotsForTests, __getTimelineCacheKeysForTests, @@ -36,6 +42,10 @@ describe('useTimeline global cache bounds', () => { __resetTimelineCacheForTests(); __clearPersistedTimelineSnapshotsForTests(); cleanup(); + fetchHistorySpy.mockReset(); + fetchTextTailSpy.mockReset(); + fetchHistorySpy.mockResolvedValue(null); + fetchTextTailSpy.mockResolvedValue(null); }); afterEach(() => { @@ -337,6 +347,84 @@ describe('useTimeline global cache bounds', () => { }); }); + it('bootstraps from local snapshot, then PG text tail, before later authoritative reconciliation', async () => { + const sessionName = `deck_text_tail_bootstrap_${Date.now()}`; + const serverId = `srv-${Date.now()}`; + let handler: ((msg: ServerMessage) => void) | null = null; + + ingestTimelineEventForCache({ + eventId: `${sessionName}-snap-1`, + sessionId: sessionName, + ts: 10, + epoch: 1, + seq: 1, + source: 'daemon', + confidence: 'high', + type: 'assistant.text', + payload: { text: 'snapshot history' }, + }, serverId); + + fetchTextTailSpy.mockResolvedValue({ + events: [{ + eventId: `${sessionName}-tail-1`, + ts: 20, + type: 'assistant.text', + text: 'pg tail text', + source: 'daemon', + confidence: 'high', + }], + }); + + const ws: WsClient = { + connected: true, + onMessage: (next: (msg: ServerMessage) => void) => { + handler = next; + return () => { handler = null; }; + }, + sendTimelineHistoryRequest: () => 'history-text-tail', + } as unknown as WsClient; + + function Probe() { + const { events } = useTimeline(sessionName, ws, serverId); + return h('div', { 'data-testid': 'probe' }, events.map((event) => String(event.payload.text ?? '')).join('|')); + } + + render(h(Probe)); + + await waitFor(() => { + expect(screen.getByTestId('probe').textContent).toBe('snapshot history'); + }); + + await waitFor(() => { + expect(fetchTextTailSpy).toHaveBeenCalledWith(serverId, sessionName); + expect(screen.getByTestId('probe').textContent).toBe('snapshot history|pg tail text'); + }); + + await act(async () => { + handler?.({ + type: 'timeline.history', + sessionName, + requestId: 'history-text-tail', + epoch: 2, + events: [{ + eventId: `${sessionName}-auth-1`, + sessionId: sessionName, + ts: 30, + epoch: 2, + seq: 3, + source: 'daemon', + confidence: 'high', + type: 'assistant.text', + payload: { text: 'authoritative text' }, + }], + } as ServerMessage); + }); + + await waitFor(() => { + expect(screen.getByTestId('probe').textContent).toBe('snapshot history|pg tail text|authoritative text'); + }); + }); + it('requests timeline history when the socket connects after the first mount', async () => { const sessionName = `deck_late_connect_${Date.now()}`; const serverId = `srv-${Date.now()}`; @@ -482,6 +570,116 @@ describe('useTimeline global cache bounds', () => { }); }); + it('merges PG text tail by eventId without regressing newer local entries', async () => { + const sessionName = `deck_text_tail_merge_${Date.now()}`; + const serverId = `srv-${Date.now()}`; + + ingestTimelineEventForCache({ + eventId: `${sessionName}-same`, + sessionId: sessionName, + ts: 50, + epoch: 9, + seq: 9, + source: 'daemon', + confidence: 'high', + type: 'assistant.text', + payload: { text: 'newer local copy', extra: 'keep-me' }, + }, serverId); + + fetchTextTailSpy.mockResolvedValue({ + events: [{ + eventId: `${sessionName}-same`, + ts: 50, + type: 'assistant.text', + text: 'older tail copy', + source: 'daemon', + confidence: 'high', + }, { + eventId: `${sessionName}-tail-new`, + ts: 60, + type: 'user.message', + text: 'new tail entry', + }], + }); + + function Probe() { + const { events } = useTimeline(sessionName, null, serverId); + return h( + 'div', + { 'data-testid': 'probe' }, + events.map((event) => String(event.payload.text ?? '')).join('|'), + ); + } + + render(h(Probe)); + + await waitFor(() => { + expect(fetchTextTailSpy).toHaveBeenCalledWith(serverId, sessionName); + expect(screen.getByTestId('probe').textContent).toBe('newer local copy|new tail entry'); + }); + }); + + it('fails open when the text-tail endpoint fails and continues with the existing timeline bootstrap', async () => { + const sessionName = `deck_text_tail_fail_open_${Date.now()}`; + const serverId = `srv-${Date.now()}`; + let handler: ((msg: ServerMessage) => void) | null = null; + + fetchTextTailSpy.mockResolvedValue(null); + + const ws: WsClient = { + connected: true, + onMessage: (next: (msg: ServerMessage) => void) => { + handler = next; + return () => { handler = null; }; + }, + sendTimelineHistoryRequest: () => 'history-fail-open', + } as unknown as WsClient; + + function Probe() { + const { events, loading } = useTimeline(sessionName, ws, serverId); + return h( + 'div', + { + 'data-testid': 'probe', + 'data-loading': String(loading), + }, + events.map((event) => String(event.payload.text ?? '')).join('|'), + ); + } + + render(h(Probe)); + + await waitFor(() => { + expect(fetchTextTailSpy).toHaveBeenCalledWith(serverId, sessionName); + expect(screen.getByTestId('probe').getAttribute('data-loading')).toBe('true'); + }); + + await act(async () => { + handler?.({ + type: 'timeline.history', + sessionName, + requestId: 'history-fail-open', + epoch: 1, + events: [{ + eventId: `${sessionName}-auth`, + sessionId: sessionName, + ts: 1, + epoch: 1, + seq: 1, + source: 'daemon', + confidence: 'high', + type: 'assistant.text', + payload: { text: 'authoritative fallback' }, + }], + } as ServerMessage); + }); + + await waitFor(() => { + expect(screen.getByTestId('probe').textContent).toBe('authoritative fallback'); + expect(screen.getByTestId('probe').getAttribute('data-loading')).toBe('false'); + }); + }); + it('does not dedup confirmed user messages marked allowDuplicate', async () => { const sessionName = `deck_transport_dup_${Date.now()}`; const serverId = `srv-${Date.now()}`; diff --git a/web/test/use-timeline-http-backfill.test.ts b/web/test/use-timeline-http-backfill.test.ts index 08f947a29..455066e27 100644 --- a/web/test/use-timeline-http-backfill.test.ts +++ b/web/test/use-timeline-http-backfill.test.ts @@ -12,7 +12,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Hoisted mock: must run before useTimeline is imported so the hook picks up // our spy rather than the real apiFetch wrapper. const fetchSpy = vi.hoisted(() => vi.fn()); -vi.mock('../src/api.js', () => ({ fetchTimelineHistoryHttp: fetchSpy })); +const fetchTextTailSpy = vi.hoisted(() => vi.fn()); +vi.mock('../src/api.js', () => ({ + fetchTimelineHistoryHttp: fetchSpy, + fetchTimelineTextTailHttp: fetchTextTailSpy, +})); import { render, screen, cleanup, act, waitFor } from '@testing-library/preact'; import { h } from 'preact'; @@ -30,6 +34,8 @@ describe('useTimeline — HTTP backfill on WS reconnect', () => { __resetTimelineCacheForTests(); cleanup(); fetchSpy.mockReset(); + fetchTextTailSpy.mockReset(); + fetchTextTailSpy.mockResolvedValue(null); }); afterEach(() => { vi.useRealTimers(); From f805efcb8035a9957fbe2e27aadb01c8801e771e Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 16:40:22 +0800 Subject: [PATCH 20/54] fix: guard preset updates in session dialogs --- web/src/components/NewSessionDialog.tsx | 3 ++- web/src/components/StartSubSessionDialog.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/components/NewSessionDialog.tsx b/web/src/components/NewSessionDialog.tsx index 80eab1de9..06f52ba65 100644 --- a/web/src/components/NewSessionDialog.tsx +++ b/web/src/components/NewSessionDialog.tsx @@ -30,6 +30,7 @@ import { type CcPresetEntry, } from "./cc-preset-form.js"; import { CC_PRESET_MSG } from "@shared/cc-presets.js"; +import type { CcPreset } from "@shared/cc-presets.js"; const DEFAULT_SHELL_KEY = "default_shell"; // Fallback suggestions used only when the daemon probe returns an empty list @@ -190,7 +191,7 @@ export function NewSessionDialog({ setCcPresets((current) => [ ...current.filter((preset) => preset.name !== msg.preset?.name), msg.preset, - ]); + ].filter((preset): preset is CcPreset => preset !== undefined)); if (newPresetName.trim().toLowerCase() === msg.preset.name.trim().toLowerCase()) { applyPresetDraft(createCcPresetDraftFromPreset(msg.preset)); } diff --git a/web/src/components/StartSubSessionDialog.tsx b/web/src/components/StartSubSessionDialog.tsx index 2556a5261..e386e2ac0 100644 --- a/web/src/components/StartSubSessionDialog.tsx +++ b/web/src/components/StartSubSessionDialog.tsx @@ -17,7 +17,7 @@ import { type CcPresetEntry, type CcPresetDraft, } from './cc-preset-form.js'; -import { CC_PRESET_MSG } from '@shared/cc-presets.js'; +import { CC_PRESET_MSG, type CcPreset } from '@shared/cc-presets.js'; const CURSOR_HEADLESS_MODEL_SUGGESTIONS = ['gpt-5.2'] as const; const COPILOT_SDK_MODEL_SUGGESTIONS = ['gpt-5.4', 'gpt-5.4-mini'] as const; @@ -138,7 +138,7 @@ export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _is setCcPresets((current) => [ ...current.filter((preset) => preset.name !== msg.preset?.name), msg.preset, - ]); + ].filter((preset): preset is CcPreset => preset !== undefined)); if (newPresetName.trim().toLowerCase() === msg.preset.name.trim().toLowerCase()) { applyPresetDraft(createCcPresetDraftFromPreset(msg.preset)); } From 50413e46a914cb40b867e817349e7e061debf488 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Wed, 22 Apr 2026 16:57:33 +0800 Subject: [PATCH 21/54] Align daemon badge with server heartbeat --- web/src/app.tsx | 20 ++++++----- web/src/components/ServerIconBar.tsx | 3 +- web/src/server-selection.ts | 26 ++++++++++++++ web/test/server-selection.test.ts | 52 ++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/web/src/app.tsx b/web/src/app.tsx index bddc550bd..b40620a9c 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -92,8 +92,10 @@ import { pickReadableSessionDisplay } from '@shared/session-display.js'; import { updateMainSessionLabel } from './session-label-api.js'; import { buildDocumentTitle } from './tab-title.js'; import { + getDaemonBadgeState, getSelectedServerName, hasResolvedActiveSession, + isServerOnline, shouldResetSelectedServer, shouldShowInitialConnectingGate, } from './server-selection.js'; @@ -166,12 +168,6 @@ interface ServerInfo { createdAt: number; } -function isServerOnline(s: ServerInfo): boolean { - if (s.status === 'offline') return false; - if (!s.lastHeartbeatAt) return false; - return Date.now() - s.lastHeartbeatAt < 60_000; // 60s — heartbeat is 5s, allow for network jitter -} - export function App() { const { t: trans } = useTranslation(); const [auth, setAuth] = useState(() => { @@ -2582,6 +2578,10 @@ export function App() { sessionsLoaded, ); const resolvedActiveSessionExists = hasResolvedActiveSession(activeSession, sessions); + const selectedServerInfo = selectedServerId + ? servers.find((server) => server.id === selectedServerId) ?? null + : null; + const daemonBadgeState = getDaemonBadgeState(connected, connecting, daemonOnline, selectedServerInfo); useEffect(() => { if (showInitialConnectingGate) { @@ -2884,8 +2884,12 @@ export function App() { )}
- - {connected ? (daemonOnline ? '● Online' : (<>{' Daemon Offline'})) : connecting ? (<>{' Connecting'}) : '○ Offline'} + + {daemonBadgeState === 'online' + ? '● Online' + : daemonBadgeState === 'connecting' + ? (<>{' Connecting'}) + : (<>{' Daemon Offline'})} {(() => { try { const d = new Date(__BUILD_TIME__); return `v${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`; } catch { return ''; } })()} diff --git a/web/src/components/ServerIconBar.tsx b/web/src/components/ServerIconBar.tsx index d0b2287d0..4dc6b1ca3 100644 --- a/web/src/components/ServerIconBar.tsx +++ b/web/src/components/ServerIconBar.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { isServerOnline } from '../server-selection.js'; interface ServerInfo { id: string; @@ -42,7 +43,7 @@ export function ServerIconBar({ servers, activeServerId, onSelectServer, onServe )} {servers.map((server) => { const isActive = server.id === activeServerId; - const isOnline = server.status !== 'offline' && server.lastHeartbeatAt != null && Date.now() - server.lastHeartbeatAt < 60_000; + const isOnline = isServerOnline(server); return (
{/* Usage footer — shared component */} - {(lastUsage || activeThinkingTs || activeToolCall || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel || sessionInfo?.quotaMeta) && ( + {(lastUsage || historyStatus.phase !== 'idle' || activeThinkingTs || activeToolCall || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel || sessionInfo?.quotaMeta) && ( )} diff --git a/web/src/components/UsageFooter.tsx b/web/src/components/UsageFooter.tsx index a36fa2342..acbfd3e6d 100644 --- a/web/src/components/UsageFooter.tsx +++ b/web/src/components/UsageFooter.tsx @@ -9,6 +9,7 @@ import { shortModelLabel } from '../model-label.js'; import { getSessionCost, getWeeklyCost, getMonthlyCost, formatCost } from '../cost-tracker.js'; import type { UsageData } from '../usage-data.js'; import { formatProviderQuotaLabel, type ProviderQuotaMeta } from '@shared/provider-quota.js'; +import type { TimelineHistoryStatus, TimelineHistoryStepKey } from '../hooks/useTimeline.js'; interface Props { usage: UsageData; @@ -30,6 +31,8 @@ interface Props { activeToolCall?: boolean; /** Current timestamp for thinking timer (updated every second). */ now?: number; + /** Visible history-fetch progress beneath the ctx bar while waiting for history. */ + historyStatus?: TimelineHistoryStatus | null; } const fmt = (n: number) => @@ -37,7 +40,7 @@ const fmt = (n: number) => : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n); -export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, activeToolCall, now }: Props) { +export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, activeToolCall, now, historyStatus }: Props) { const { t } = useTranslation(); const isCodexFamily = agentType === 'codex' || agentType === 'codex-sdk'; const hasActiveLiveWork = !!activeToolCall || !!activeThinkingTs; @@ -119,6 +122,26 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model const codexQuotaLines = (agentType === 'codex' || agentType === 'codex-sdk') ? (displayQuotaLabel ?? '').split(' · ').filter(Boolean) : []; + const historySteps = useMemo(() => { + if (!historyStatus || historyStatus.phase === 'idle') return []; + const order: TimelineHistoryStepKey[] = ['cache', 'textTail', 'daemon', 'http', 'older']; + return order + .map((key) => ({ key, state: historyStatus.steps[key] })) + .filter((step) => step.state !== 'skipped') + .map((step) => ({ + ...step, + label: step.key === 'cache' + ? t('session.history_step_cache') + : step.key === 'textTail' + ? t('session.history_step_text_tail') + : step.key === 'daemon' + ? t('session.history_step_daemon') + : step.key === 'http' + ? t('session.history_step_http') + : t('session.history_step_older'), + })); + }, [historyStatus, t]); + const showHistoryProgress = historySteps.some((step) => step.state === 'pending' || step.state === 'running'); return (