From 4f322f6571767cd695a879fbaaee8f7672d0d268 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Thu, 9 Apr 2026 23:28:12 +0800 Subject: [PATCH 01/29] Fix main session rename to edit labels --- server/test/session-mgmt-routes.test.ts | 18 ++++++ test/daemon/command-handler-stop.test.ts | 47 +++++++++++++++ web/src/app.tsx | 15 ++--- web/src/components/SessionTabs.tsx | 8 +-- web/test/components/SessionTabs.test.tsx | 74 ++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 11 deletions(-) diff --git a/server/test/session-mgmt-routes.test.ts b/server/test/session-mgmt-routes.test.ts index 9daa8cb40..773df648e 100644 --- a/server/test/session-mgmt-routes.test.ts +++ b/server/test/session-mgmt-routes.test.ts @@ -198,4 +198,22 @@ describe('session-mgmt persistence routes', () => { label: 'Main Label', }); }); + + it('PATCH /sessions/:name/label allows clearing the label and still relays session.relabel', async () => { + const { updateSessionLabel } = await import('../src/db/queries.js'); + const app = await buildApp(); + const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain/label', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: '' }), + }); + + expect(res.status).toBe(200); + expect(updateSessionLabel).toHaveBeenCalledWith({}, 'srv-1', 'deck_proj_brain', null); + expect(JSON.parse(String(sendToDaemonMock.mock.calls[0]?.[0]))).toEqual({ + type: 'session.relabel', + sessionName: 'deck_proj_brain', + label: null, + }); + }); }); diff --git a/test/daemon/command-handler-stop.test.ts b/test/daemon/command-handler-stop.test.ts index f0f2069ed..71f8f253e 100644 --- a/test/daemon/command-handler-stop.test.ts +++ b/test/daemon/command-handler-stop.test.ts @@ -265,6 +265,53 @@ describe('handleWebCommand shutdown failure paths', () => { }); }); + it('clears the main-session label and pushes a refreshed session_list on session.relabel', async () => { + const { getSession, upsertSession } = await import('../../src/store/session-store.js'); + vi.mocked(getSession).mockReturnValue({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + label: 'Main Label', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + projectDir: '/tmp/proj', + } as any); + buildSessionListMock.mockResolvedValueOnce([ + { + name: 'deck_proj_brain', + project: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + }, + ]); + + handleWebCommand({ type: 'session.relabel', sessionName: 'deck_proj_brain', label: null }, serverLink as any); + await flushAsync(); + + expect(upsertSession).toHaveBeenCalledWith(expect.objectContaining({ + name: 'deck_proj_brain', + label: undefined, + })); + expect(serverLink.send).toHaveBeenCalledWith({ + type: 'session_list', + daemonVersion: '0.1.0', + sessions: [ + { + name: 'deck_proj_brain', + project: 'proj', + role: 'brain', + agentType: 'codex', + state: 'idle', + }, + ], + }); + }); + it('updates the sub-session label and emits subsession.sync on subsession.rename', async () => { const { getSession, upsertSession } = await import('../../src/store/session-store.js'); vi.mocked(getSession).mockReturnValue({ diff --git a/web/src/app.tsx b/web/src/app.tsx index 128b1791f..80a963cec 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -546,15 +546,16 @@ export function App() { return () => clearInterval(id); }, [auth, loadServers]); - // Rename = update project_name in D1 + local sessions state - const handleRenameSession = useCallback(async (sessionName: string, newProjectName: string) => { - if (!selectedServerId || !newProjectName) return; - // Optimistic update - setSessions((prev) => prev.map((s) => s.name === sessionName ? { ...s, project: newProjectName } : s)); + // Rename = update main-session label in D1 + local sessions state + const handleRenameSession = useCallback(async (sessionName: string, nextLabel: string | null) => { + if (!selectedServerId) return; + setSessions((prev) => prev.map((s) => ( + s.name === sessionName ? { ...s, label: nextLabel } : s + ))); try { - await apiFetch(`/api/server/${selectedServerId}/sessions/${encodeURIComponent(sessionName)}/rename`, { + await apiFetch(`/api/server/${selectedServerId}/sessions/${encodeURIComponent(sessionName)}/label`, { method: 'PATCH', - body: JSON.stringify({ name: newProjectName }), + body: JSON.stringify({ label: nextLabel }), }); } catch { /* best-effort */ } }, [selectedServerId]); diff --git a/web/src/components/SessionTabs.tsx b/web/src/components/SessionTabs.tsx index bbf20c7fd..3a693981b 100644 --- a/web/src/components/SessionTabs.tsx +++ b/web/src/components/SessionTabs.tsx @@ -21,8 +21,8 @@ interface Props { /** When set to a session name, triggers inline rename */ renameRequest?: string | null; onRenameHandled?: () => void; - /** Called when user commits a rename — updates project_name in D1 */ - onRenameSession?: (sessionName: string, newProjectName: string) => void; + /** Called when user commits a rename — updates session label in D1 */ + onRenameSession?: (sessionName: string, nextLabel: string | null) => void; /** True once sessions have been loaded (from API or WS) */ sessionsLoaded?: boolean; } @@ -149,14 +149,14 @@ export function SessionTabs({ sessions, activeSession, connected, latencyMs, idl const startRename = (s: SessionInfo) => { setCtx(null); - setRenameVal(s.project); + setRenameVal(s.label ?? ''); setRenaming(s.name); }; const commitRename = () => { if (!renaming) return; const trimmed = renameVal.trim(); - if (trimmed) onRenameSession?.(renaming, trimmed); + onRenameSession?.(renaming, trimmed || null); setRenaming(null); }; diff --git a/web/test/components/SessionTabs.test.tsx b/web/test/components/SessionTabs.test.tsx index cf8a19ee1..80c2e2a98 100644 --- a/web/test/components/SessionTabs.test.tsx +++ b/web/test/components/SessionTabs.test.tsx @@ -11,6 +11,14 @@ vi.mock('react-i18next', () => ({ }), })); +const getUserPrefMock = vi.fn().mockResolvedValue(null); +const saveUserPrefMock = vi.fn().mockResolvedValue(undefined); + +vi.mock('../../src/api.js', () => ({ + getUserPref: (...args: unknown[]) => getUserPrefMock(...args), + saveUserPref: (...args: unknown[]) => saveUserPrefMock(...args), +})); + import { SessionTabs } from '../../src/components/SessionTabs.js'; import type { SessionInfo } from '../../src/types.js'; @@ -33,6 +41,8 @@ const defaultProps = { describe('SessionTabs', () => { beforeEach(() => { + getUserPrefMock.mockResolvedValue(null); + saveUserPrefMock.mockResolvedValue(undefined); // Ensure localStorage is available (jsdom may provide a broken stub) if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.setItem !== 'function') { const store: Record = {}; @@ -164,4 +174,68 @@ describe('SessionTabs', () => { expect(onStopProject).toHaveBeenCalledOnce(); expect(onStopProject).toHaveBeenCalledWith('proj-1'); }); + + it('uses the current label as the rename input value and commits a label update', () => { + const onRenameSession = vi.fn(); + const sessions: SessionInfo[] = [{ + name: 'deck_proj_brain', + project: 'my-project', + role: 'brain', + agentType: 'brain', + state: 'idle', + label: 'Main Label', + }]; + + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe('Main Label'); + + fireEvent.input(input, { target: { value: 'Readable Main' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenameSession).toHaveBeenCalledWith('deck_proj_brain', 'Readable Main'); + }); + + it('allows clearing the label so the session falls back to the project name', () => { + const onRenameSession = vi.fn(); + const sessions: SessionInfo[] = [{ + name: 'deck_proj_brain', + project: 'my-project', + role: 'brain', + agentType: 'brain', + state: 'idle', + label: 'Main Label', + }]; + + render( + , + ); + + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.input(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(onRenameSession).toHaveBeenCalledWith('deck_proj_brain', null); + }); }); From 86d2559d5b83ca11ae0dcb5471f8d08ec783749a Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Thu, 9 Apr 2026 23:51:28 +0800 Subject: [PATCH 02/29] Persist main session labels across reloads --- src/daemon/lifecycle.ts | 24 ++------------ src/daemon/session-bootstrap.ts | 42 ++++++++++++++++++++++++ test/daemon/session-bootstrap.test.ts | 46 +++++++++++++++++++++++++++ web/src/app.tsx | 31 ++++++++++-------- web/src/session-label-api.ts | 13 ++++++++ web/test/session-label-api.test.ts | 43 +++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 36 deletions(-) create mode 100644 src/daemon/session-bootstrap.ts create mode 100644 test/daemon/session-bootstrap.test.ts create mode 100644 web/src/session-label-api.ts create mode 100644 web/test/session-label-api.test.ts diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index d05057076..f32c1c615 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -27,6 +27,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { P2P_TERMINAL_RUN_STATUSES } from '../../shared/p2p-status.js'; import { pickReadableSessionDisplay } from '../../shared/session-display.js'; +import { mergeWorkerSessionSnapshot } from './session-bootstrap.js'; /** Get the last assistant.text from a session's timeline (for push notification context). */ function getLastAssistantText(sessionName: string): string | undefined { @@ -211,28 +212,7 @@ async function syncSessionsFromWorker(workerUrl: string, serverId: string, token for (const s of data.sessions) { if (s.state === 'stopped') continue; // skip stopped sessions const existing = getSession(s.name); - // Merge with existing local record to preserve fields not stored in server DB - // (ccSessionId, codexSessionId, geminiSessionId, restarts, etc.) - upsertSession({ - ...(existing ?? {}), - name: s.name, - projectName: s.project_name, - role: s.role as 'brain' | `w${number}`, - agentType: s.agent_type, - projectDir: s.project_dir, - state: s.state as import('../store/session-store.js').SessionState, - requestedModel: s.requested_model ?? existing?.requestedModel, - activeModel: s.active_model ?? existing?.activeModel, - modelDisplay: s.active_model ?? existing?.modelDisplay, - effort: s.effort ?? existing?.effort, - transportConfig: (typeof s.transport_config === 'string' - ? JSON.parse(s.transport_config) - : (s.transport_config ?? existing?.transportConfig)) as Record | undefined, - restarts: existing?.restarts ?? 0, - restartTimestamps: existing?.restartTimestamps ?? [], - createdAt: existing?.createdAt ?? Date.now(), - updatedAt: Date.now(), - }); + upsertSession(mergeWorkerSessionSnapshot(existing, s)); count++; } logger.info({ count }, 'Sessions synced from D1'); diff --git a/src/daemon/session-bootstrap.ts b/src/daemon/session-bootstrap.ts new file mode 100644 index 000000000..3541795e6 --- /dev/null +++ b/src/daemon/session-bootstrap.ts @@ -0,0 +1,42 @@ +import type { SessionRecord } from '../store/session-store.js'; + +export interface WorkerSessionSnapshot { + name: string; + project_name: string; + role: string; + agent_type: string; + project_dir: string; + state: string; + label?: string | null; + requested_model?: string | null; + active_model?: string | null; + effort?: SessionRecord['effort'] | null; + transport_config?: Record | string | null; +} + +export function mergeWorkerSessionSnapshot( + existing: SessionRecord | undefined, + snapshot: WorkerSessionSnapshot, +): SessionRecord { + return { + ...(existing ?? {}), + name: snapshot.name, + projectName: snapshot.project_name, + role: snapshot.role as 'brain' | `w${number}`, + agentType: snapshot.agent_type, + projectDir: snapshot.project_dir, + state: snapshot.state as SessionRecord['state'], + label: snapshot.label ?? undefined, + requestedModel: snapshot.requested_model ?? existing?.requestedModel, + activeModel: snapshot.active_model ?? existing?.activeModel, + modelDisplay: snapshot.active_model ?? existing?.modelDisplay, + effort: snapshot.effort ?? existing?.effort, + transportConfig: (typeof snapshot.transport_config === 'string' + ? JSON.parse(snapshot.transport_config) + : (snapshot.transport_config ?? existing?.transportConfig)) as Record | undefined, + restarts: existing?.restarts ?? 0, + restartTimestamps: existing?.restartTimestamps ?? [], + createdAt: existing?.createdAt ?? Date.now(), + updatedAt: Date.now(), + }; +} diff --git a/test/daemon/session-bootstrap.test.ts b/test/daemon/session-bootstrap.test.ts new file mode 100644 index 000000000..15fd1f79a --- /dev/null +++ b/test/daemon/session-bootstrap.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeWorkerSessionSnapshot } from '../../src/daemon/session-bootstrap.js'; + +describe('mergeWorkerSessionSnapshot', () => { + it('hydrates the persisted main-session label from the worker snapshot', () => { + const merged = mergeWorkerSessionSnapshot(undefined, { + name: 'deck_proj_brain', + project_name: 'proj', + role: 'brain', + agent_type: 'codex', + project_dir: '/tmp/proj', + state: 'idle', + label: 'Readable Main', + }); + + expect(merged.label).toBe('Readable Main'); + expect(merged.projectName).toBe('proj'); + }); + + it('clears a stale local label when the persisted worker snapshot has no label', () => { + const merged = mergeWorkerSessionSnapshot({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + projectDir: '/tmp/proj', + state: 'idle', + label: 'Stale Label', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + }, { + name: 'deck_proj_brain', + project_name: 'proj', + role: 'brain', + agent_type: 'codex', + project_dir: '/tmp/proj', + state: 'idle', + label: null, + }); + + expect(merged.label).toBeUndefined(); + }); +}); diff --git a/web/src/app.tsx b/web/src/app.tsx index 80a963cec..43f311a37 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -64,6 +64,7 @@ import { extractTransportPendingMessages } from './transport-queue.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'; import { getSelectedServerName, shouldResetSelectedServer, @@ -546,20 +547,6 @@ export function App() { return () => clearInterval(id); }, [auth, loadServers]); - // Rename = update main-session label in D1 + local sessions state - const handleRenameSession = useCallback(async (sessionName: string, nextLabel: string | null) => { - if (!selectedServerId) return; - setSessions((prev) => prev.map((s) => ( - s.name === sessionName ? { ...s, label: nextLabel } : s - ))); - try { - await apiFetch(`/api/server/${selectedServerId}/sessions/${encodeURIComponent(sessionName)}/label`, { - method: 'PATCH', - body: JSON.stringify({ label: nextLabel }), - }); - } catch { /* best-effort */ } - }, [selectedServerId]); - // Fetch sessions from DB immediately when auth + server are available useEffect(() => { if (!auth || !selectedServerId) return; @@ -629,6 +616,22 @@ export function App() { const lastImcodesActivityRef = useRef(Date.now()); const resubscribeTimersRef = useRef>>(new Set()); + // Rename = update main-session label in D1 + local sessions state + const handleRenameSession = useCallback(async (sessionName: string, nextLabel: string | null) => { + if (!selectedServerId) return; + const previousLabel = sessions.find((s) => s.name === sessionName)?.label ?? null; + setSessions((prev) => prev.map((s) => ( + s.name === sessionName ? { ...s, label: nextLabel } : s + ))); + try { + await updateMainSessionLabel(selectedServerId, sessionName, nextLabel); + } catch { + setSessions((prev) => prev.map((s) => ( + s.name === sessionName ? { ...s, label: previousLabel } : s + ))); + } + }, [selectedServerId, sessions]); + // IDs of currently-open (non-minimized) sub-session windows const [openSubIds, setOpenSubIds] = useState>(new Set()); diff --git a/web/src/session-label-api.ts b/web/src/session-label-api.ts new file mode 100644 index 000000000..86f21aa7a --- /dev/null +++ b/web/src/session-label-api.ts @@ -0,0 +1,13 @@ +import { apiFetch } from './api.js'; + +export async function updateMainSessionLabel( + serverId: string, + sessionName: string, + nextLabel: string | null, +): Promise { + await apiFetch(`/api/server/${serverId}/sessions/${encodeURIComponent(sessionName)}/label`, { + method: 'PATCH', + keepalive: true, + body: JSON.stringify({ label: nextLabel }), + }); +} diff --git a/web/test/session-label-api.test.ts b/web/test/session-label-api.test.ts new file mode 100644 index 000000000..1964fb7a9 --- /dev/null +++ b/web/test/session-label-api.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetchMock = vi.fn(); + +vi.mock('../src/api.js', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), +})); + +describe('updateMainSessionLabel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('persists a label update via the main-session label route with keepalive enabled', async () => { + const { updateMainSessionLabel } = await import('../src/session-label-api.js'); + + await updateMainSessionLabel('srv-1', 'deck_proj_brain', 'Readable Main'); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/server/srv-1/sessions/deck_proj_brain/label', + { + method: 'PATCH', + keepalive: true, + body: JSON.stringify({ label: 'Readable Main' }), + }, + ); + }); + + it('persists label clearing as null', async () => { + const { updateMainSessionLabel } = await import('../src/session-label-api.js'); + + await updateMainSessionLabel('srv-1', 'deck_proj_brain', null); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/server/srv-1/sessions/deck_proj_brain/label', + { + method: 'PATCH', + keepalive: true, + body: JSON.stringify({ label: null }), + }, + ); + }); +}); From 443eefaff4f3bbbebf08d58707aa295066167612 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Fri, 10 Apr 2026 00:13:41 +0800 Subject: [PATCH 03/29] Add Apple Watch to hero copy --- README.i18n/README.es.md | 2 +- README.i18n/README.ja.md | 2 +- README.i18n/README.ko.md | 2 +- README.i18n/README.ru.md | 2 +- README.i18n/README.zh-CN.md | 2 +- README.i18n/README.zh-TW.md | 2 +- README.md | 2 +- landing/index.html | 14 +++++++------- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.i18n/README.es.md b/README.i18n/README.es.md index 7d06322a9..cca5e361b 100644 --- a/README.i18n/README.es.md +++ b/README.i18n/README.es.md @@ -5,7 +5,7 @@ **La capa de mensajería para agentes.** -IM.codes es un mensajero especializado para agentes de programación con IA. Te permite seguir sesiones largas desde móvil o web, con acceso a terminal, navegación de archivos, vistas de Git, vista previa de localhost, notificaciones y flujos multiagente integrados. Funciona con [Claude Code](https://github.com/anthropics/claude-code), [Codex](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com) y [Qwen](https://github.com/QwenLM/qwen-agent). +IM.codes es un mensajero especializado para agentes de programación con IA. Te permite seguir sesiones largas desde iPhone, iPad, Apple Watch, móvil o web, con acceso a terminal, navegación de archivos, vistas de Git, vista previa de localhost, notificaciones y flujos multiagente integrados. Funciona con [Claude Code](https://github.com/anthropics/claude-code), [Codex](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com) y [Qwen](https://github.com/QwenLM/qwen-agent). > **Nota:** Este archivo es una traducción. **El README en inglés (`../README.md`) es la versión canónica.** Si hay alguna diferencia, prevalece la versión en inglés. diff --git a/README.i18n/README.ja.md b/README.i18n/README.ja.md index ddfff26ac..85ea11536 100644 --- a/README.i18n/README.ja.md +++ b/README.i18n/README.ja.md @@ -4,7 +4,7 @@ **AI エージェントのための IM。** -IM.codes は AI コーディングエージェント向けの専用メッセンジャーです。モバイルや Web から長時間動作する agent session にアクセスし、ターミナル、ファイル閲覧、Git 変更、localhost プレビュー、通知、マルチエージェント連携を扱えます。Claude Code、Codex、Gemini CLI、OpenClaw、Qwen に対応します。 +IM.codes は AI コーディングエージェント向けの専用メッセンジャーです。iPhone、iPad、Apple Watch、モバイルや Web から長時間動作する agent session にアクセスし、ターミナル、ファイル閲覧、Git 変更、localhost プレビュー、通知、マルチエージェント連携を扱えます。Claude Code、Codex、Gemini CLI、OpenClaw、Qwen に対応します。 > これは翻訳版です。**正式な内容は英語版 README(`../README.md`)です。** 差異がある場合は英語版を優先してください。 diff --git a/README.i18n/README.ko.md b/README.i18n/README.ko.md index 146c1ae0e..171c5a644 100644 --- a/README.i18n/README.ko.md +++ b/README.i18n/README.ko.md @@ -4,7 +4,7 @@ **AI 에이전트를 위한 IM.** -IM.codes는 AI 코딩 에이전트를 위한 전용 메신저입니다. 모바일이나 웹에서 장시간 실행 중인 agent session에 접근해 터미널, 파일 브라우징, Git 변경 보기, localhost 미리보기, 알림, 멀티 에이전트 워크플로를 사용할 수 있습니다. Claude Code, Codex, Gemini CLI, OpenClaw, Qwen을 지원합니다. +IM.codes는 AI 코딩 에이전트를 위한 전용 메신저입니다. iPhone, iPad, Apple Watch, 모바일이나 웹에서 장시간 실행 중인 agent session에 접근해 터미널, 파일 브라우징, Git 변경 보기, localhost 미리보기, 알림, 멀티 에이전트 워크플로를 사용할 수 있습니다. Claude Code, Codex, Gemini CLI, OpenClaw, Qwen을 지원합니다. > 이 문서는 번역본입니다. **기준 문서는 영어 README(`../README.md`)입니다.** 차이가 있으면 영어판을 우선합니다. diff --git a/README.i18n/README.ru.md b/README.i18n/README.ru.md index a6b021b6c..cf74897ed 100644 --- a/README.i18n/README.ru.md +++ b/README.i18n/README.ru.md @@ -4,7 +4,7 @@ **Слой мессенджера для агентов.** -IM.codes — специализированный мессенджер для AI coding agents. Он позволяет держать долгие agent‑сессии под рукой с телефона или из веба: терминал, файлы, Git, просмотр localhost, уведомления и multi‑agent workflows. Поддерживаются Claude Code, Codex, Gemini CLI, OpenClaw и Qwen. +IM.codes — специализированный мессенджер для AI coding agents. Он позволяет держать долгие agent‑сессии под рукой с iPhone, iPad, Apple Watch, телефона или из веба: терминал, файлы, Git, просмотр localhost, уведомления и multi‑agent workflows. Поддерживаются Claude Code, Codex, Gemini CLI, OpenClaw и Qwen. > Это перевод. **Каноническая версия — английский README (`../README.md`).** Если есть расхождения, ориентируйтесь на английский вариант. diff --git a/README.i18n/README.zh-CN.md b/README.i18n/README.zh-CN.md index d910728de..9da755568 100644 --- a/README.i18n/README.zh-CN.md +++ b/README.i18n/README.zh-CN.md @@ -5,7 +5,7 @@ **Agent 的即时通讯层。** -IM.codes 是一个面向 AI 编码代理的专用即时通讯器。你可以在手机或网页上持续查看长时间运行的 agent 会话,直接访问终端、浏览文件、查看 Git 变更、预览本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 +IM.codes 是一个面向 AI 编码代理的专用即时通讯器。你可以在 iPhone、iPad、Apple Watch、手机或网页上持续查看长时间运行的 agent 会话,直接访问终端、浏览文件、查看 Git 变更、预览本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 > **说明:** 本文件是中文翻译版。**英文 README(`../README.md`)是规范版本。** 若内容存在差异,以英文版为准。 diff --git a/README.i18n/README.zh-TW.md b/README.i18n/README.zh-TW.md index 460c6c156..e015509b0 100644 --- a/README.i18n/README.zh-TW.md +++ b/README.i18n/README.zh-TW.md @@ -5,7 +5,7 @@ **Agent 的即時通訊層。** -IM.codes 是一个面向 AI 编码代理的專用即時通訊器。你可以在手機或網頁上持续檢視长时间运行的 agent 会话,直接访问终端、瀏覽文件、檢視 Git 變更、預覽本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 +IM.codes 是一个面向 AI 编码代理的專用即時通訊器。你可以在 iPhone、iPad、Apple Watch、手機或網頁上持续檢視长时间运行的 agent 会话,直接访问终端、瀏覽文件、檢視 Git 變更、預覽本地 localhost、接收通知,并进行多 agent 协作。支持 [Claude Code](https://github.com/anthropics/claude-code)、[Codex](https://github.com/openai/codex)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[OpenClaw](https://openclaw.com)、[Qwen](https://github.com/QwenLM/qwen-agent) 等,也支持 transport 型 agent 的原生流式输出。 > **說明:** 本文件是中文翻译版。**英文 README(`../README.md`)是規範版本。** 若内容存在差异,以英文版为准。 diff --git a/README.md b/README.md index fa157e2a2..9bae39fa0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **The IM for agents.** -A specialized instant messenger for AI agents. Keep long-running coding-agent sessions within reach from mobile or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in. Works with [Claude Code](https://github.com/anthropics/claude-code) and [Codex](https://github.com/openai/codex) via both CLI and SDK integrations, plus [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com), [Qwen](https://github.com/QwenLM/qwen-agent), and more — including native streaming output for transport-backed agents. +A specialized instant messenger for AI agents. Keep long-running coding-agent sessions within reach from iPhone, iPad, Apple Watch, mobile, or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in. Works with [Claude Code](https://github.com/anthropics/claude-code) and [Codex](https://github.com/openai/codex) via both CLI and SDK integrations, plus [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.com), [Qwen](https://github.com/QwenLM/qwen-agent), and more — including native streaming output for transport-backed agents. > **Disclaimer:** This is an actively developed personal open-source project. There are no warranties, no SLA, and no guarantees of stability, security, or backward compatibility. Use at your own risk. Breaking changes may happen at any time without notice. diff --git a/landing/index.html b/landing/index.html index ce668302c..e0c0b3e76 100644 --- a/landing/index.html +++ b/landing/index.html @@ -236,7 +236,7 @@

IM.codes

-

Keep long-running coding-agent sessions within reach from mobile or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in.

+

Keep long-running coding-agent sessions within reach from iPhone, iPad, Apple Watch, mobile, or web, with terminal access, file browsing, git views, localhost preview, notifications, and multi-agent workflows built in.

imcodes bind https://app.im.codes/bind/<key>
bound to app.im.codes · daemon started · registered as system service
@@ -468,7 +468,7 @@

about

}, 'zh-CN': { tagline: '为 AI 代理而生的即时通讯', - hero_intro: '让长时间运行的 coding agent 会话始终触手可及:手机或网页即可查看终端、文件、Git、localhost 预览、通知和多代理工作流。', + hero_intro: '让长时间运行的 coding agent 会话始终触手可及:iPhone、iPad、Apple Watch、手机或网页即可查看终端、文件、Git、localhost 预览、通知和多代理工作流。', hero_output: '已绑定 app.im.codes · 守护进程已启动 · 已注册为系统服务', self_host_warning: '强烈建议自行部署。app.im.codes 是共享测试实例,无可用性保证,可能被限流、攻击或不可用。这是个人项目,不提供商用保障。正式使用请部署到自己的服务器。', h_screenshots: '截图', h_why: '为什么', h_not: '它不是什么', h_features: '功能', h_arch: '架构', h_download: '下载', h_install: '安装', h_quick: '快速开始', h_selfhost: '自托管部署', h_agents: '支持的代理', h_reqs: '系统要求', h_about: '关于', @@ -527,7 +527,7 @@

about

}, 'zh-TW': { tagline: '為 AI 代理而生的即時通訊', - hero_intro: '讓長時間運行的 coding agent 會話始終觸手可及:手機或網頁即可查看終端、檔案、Git、localhost 預覽、通知和多代理工作流。', + hero_intro: '讓長時間運行的 coding agent 會話始終觸手可及:iPhone、iPad、Apple Watch、手機或網頁即可查看終端、檔案、Git、localhost 預覽、通知和多代理工作流。', hero_output: '已綁定 app.im.codes · 守護程序已啟動 · 已註冊為系統服務', self_host_warning: '強烈建議自行部署。app.im.codes 是共享測試實例,無可用性保證,可能被限流、攻擊或不可用。這是個人專案,不提供商用保障。正式使用請部署到自己的伺服器。', h_screenshots: '截圖', h_why: '為什麼', h_not: '它不是什麼', h_features: '功能', h_arch: '架構', h_download: '下載', h_install: '安裝', h_quick: '快速開始', h_selfhost: '自託管部署', h_agents: '支援的代理', h_reqs: '系統需求', h_about: '關於', @@ -586,7 +586,7 @@

about

}, ja: { tagline: 'AIエージェントのためのIM', - hero_intro: '長時間動く coding agent セッションを、モバイルやWebから常に手の届く場所に。ターミナル、ファイル、Git、localhost プレビュー、通知、マルチエージェントワークフローをまとめて提供します。', + hero_intro: '長時間動く coding agent セッションを、iPhone、iPad、Apple Watch、モバイルやWebから常に手の届く場所に。ターミナル、ファイル、Git、localhost プレビュー、通知、マルチエージェントワークフローをまとめて提供します。', hero_output: 'app.im.codes にバインド完了 · デーモン起動 · システムサービスとして登録', self_host_warning: 'セルフホスティングを強く推奨します。app.im.codes は共有テストインスタンスであり、稼働保証はありません。レート制限、攻撃対象、利用不可の可能性があります。個人プロジェクトのため商用サポートはありません。評価以外の用途では自社インフラにデプロイしてください。', h_screenshots: 'スクリーンショット', h_why: '背景', h_not: 'これは何ではないか', h_features: '機能', h_arch: 'アーキテクチャ', h_download: 'ダウンロード', h_install: 'インストール', h_quick: 'クイックスタート', h_selfhost: 'セルフホスト', h_agents: '対応エージェント', h_reqs: '要件', h_about: '概要', @@ -644,7 +644,7 @@

about

}, ko: { tagline: 'AI 에이전트를 위한 IM', - hero_intro: '오래 실행되는 coding agent 세션을 모바일이나 웹에서 항상 닿는 곳에 두세요. 터미널, 파일, Git, localhost 미리보기, 알림, 멀티 에이전트 워크플로우가 함께 제공됩니다.', + hero_intro: '오래 실행되는 coding agent 세션을 iPhone, iPad, Apple Watch, 모바일이나 웹에서 항상 닿는 곳에 두세요. 터미널, 파일, Git, localhost 미리보기, 알림, 멀티 에이전트 워크플로우가 함께 제공됩니다.', hero_output: 'app.im.codes에 바인딩 완료 · 데몬 시작됨 · 시스템 서비스로 등록됨', self_host_warning: '셀프 호스팅을 강력히 권장합니다. app.im.codes는 공유 테스트 인스턴스로 가동 보장이 없으며, 속도 제한, 공격 대상이 되거나 사용 불가할 수 있습니다. 개인 프로젝트로 상업적 지원은 제공되지 않습니다. 평가 이외의 용도에는 자체 인프라에 배포하세요.', h_screenshots: '스크린샷', h_why: '배경', h_not: '무엇이 아닌가', h_features: '기능', h_arch: '아키텍처', h_download: '다운로드', h_install: '설치', h_quick: '빠른 시작', h_selfhost: '셀프 호스팅', h_agents: '지원 에이전트', h_reqs: '요구사항', h_about: '소개', @@ -703,7 +703,7 @@

about

}, es: { tagline: 'El IM para agentes', - hero_intro: 'Mantén las sesiones de coding agents de larga duración al alcance desde móvil o web, con terminal, archivos, vistas Git, vista previa de localhost, notificaciones y flujos multiagente integrados.', + hero_intro: 'Mantén las sesiones de coding agents de larga duración al alcance desde iPhone, iPad, Apple Watch, móvil o web, con terminal, archivos, vistas Git, vista previa de localhost, notificaciones y flujos multiagente integrados.', hero_output: 'vinculado a app.im.codes · daemon iniciado · registrado como servicio del sistema', self_host_warning: 'Se recomienda encarecidamente el autoalojamiento. app.im.codes es una instancia de prueba compartida sin garantías de disponibilidad — puede tener límites, ser objetivo de ataques o no estar disponible. Este es un proyecto personal sin soporte comercial. Para uso más allá de la evaluación, despliega en tu propia infraestructura.', h_screenshots: 'capturas', h_why: 'por qué', h_not: 'qué no es', h_features: 'características', h_arch: 'arquitectura', h_download: 'descargar', h_install: 'instalar', h_quick: 'inicio rápido', h_selfhost: 'autoalojamiento', h_agents: 'agentes compatibles', h_reqs: 'requisitos', h_about: 'acerca de', @@ -762,7 +762,7 @@

about

}, ru: { tagline: 'IM для агентов', - hero_intro: 'Держите долгоживущие coding agent-сессии под рукой с телефона или из браузера: терминал, файлы, Git, localhost-превью, уведомления и мульти-агентные сценарии уже встроены.', + hero_intro: 'Держите долгоживущие coding agent-сессии под рукой с iPhone, iPad, Apple Watch, телефона или из браузера: терминал, файлы, Git, localhost-превью, уведомления и мульти-агентные сценарии уже встроены.', hero_output: 'привязан к app.im.codes · демон запущен · зарегистрирован как системная служба', self_host_warning: 'Настоятельно рекомендуется самостоятельный хостинг. app.im.codes — общий тестовый экземпляр без гарантий доступности. Может быть ограничен, атакован или недоступен. Это личный проект без коммерческой поддержки. Для использования помимо тестирования разверните на собственной инфраструктуре.', h_screenshots: 'скриншоты', h_why: 'зачем', h_not: 'чем это не является', h_features: 'возможности', h_arch: 'архитектура', h_download: 'скачать', h_install: 'установка', h_quick: 'быстрый старт', h_selfhost: 'свой сервер', h_agents: 'поддерживаемые агенты', h_reqs: 'требования', h_about: 'о проекте', From febc30210b874864dffbb93e9224460c075c4e1c Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Fri, 10 Apr 2026 00:31:04 +0800 Subject: [PATCH 04/29] Preserve main session labels across daemon sync --- server/src/db/queries.ts | 7 +++-- server/src/routes/session-mgmt.ts | 2 ++ server/test/db.integration.test.ts | 9 ++++++ server/test/session-mgmt-routes.test.ts | 4 ++- src/daemon/lifecycle.ts | 22 +++----------- src/daemon/session-bootstrap.ts | 38 +++++++++++++++++++++++++ test/daemon/session-bootstrap.test.ts | 32 ++++++++++++++++++++- 7 files changed, 92 insertions(+), 22 deletions(-) diff --git a/server/src/db/queries.ts b/server/src/db/queries.ts index e5347a609..109cc408b 100644 --- a/server/src/db/queries.ts +++ b/server/src/db/queries.ts @@ -368,6 +368,7 @@ export async function upsertDbSession( agentType: string, projectDir: string, state: string, + label?: string | null, agentVersion?: string | null, runtimeType?: string | null, providerId?: string | null, @@ -380,14 +381,15 @@ export async function upsertDbSession( ): Promise { const now = Date.now(); await db.execute( - `INSERT INTO sessions (id, server_id, name, project_name, role, agent_type, agent_version, project_dir, state, runtime_type, provider_id, provider_session_id, description, requested_model, active_model, effort, transport_config, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::jsonb, $18, $19) + `INSERT INTO sessions (id, server_id, name, project_name, role, agent_type, agent_version, project_dir, state, label, runtime_type, provider_id, provider_session_id, description, requested_model, active_model, effort, transport_config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20) ON CONFLICT(server_id, name) DO UPDATE SET role = excluded.role, agent_type = excluded.agent_type, agent_version = excluded.agent_version, project_dir = excluded.project_dir, state = excluded.state, + label = COALESCE(excluded.label, sessions.label), runtime_type = excluded.runtime_type, provider_id = excluded.provider_id, provider_session_id = excluded.provider_session_id, @@ -407,6 +409,7 @@ export async function upsertDbSession( agentVersion ?? null, projectDir, state, + label ?? null, runtimeType ?? null, providerId ?? null, providerSessionId ?? null, diff --git a/server/src/routes/session-mgmt.ts b/server/src/routes/session-mgmt.ts index 6489ca13e..19a548e99 100644 --- a/server/src/routes/session-mgmt.ts +++ b/server/src/routes/session-mgmt.ts @@ -63,6 +63,7 @@ sessionMgmtRoutes.put('/:id/sessions/:name', async (c) => { agentVersion, projectDir, state, + label, runtimeType, providerId, providerSessionId, @@ -86,6 +87,7 @@ sessionMgmtRoutes.put('/:id/sessions/:name', async (c) => { String(agentType), String(projectDir), String(state), + typeof label === 'string' && label.trim() ? label.trim() : null, typeof agentVersion === 'string' ? agentVersion : null, typeof runtimeType === 'string' ? runtimeType : null, typeof providerId === 'string' ? providerId : null, diff --git a/server/test/db.integration.test.ts b/server/test/db.integration.test.ts index ea65d27f4..23bf62e4e 100644 --- a/server/test/db.integration.test.ts +++ b/server/test/db.integration.test.ts @@ -474,6 +474,15 @@ describe('sessions', () => { expect(s?.state).toBe('idle'); }); + it('upsertDbSession preserves an existing label when a later sync omits it', async () => { + await upsertDbSession(db, 'sid-keep-label', serverId, 'deck_proj_brain', 'myproj', 'brain', 'claude-code', '/home/dev', 'idle', 'Readable Main'); + await upsertDbSession(db, 'sid-1', serverId, 'deck_proj_brain', 'myproj', 'brain', 'claude-code', '/home/dev', 'running'); + const sessions = await getDbSessionsByServer(db, serverId); + const s = sessions.find((session) => session.name === 'deck_proj_brain'); + expect(s?.label).toBe('Readable Main'); + expect(s?.state).toBe('running'); + }); + it('updateSessionLabel sets label', async () => { await updateSessionLabel(db, serverId, 'deck_proj_brain', 'My Project'); const sessions = await getDbSessionsByServer(db, serverId); diff --git a/server/test/session-mgmt-routes.test.ts b/server/test/session-mgmt-routes.test.ts index 773df648e..aa3ea97a6 100644 --- a/server/test/session-mgmt-routes.test.ts +++ b/server/test/session-mgmt-routes.test.ts @@ -58,7 +58,7 @@ describe('session-mgmt persistence routes', () => { return app; } - it('PUT /sessions/:name persists requestedModel/activeModel/effort/transportConfig', async () => { + it('PUT /sessions/:name persists label plus requestedModel/activeModel/effort/transportConfig', async () => { const app = await buildApp(); const res = await app.request('/api/server/srv-1/sessions/deck_proj_brain', { method: 'PUT', @@ -69,6 +69,7 @@ describe('session-mgmt persistence routes', () => { agentType: 'claude-code-sdk', projectDir: '/tmp/proj', state: 'idle', + label: 'Readable Main', runtimeType: 'transport', providerId: 'claude-code-sdk', providerSessionId: 'route-1', @@ -91,6 +92,7 @@ describe('session-mgmt persistence routes', () => { 'claude-code-sdk', '/tmp/proj', 'idle', + 'Readable Main', null, 'transport', 'claude-code-sdk', diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index f32c1c615..f13891b9a 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -27,7 +27,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { P2P_TERMINAL_RUN_STATUSES } from '../../shared/p2p-status.js'; import { pickReadableSessionDisplay } from '../../shared/session-display.js'; -import { mergeWorkerSessionSnapshot } from './session-bootstrap.js'; +import { buildWorkerSessionPersistBody, mergeWorkerSessionSnapshot } from './session-bootstrap.js'; /** Get the last assistant.text from a session's timeline (for push notification context). */ function getLastAssistantText(sessionName: string): string | undefined { @@ -103,25 +103,11 @@ async function persistSessionToWorker( record: import('../store/session-store.js').SessionRecord, ): Promise { try { + const payload = buildWorkerSessionPersistBody(record); const res = await fetch(`${workerUrl}/api/server/${serverId}/sessions/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'X-Server-Id': serverId }, - body: JSON.stringify({ - projectName: record.projectName, - projectRole: record.role, - agentType: record.agentType, - agentVersion: record.agentVersion, - projectDir: record.projectDir, - state: record.state, - runtimeType: record.runtimeType ?? null, - providerId: record.providerId ?? null, - providerSessionId: record.providerSessionId ?? null, - description: record.description ?? null, - requestedModel: record.requestedModel ?? null, - activeModel: record.activeModel ?? record.modelDisplay ?? null, - effort: record.effort ?? null, - transportConfig: record.transportConfig ?? null, - }), + body: JSON.stringify(payload), }); if (!res.ok) logger.warn({ status: res.status, name }, 'persistSessionToWorker: non-ok response'); } catch (e) { @@ -186,7 +172,7 @@ async function syncSessionsFromWorker(workerUrl: string, serverId: string, token return; } - const data = await sessionRes.json() as { sessions: Array<{ name: string; project_name: string; role: string; agent_type: string; project_dir: string; state: string; requested_model?: string | null; active_model?: string | null; effort?: SessionRecord['effort'] | null; transport_config?: Record | string | null }> }; + const data = await sessionRes.json() as { sessions: Array<{ name: string; project_name: string; role: string; agent_type: string; project_dir: string; state: string; label?: string | null; requested_model?: string | null; active_model?: string | null; effort?: SessionRecord['effort'] | null; transport_config?: Record | string | null }> }; const subData = await subRes.json() as { subSessions: Array<{ id: string }> }; const remoteSessionNames = new Set( data.sessions diff --git a/src/daemon/session-bootstrap.ts b/src/daemon/session-bootstrap.ts index 3541795e6..2f278fd84 100644 --- a/src/daemon/session-bootstrap.ts +++ b/src/daemon/session-bootstrap.ts @@ -14,6 +14,44 @@ export interface WorkerSessionSnapshot { transport_config?: Record | string | null; } +export interface WorkerSessionPersistBody { + projectName: string; + projectRole: string; + agentType: string; + agentVersion: string | null; + projectDir: string; + state: string; + label: string | null; + runtimeType: string | null; + providerId: string | null; + providerSessionId: string | null; + description: string | null; + requestedModel: string | null; + activeModel: string | null; + effort: SessionRecord['effort'] | null; + transportConfig: Record | null; +} + +export function buildWorkerSessionPersistBody(record: SessionRecord): WorkerSessionPersistBody { + return { + projectName: record.projectName, + projectRole: record.role, + agentType: record.agentType, + agentVersion: record.agentVersion ?? null, + projectDir: record.projectDir, + state: record.state, + label: record.label ?? null, + runtimeType: record.runtimeType ?? null, + providerId: record.providerId ?? null, + providerSessionId: record.providerSessionId ?? null, + description: record.description ?? null, + requestedModel: record.requestedModel ?? null, + activeModel: record.activeModel ?? record.modelDisplay ?? null, + effort: record.effort ?? null, + transportConfig: record.transportConfig ?? null, + }; +} + export function mergeWorkerSessionSnapshot( existing: SessionRecord | undefined, snapshot: WorkerSessionSnapshot, diff --git a/test/daemon/session-bootstrap.test.ts b/test/daemon/session-bootstrap.test.ts index 15fd1f79a..5f5a60456 100644 --- a/test/daemon/session-bootstrap.test.ts +++ b/test/daemon/session-bootstrap.test.ts @@ -1,6 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { mergeWorkerSessionSnapshot } from '../../src/daemon/session-bootstrap.js'; +import { buildWorkerSessionPersistBody, mergeWorkerSessionSnapshot } from '../../src/daemon/session-bootstrap.js'; + +describe('buildWorkerSessionPersistBody', () => { + it('serializes the current label so later daemon syncs do not wipe it', () => { + const payload = buildWorkerSessionPersistBody({ + name: 'deck_proj_brain', + projectName: 'proj', + role: 'brain', + agentType: 'codex', + projectDir: '/tmp/proj', + state: 'idle', + label: 'Readable Main', + description: 'persona', + requestedModel: 'gpt-5', + activeModel: 'gpt-5', + modelDisplay: 'GPT-5', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + } as any); + + expect(payload).toEqual(expect.objectContaining({ + projectName: 'proj', + projectRole: 'brain', + label: 'Readable Main', + requestedModel: 'gpt-5', + activeModel: 'gpt-5', + })); + }); +}); describe('mergeWorkerSessionSnapshot', () => { it('hydrates the persisted main-session label from the worker snapshot', () => { From 88922f1de2e2f139da398c37e5e52d2467cf9dd1 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Fri, 10 Apr 2026 00:46:18 +0800 Subject: [PATCH 05/29] Fix server integration test for session labels --- server/test/db.integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/test/db.integration.test.ts b/server/test/db.integration.test.ts index 23bf62e4e..2f5eefa2f 100644 --- a/server/test/db.integration.test.ts +++ b/server/test/db.integration.test.ts @@ -977,7 +977,7 @@ describe('transport session metadata persistence', () => { it('upsertDbSession with transport fields roundtrip', async () => { await upsertDbSession( db, 'tmd-sid-1', serverId, 'deck_transport_brain', 'tproj', 'brain', 'claude-code', '/home/dev', - 'running', null, 'transport', 'openclaw', 'oc-key-123', 'test persona', + 'running', null, null, 'transport', 'openclaw', 'oc-key-123', 'test persona', ); const sessions = await getDbSessionsByServer(db, serverId); const s = sessions.find(s => s.name === 'deck_transport_brain'); @@ -992,7 +992,7 @@ describe('transport session metadata persistence', () => { // Upsert same session with a new state — transport fields should survive await upsertDbSession( db, 'tmd-sid-1', serverId, 'deck_transport_brain', 'tproj', 'brain', 'claude-code', '/home/dev', - 'idle', null, 'transport', 'openclaw', 'oc-key-123', 'test persona', 'sonnet', 'sonnet', 'high', { provider: { mode: 'safe' } }, + 'idle', null, null, 'transport', 'openclaw', 'oc-key-123', 'test persona', 'sonnet', 'sonnet', 'high', { provider: { mode: 'safe' } }, ); const sessions = await getDbSessionsByServer(db, serverId); const s = sessions.find(s => s.name === 'deck_transport_brain'); From dd2e8f8dcefbc05e0a67a0d15a838166cff91844 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Fri, 10 Apr 2026 09:10:12 +0800 Subject: [PATCH 06/29] Strengthen OpenSpec dropdown prompts --- web/src/i18n/locales/en.json | 12 +++++----- web/src/i18n/locales/es.json | 12 +++++----- web/src/i18n/locales/ja.json | 12 +++++----- web/src/i18n/locales/ko.json | 12 +++++----- web/src/i18n/locales/ru.json | 12 +++++----- web/src/i18n/locales/zh-CN.json | 12 +++++----- web/src/i18n/locales/zh-TW.json | 12 +++++----- web/test/components/SessionControls.test.tsx | 24 ++++++++++---------- 8 files changed, 54 insertions(+), 54 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5943ad740..7e0ac7945 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -476,12 +476,12 @@ "propose_action": "Propose", "propose_from_discussion_action": "From Recent Discussion", "propose_from_description_action": "From Description Below", - "audit_implementation_prompt": "Perform a strict implementation audit for {{reference}} against its OpenSpec artifacts. Identify every mismatch, omission, regression risk, edge-case gap, and missing test; then fix the code, tests, and any required supporting changes. Do not stop at a report. Deliver the implementation in full spec compliance.", - "audit_spec_prompt": "Perform a strict specification audit for {{reference}}. Strengthen scope, requirements, acceptance criteria, edge cases, failure modes, dependencies, and testability; remove ambiguity and internal inconsistency; and update the spec until it is implementation-ready. Do not stop at review notes.", - "implement_prompt": "Drive the implementation of {{reference}} aggressively. Break the work into concrete sub-tasks, dispatch sub-agents with clear ownership, integrate their output, resolve gaps against the spec, add or update tests, and verify the finished result. You own orchestration, technical decisions, and final acceptance.", - "achieve_prompt": "Take {{reference}} to done using the full OpenSpec workflow. Inspect all remaining tasks and artifacts, complete the required implementation and spec work, close outstanding gaps, sync affected specs if needed, and archive the change once it meets completion criteria. Do not stop at status reporting.", - "propose_from_discussion_prompt": "Generate an OpenSpec proposal from the recent discussion. Extract the goal, scope, key requirements, and acceptance criteria, and list unclear points as follow-ups. Start with a change proposal draft that is ready to land.", - "propose_from_description_prompt": "Generate an OpenSpec proposal from the description below. Organize the goal, scope, key requirements, and acceptance criteria, and list missing details as follow-ups. Start with a change proposal draft that is ready to land." + "audit_implementation_prompt": "Perform a strict implementation audit for {{reference}} against its OpenSpec artifacts. Identify every mismatch, omission, regression risk, edge-case gap, and missing test; then fix the code, tests, and any required supporting changes. If the change artifacts need to move with the implementation, update the relevant OpenSpec files under {{reference}} in the same task. Do not stop at a report. Deliver the implementation in full spec compliance.", + "audit_spec_prompt": "Perform a strict specification audit for {{reference}}. Strengthen scope, requirements, acceptance criteria, edge cases, failure modes, dependencies, and testability; remove ambiguity and internal inconsistency; then directly update the change artifacts under {{reference}} (proposal, design, specs, tasks) until they are implementation-ready. Do not stop at review notes.", + "implement_prompt": "Drive the implementation of {{reference}} aggressively. Break the work into concrete sub-tasks, dispatch sub-agents with clear ownership, integrate their output, resolve gaps against the spec, add or update tests, and verify the finished result. Keep the OpenSpec artifacts under {{reference}} aligned with the implementation as you go instead of leaving follow-up notes. You own orchestration, technical decisions, and final acceptance.", + "achieve_prompt": "Take {{reference}} to done using the full OpenSpec workflow. Inspect all remaining tasks and artifacts, complete the required implementation and spec work, directly update proposal/design/specs/tasks under {{reference}} where needed, close outstanding gaps, sync affected specs if needed, and archive the change once it meets completion criteria. Do not stop at status reporting.", + "propose_from_discussion_prompt": "Generate an OpenSpec change from the recent discussion. Extract the goal, scope, key requirements, and acceptance criteria, list unclear points as follow-ups, and write the actual change artifacts under openspec/changes/ (proposal, design, specs, tasks) instead of stopping at a draft note.", + "propose_from_description_prompt": "Generate an OpenSpec change from the description below. Organize the goal, scope, key requirements, and acceptance criteria, list missing details as follow-ups, and write the actual change artifacts under openspec/changes/ (proposal, design, specs, tasks) instead of stopping at a draft note." }, "upload": { "upload_file": "Upload file", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index 52e5ab6b2..7a767f035 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -476,12 +476,12 @@ "propose_action": "Proponer", "propose_from_discussion_action": "Desde la discusión reciente", "propose_from_description_action": "Desde la descripción de abajo", - "audit_implementation_prompt": "Realiza una auditoría estricta de la implementación de {{reference}} contra sus artefactos de OpenSpec. Identifica cada discrepancia, omisión, riesgo de regresión, hueco en casos límite y prueba faltante; después corrige el código, las pruebas y cualquier cambio de soporte necesario. No te detengas en un informe: deja la implementación totalmente alineada con la especificación.", - "audit_spec_prompt": "Realiza una auditoría estricta de la especificación de {{reference}}. Refuerza alcance, requisitos, criterios de aceptación, casos límite, modos de fallo, dependencias y capacidad de prueba; elimina ambigüedades e inconsistencias internas; y actualiza la especificación hasta que quede lista para implementar. No te limites a observaciones de revisión.", - "implement_prompt": "Impulsa con firmeza la implementación de {{reference}}. Divide el trabajo en subtareas concretas, asigna subagentes con propiedad clara, integra sus resultados, cierra las brechas respecto a la especificación, añade o actualiza pruebas y verifica el resultado final. Tú eres responsable de la orquestación, las decisiones técnicas y la aceptación final.", - "achieve_prompt": "Lleva {{reference}} hasta completarlo usando el flujo completo de OpenSpec. Revisa todas las tareas y artefactos pendientes, completa el trabajo necesario de implementación y especificación, cierra los huecos abiertos, sincroniza las especificaciones afectadas si hace falta y archiva el cambio cuando cumpla los criterios de finalización. No te quedes en el reporte de estado.", - "propose_from_discussion_prompt": "Genera una propuesta de OpenSpec a partir de la discusión reciente. Extrae el objetivo, el alcance, los requisitos clave y los criterios de aceptación, y deja los puntos poco claros como pendientes. Empieza con un borrador de change proposal listo para guardar.", - "propose_from_description_prompt": "Genera una propuesta de OpenSpec a partir de la descripción de abajo. Organiza el objetivo, el alcance, los requisitos clave y los criterios de aceptación, y deja los detalles faltantes como pendientes. Empieza con un borrador de change proposal listo para guardar." + "audit_implementation_prompt": "Realiza una auditoría estricta de la implementación de {{reference}} contra sus artefactos de OpenSpec. Identifica cada discrepancia, omisión, riesgo de regresión, hueco en casos límite y prueba faltante; después corrige el código, las pruebas y cualquier cambio de soporte necesario. Si la implementación exige mover también la especificación, actualiza en la misma tarea los archivos de OpenSpec bajo {{reference}}. No te detengas en un informe: deja la implementación totalmente alineada con la especificación.", + "audit_spec_prompt": "Realiza una auditoría estricta de la especificación de {{reference}}. Refuerza alcance, requisitos, criterios de aceptación, casos límite, modos de fallo, dependencias y capacidad de prueba; elimina ambigüedades e inconsistencias internas; y actualiza directamente proposal, design, specs y tasks bajo {{reference}} hasta que quede lista para implementar. No te limites a observaciones de revisión.", + "implement_prompt": "Impulsa con firmeza la implementación de {{reference}}. Divide el trabajo en subtareas concretas, asigna subagentes con propiedad clara, integra sus resultados, cierra las brechas respecto a la especificación, añade o actualiza pruebas y verifica el resultado final. Mantén alineados durante el trabajo los artefactos de OpenSpec bajo {{reference}} en lugar de dejar notas para después. Tú eres responsable de la orquestación, las decisiones técnicas y la aceptación final.", + "achieve_prompt": "Lleva {{reference}} hasta completarlo usando el flujo completo de OpenSpec. Revisa todas las tareas y artefactos pendientes, completa el trabajo necesario de implementación y especificación, actualiza directamente proposal, design, specs y tasks bajo {{reference}} cuando haga falta, cierra los huecos abiertos, sincroniza las especificaciones afectadas si hace falta y archiva el cambio cuando cumpla los criterios de finalización. No te quedes en el reporte de estado.", + "propose_from_discussion_prompt": "Genera un cambio de OpenSpec a partir de la discusión reciente. Extrae el objetivo, el alcance, los requisitos clave y los criterios de aceptación, deja los puntos poco claros como pendientes y escribe los artefactos reales bajo openspec/changes/ (proposal, design, specs y tasks) en lugar de quedarte en una nota borrador.", + "propose_from_description_prompt": "Genera un cambio de OpenSpec a partir de la descripción de abajo. Organiza el objetivo, el alcance, los requisitos clave y los criterios de aceptación, deja los detalles faltantes como pendientes y escribe los artefactos reales bajo openspec/changes/ (proposal, design, specs y tasks) en lugar de quedarte en una nota borrador." }, "upload": { "upload_file": "Subir archivo", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 64b3847c8..c0bf48dc0 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -476,12 +476,12 @@ "propose_action": "提案", "propose_from_discussion_action": "直近の議論から生成", "propose_from_description_action": "下の説明から生成", - "audit_implementation_prompt": "{{reference}} について、OpenSpec の成果物に照らした厳格な実装監査を実施してください。不一致、抜け漏れ、回帰リスク、境界ケースの欠落、未整備のテストをすべて洗い出し、そのうえでコード、テスト、必要な関連修正まで直してください。レポートだけで終わらせず、実装を仕様完全準拠まで持っていってください。", - "audit_spec_prompt": "{{reference}} について、厳格な仕様監査を実施してください。スコープ、要件、受け入れ条件、境界ケース、失敗モード、依存関係、テスト容易性を強化し、曖昧さと内部矛盾を除去して、実装可能な品質まで仕様を更新してください。レビューコメントだけで終わらせないでください。", - "implement_prompt": "{{reference}} の実装を強力に前進させてください。作業を具体的なサブタスクに分解し、サブエージェントへ明確な責務で割り当て、成果を統合し、仕様との差分を解消し、テストを追加または更新し、最終結果を検証してください。あなたがオーケストレーション、技術判断、最終受け入れを担ってください。", - "achieve_prompt": "完全な OpenSpec ワークフローで {{reference}} を done まで持っていってください。残っているタスクと成果物をすべて確認し、必要な実装と仕様の作業を完了し、未解決事項を閉じ、必要なら影響するメイン仕様を同期し、完了条件を満たしたら変更をアーカイブしてください。進捗報告だけで止まらないでください。", - "propose_from_discussion_prompt": "直近の議論から OpenSpec proposal を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不明点は確認事項として明示してください。まずはそのまま保存できる change proposal の草案を出してください。", - "propose_from_description_prompt": "下の説明から OpenSpec proposal を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不足情報は確認事項として明示してください。まずはそのまま保存できる change proposal の草案を出してください。" + "audit_implementation_prompt": "{{reference}} について、OpenSpec の成果物に照らした厳格な実装監査を実施してください。不一致、抜け漏れ、回帰リスク、境界ケースの欠落、未整備のテストをすべて洗い出し、そのうえでコード、テスト、必要な関連修正まで直してください。実装に合わせて仕様成果物も動かす必要がある場合は、同じ作業の中で {{reference}} 配下の OpenSpec ファイルも直接更新してください。レポートだけで終わらせず、実装を仕様完全準拠まで持っていってください。", + "audit_spec_prompt": "{{reference}} について、厳格な仕様監査を実施してください。スコープ、要件、受け入れ条件、境界ケース、失敗モード、依存関係、テスト容易性を強化し、曖昧さと内部矛盾を除去したうえで、{{reference}} 配下の proposal・design・specs・tasks を直接更新し、実装可能な品質まで仕様を仕上げてください。レビューコメントだけで終わらせないでください。", + "implement_prompt": "{{reference}} の実装を強力に前進させてください。作業を具体的なサブタスクに分解し、サブエージェントへ明確な責務で割り当て、成果を統合し、仕様との差分を解消し、テストを追加または更新し、最終結果を検証してください。作業中は {{reference}} 配下の OpenSpec 成果物も同期させ、後続メモに先送りしないでください。あなたがオーケストレーション、技術判断、最終受け入れを担ってください。", + "achieve_prompt": "完全な OpenSpec ワークフローで {{reference}} を done まで持っていってください。残っているタスクと成果物をすべて確認し、必要な実装と仕様の作業を完了し、必要に応じて {{reference}} 配下の proposal・design・specs・tasks を直接更新し、未解決事項を閉じ、必要なら影響するメイン仕様を同期し、完了条件を満たしたら変更をアーカイブしてください。進捗報告だけで止まらないでください。", + "propose_from_discussion_prompt": "直近の議論から OpenSpec 変更を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不明点は確認事項として明示したうえで、draft note で止めずに openspec/changes/ 配下へ proposal・design・specs・tasks を実際に書き出してください。", + "propose_from_description_prompt": "下の説明から OpenSpec 変更を生成してください。目的、スコープ、主要要件、受け入れ基準を整理し、不足情報は確認事項として明示したうえで、draft note で止めずに openspec/changes/ 配下へ proposal・design・specs・tasks を実際に書き出してください。" }, "upload": { "upload_file": "ファイルをアップロード", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index c2bf23f67..cde74b344 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -476,12 +476,12 @@ "propose_action": "제안", "propose_from_discussion_action": "최근 논의에서 생성", "propose_from_description_action": "아래 설명에서 생성", - "audit_implementation_prompt": "{{reference}}에 대해 OpenSpec 산출물을 기준으로 엄격한 구현 감사를 수행하세요. 모든 불일치, 누락, 회귀 위험, 경계 사례 공백, 누락된 테스트를 찾아낸 뒤 코드, 테스트, 필요한 보조 변경까지 직접 수정하세요. 보고서로 끝내지 말고 구현을 명세와 완전히 일치시키세요.", - "audit_spec_prompt": "{{reference}}에 대해 엄격한 명세 감사를 수행하세요. 범위, 요구사항, 승인 기준, 경계 사례, 실패 모드, 의존성, 테스트 가능성을 강화하고, 모호함과 내부 불일치를 제거해 바로 구현 가능한 수준까지 명세를 개선하세요. 검토 의견만 남기지 말고 명세 자체를 고치세요.", - "implement_prompt": "{{reference}} 구현을 강하게 밀어붙이세요. 작업을 구체적 하위 작업으로 분해하고, 하위 에이전트에 명확한 소유권을 배정하고, 결과를 통합하고, 명세 대비 부족분을 메우고, 테스트를 추가 또는 갱신하고, 최종 결과를 검증하세요. 당신이 오케스트레이션, 기술 판단, 최종 검수를 책임지세요.", - "achieve_prompt": "전체 OpenSpec 워크플로로 {{reference}}를 완료 상태까지 밀어붙이세요. 남은 작업과 산출물을 모두 점검하고, 필요한 구현과 명세 작업을 끝내고, 미해결 항목을 닫고, 필요하면 영향받는 메인 명세를 동기화한 뒤, 완료 기준을 충족하면 변경을 보관하세요. 상태 보고에서 멈추지 마세요.", - "propose_from_discussion_prompt": "최근 논의를 바탕으로 OpenSpec proposal을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 불명확한 부분은 확인 필요 항목으로 남기세요. 먼저 바로 저장할 수 있는 change proposal 초안을 작성하세요.", - "propose_from_description_prompt": "아래 설명을 바탕으로 OpenSpec proposal을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 빠진 정보는 확인 필요 항목으로 남기세요. 먼저 바로 저장할 수 있는 change proposal 초안을 작성하세요." + "audit_implementation_prompt": "{{reference}}에 대해 OpenSpec 산출물을 기준으로 엄격한 구현 감사를 수행하세요. 모든 불일치, 누락, 회귀 위험, 경계 사례 공백, 누락된 테스트를 찾아낸 뒤 코드, 테스트, 필요한 보조 변경까지 직접 수정하세요. 구현에 맞춰 명세 산출물도 움직여야 한다면 같은 작업 안에서 {{reference}} 아래 OpenSpec 파일까지 직접 갱신하세요. 보고서로 끝내지 말고 구현을 명세와 완전히 일치시키세요.", + "audit_spec_prompt": "{{reference}}에 대해 엄격한 명세 감사를 수행하세요. 범위, 요구사항, 승인 기준, 경계 사례, 실패 모드, 의존성, 테스트 가능성을 강화하고, 모호함과 내부 불일치를 제거한 뒤 {{reference}} 아래 proposal, design, specs, tasks를 직접 갱신해 바로 구현 가능한 수준까지 명세를 개선하세요. 검토 의견만 남기지 말고 명세 자체를 고치세요.", + "implement_prompt": "{{reference}} 구현을 강하게 밀어붙이세요. 작업을 구체적 하위 작업으로 분해하고, 하위 에이전트에 명확한 소유권을 배정하고, 결과를 통합하고, 명세 대비 부족분을 메우고, 테스트를 추가 또는 갱신하고, 최종 결과를 검증하세요. 작업 중에는 {{reference}} 아래 OpenSpec 산출물도 함께 맞춰 두고 후속 메모로 미루지 마세요. 당신이 오케스트레이션, 기술 판단, 최종 검수를 책임지세요.", + "achieve_prompt": "전체 OpenSpec 워크플로로 {{reference}}를 완료 상태까지 밀어붙이세요. 남은 작업과 산출물을 모두 점검하고, 필요한 구현과 명세 작업을 끝내고, 필요하면 {{reference}} 아래 proposal, design, specs, tasks를 직접 갱신하고, 미해결 항목을 닫고, 영향받는 메인 명세를 동기화한 뒤, 완료 기준을 충족하면 변경을 보관하세요. 상태 보고에서 멈추지 마세요.", + "propose_from_discussion_prompt": "최근 논의를 바탕으로 OpenSpec 변경을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 불명확한 부분은 확인 필요 항목으로 남긴 뒤, 초안 메모에서 멈추지 말고 openspec/changes/ 아래에 proposal, design, specs, tasks를 실제로 작성하세요.", + "propose_from_description_prompt": "아래 설명을 바탕으로 OpenSpec 변경을 생성하세요. 목표, 범위, 핵심 요구사항, 승인 기준을 정리하고, 빠진 정보는 확인 필요 항목으로 남긴 뒤, 초안 메모에서 멈추지 말고 openspec/changes/ 아래에 proposal, design, specs, tasks를 실제로 작성하세요." }, "upload": { "upload_file": "파일 업로드", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f2b48d19e..7921d0fca 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -476,12 +476,12 @@ "propose_action": "Предложить", "propose_from_discussion_action": "Из недавнего обсуждения", "propose_from_description_action": "Из описания ниже", - "audit_implementation_prompt": "Проведи строгий аудит реализации {{reference}} по его артефактам OpenSpec. Выяви каждое расхождение, упущение, риск регрессии, пробел в пограничных сценариях и недостающий тест; затем исправь код, тесты и все необходимые сопутствующие изменения. Не останавливайся на отчете: доведи реализацию до полного соответствия спецификации.", - "audit_spec_prompt": "Проведи строгий аудит спецификации {{reference}}. Усиль границы задачи, требования, критерии приемки, пограничные случаи, режимы отказа, зависимости и проверяемость; устрани неоднозначность и внутренние противоречия; и обнови спецификацию до состояния, готового к реализации. Не ограничивайся замечаниями ревью.", - "implement_prompt": "Жестко доведи реализацию {{reference}}. Разбей работу на конкретные подзадачи, раздай подагентам четкие зоны ответственности, интегрируй их результат, закрой разрывы относительно спецификации, добавь или обнови тесты и проверь финальный результат. На тебе оркестрация, технические решения и итоговая приемка.", - "achieve_prompt": "Доведи {{reference}} до состояния done по полному процессу OpenSpec. Проверь все оставшиеся задачи и артефакты, выполни необходимую работу по реализации и спецификации, закрой незавершенные пункты, при необходимости синхронизируй затронутые основные спецификации и архивируй изменение, когда оно будет соответствовать критериям завершения. Не останавливайся на отчете о статусе.", - "propose_from_discussion_prompt": "Сгенерируй OpenSpec proposal на основе недавнего обсуждения. Выдели цель, scope, ключевые требования и критерии приемки, а неясные места перечисли как вопросы на уточнение. Начни с черновика change proposal, готового к сохранению.", - "propose_from_description_prompt": "Сгенерируй OpenSpec proposal на основе описания ниже. Сформируй цель, scope, ключевые требования и критерии приемки, а недостающие детали перечисли как вопросы на уточнение. Начни с черновика change proposal, готового к сохранению." + "audit_implementation_prompt": "Проведи строгий аудит реализации {{reference}} по его артефактам OpenSpec. Выяви каждое расхождение, упущение, риск регрессии, пробел в пограничных сценариях и недостающий тест; затем исправь код, тесты и все необходимые сопутствующие изменения. Если по ходу нужно синхронизировать спецификацию, обнови в той же задаче соответствующие файлы OpenSpec внутри {{reference}}. Не останавливайся на отчете: доведи реализацию до полного соответствия спецификации.", + "audit_spec_prompt": "Проведи строгий аудит спецификации {{reference}}. Усиль границы задачи, требования, критерии приемки, пограничные случаи, режимы отказа, зависимости и проверяемость; устрани неоднозначность и внутренние противоречия; затем напрямую обнови proposal, design, specs и tasks внутри {{reference}} до состояния, готового к реализации. Не ограничивайся замечаниями ревью.", + "implement_prompt": "Жестко доведи реализацию {{reference}}. Разбей работу на конкретные подзадачи, раздай подагентам четкие зоны ответственности, интегрируй их результат, закрой разрывы относительно спецификации, добавь или обнови тесты и проверь финальный результат. По ходу работы держи артефакты OpenSpec внутри {{reference}} синхронизированными, а не оставляй это как последующую заметку. На тебе оркестрация, технические решения и итоговая приемка.", + "achieve_prompt": "Доведи {{reference}} до состояния done по полному процессу OpenSpec. Проверь все оставшиеся задачи и артефакты, выполни необходимую работу по реализации и спецификации, при необходимости напрямую обнови proposal, design, specs и tasks внутри {{reference}}, закрой незавершенные пункты, при необходимости синхронизируй затронутые основные спецификации и архивируй изменение, когда оно будет соответствовать критериям завершения. Не останавливайся на отчете о статусе.", + "propose_from_discussion_prompt": "Сгенерируй изменение OpenSpec на основе недавнего обсуждения. Выдели цель, scope, ключевые требования и критерии приемки, неясные места перечисли как вопросы на уточнение и запиши реальные артефакты в openspec/changes/ (proposal, design, specs и tasks), а не останавливайся на черновой заметке.", + "propose_from_description_prompt": "Сгенерируй изменение OpenSpec на основе описания ниже. Сформируй цель, scope, ключевые требования и критерии приемки, недостающие детали перечисли как вопросы на уточнение и запиши реальные артефакты в openspec/changes/ (proposal, design, specs и tasks), а не останавливайся на черновой заметке." }, "upload": { "upload_file": "Загрузить файл", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 9934f6faa..63a49453a 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -476,12 +476,12 @@ "propose_action": "Propose", "propose_from_discussion_action": "根据最近讨论生成", "propose_from_description_action": "根据下面的描述生成", - "audit_implementation_prompt": "对 {{reference}} 执行严格的实现审计,逐项对照其 OpenSpec 产物。找出所有不一致、遗漏、回归风险、边界场景缺口和缺失测试;然后直接修复代码、测试以及必要的配套改动。不要停留在报告层面,必须把实现修到与规范完全一致。", - "audit_spec_prompt": "对 {{reference}} 执行严格的规范审计。强化范围定义、需求、验收标准、边界场景、失败模式、依赖关系与可测试性,消除歧义和内部不一致,把规范修到可直接落地实施的质量。不要只给审查意见,直接修规范。", - "implement_prompt": "强力推进 {{reference}} 的实施。把工作拆成明确子任务,给子代理分配清晰所有权,整合输出,逐项补齐与规范的差距,补充或更新测试,并对最终结果负责验收。你负责调度、技术决策、集成收口和最终质量把关。", - "achieve_prompt": "按完整 OpenSpec 工作流把 {{reference}} 推到完成。检查所有剩余任务和产物,完成必要的实现与规范工作,关闭未完成项,必要时同步受影响的主规范,并在满足完成条件后归档该变更。不要只汇报状态,直接把它做完。", - "propose_from_discussion_prompt": "根据最近的讨论生成一个 OpenSpec proposal。提炼目标、范围、关键需求和验收标准;不明确的部分明确列为待确认项。先给出可直接落库的 change proposal 草稿。", - "propose_from_description_prompt": "根据下面的描述生成一个 OpenSpec proposal。整理目标、范围、关键需求和验收标准;缺失信息明确列为待确认项。先给出可直接落库的 change proposal 草稿。" + "audit_implementation_prompt": "对 {{reference}} 执行严格的实现审计,逐项对照其 OpenSpec 产物。找出所有不一致、遗漏、回归风险、边界场景缺口和缺失测试;然后直接修复代码、测试以及必要的配套改动。如果实现推进过程中需要同步变更规范产物,也要在同一次任务里直接更新 {{reference}} 下的 OpenSpec 文件。不要停留在报告层面,必须把实现修到与规范完全一致。", + "audit_spec_prompt": "对 {{reference}} 执行严格的规范审计。强化范围定义、需求、验收标准、边界场景、失败模式、依赖关系与可测试性,消除歧义和内部不一致;然后直接更新 {{reference}} 下的 proposal、design、specs、tasks,直到规范达到可直接落地实施的质量。不要只给审查意见。", + "implement_prompt": "强力推进 {{reference}} 的实施。把工作拆成明确子任务,给子代理分配清晰所有权,整合输出,逐项补齐与规范的差距,补充或更新测试,并对最终结果负责验收。实施过程中要同步维护 {{reference}} 下的 OpenSpec 产物,不要把规范更新留成后续备注。你负责调度、技术决策、集成收口和最终质量把关。", + "achieve_prompt": "按完整 OpenSpec 工作流把 {{reference}} 推到完成。检查所有剩余任务和产物,完成必要的实现与规范工作,按需直接更新 {{reference}} 下的 proposal、design、specs、tasks,关闭未完成项,必要时同步受影响的主规范,并在满足完成条件后归档该变更。不要只汇报状态,直接把它做完。", + "propose_from_discussion_prompt": "根据最近的讨论生成一个 OpenSpec 变更。提炼目标、范围、关键需求和验收标准;不明确的部分明确列为待确认项;并直接把 proposal、design、specs、tasks 写到 openspec/changes/ 下,而不是只停留在草稿说明。", + "propose_from_description_prompt": "根据下面的描述生成一个 OpenSpec 变更。整理目标、范围、关键需求和验收标准;缺失信息明确列为待确认项;并直接把 proposal、design、specs、tasks 写到 openspec/changes/ 下,而不是只停留在草稿说明。" }, "upload": { "upload_file": "上传文件", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 90a2075be..aae0a18d8 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -476,12 +476,12 @@ "propose_action": "Propose", "propose_from_discussion_action": "根據最近討論生成", "propose_from_description_action": "根據下面的描述生成", - "audit_implementation_prompt": "對 {{reference}} 執行嚴格的實作審計,逐項對照其 OpenSpec 產物。找出所有不一致、遺漏、回歸風險、邊界情境缺口與缺失測試;然後直接修復程式碼、測試以及必要的配套改動。不要停留在報告層面,必須把實作修到與規格完全一致。", - "audit_spec_prompt": "對 {{reference}} 執行嚴格的規格審計。強化範圍定義、需求、驗收標準、邊界情境、失敗模式、依賴關係與可測試性,消除歧義與內部不一致,把規格修到可直接落地實作的品質。不要只給審查意見,直接修規格。", - "implement_prompt": "強力推進 {{reference}} 的實作。把工作拆成明確子任務,給子代理分配清楚所有權,整合輸出,逐項補齊與規格的差距,補充或更新測試,並對最終結果負責驗收。你負責調度、技術決策、整合收口與最終品質把關。", - "achieve_prompt": "依照完整 OpenSpec 工作流程把 {{reference}} 推到完成。檢查所有剩餘任務與產物,完成必要的實作與規格工作,關閉未完成項,必要時同步受影響的主規格,並在符合完成條件後封存此變更。不要只回報狀態,直接把它做完。", - "propose_from_discussion_prompt": "根據最近的討論生成一個 OpenSpec proposal。提煉目標、範圍、關鍵需求和驗收標準;不明確的部分明確列為待確認項。先給出可直接落庫的 change proposal 草稿。", - "propose_from_description_prompt": "根據下面的描述生成一個 OpenSpec proposal。整理目標、範圍、關鍵需求和驗收標準;缺失資訊明確列為待確認項。先給出可直接落庫的 change proposal 草稿。" + "audit_implementation_prompt": "對 {{reference}} 執行嚴格的實作審計,逐項對照其 OpenSpec 產物。找出所有不一致、遺漏、回歸風險、邊界情境缺口與缺失測試;然後直接修復程式碼、測試以及必要的配套改動。如果實作推進過程需要同步調整規格產物,也要在同一次任務裡直接更新 {{reference}} 下的 OpenSpec 檔案。不要停留在報告層面,必須把實作修到與規格完全一致。", + "audit_spec_prompt": "對 {{reference}} 執行嚴格的規格審計。強化範圍定義、需求、驗收標準、邊界情境、失敗模式、依賴關係與可測試性,消除歧義與內部不一致;然後直接更新 {{reference}} 下的 proposal、design、specs、tasks,直到規格達到可直接落地實作的品質。不要只給審查意見。", + "implement_prompt": "強力推進 {{reference}} 的實作。把工作拆成明確子任務,給子代理分配清楚所有權,整合輸出,逐項補齊與規格的差距,補充或更新測試,並對最終結果負責驗收。實作過程中要同步維護 {{reference}} 下的 OpenSpec 產物,不要把規格更新留成後續備註。你負責調度、技術決策、整合收口與最終品質把關。", + "achieve_prompt": "依照完整 OpenSpec 工作流程把 {{reference}} 推到完成。檢查所有剩餘任務與產物,完成必要的實作與規格工作,按需直接更新 {{reference}} 下的 proposal、design、specs、tasks,關閉未完成項,必要時同步受影響的主規格,並在符合完成條件後封存此變更。不要只回報狀態,直接把它做完。", + "propose_from_discussion_prompt": "根據最近的討論生成一個 OpenSpec 變更。提煉目標、範圍、關鍵需求和驗收標準;不明確的部分明確列為待確認項;並直接把 proposal、design、specs、tasks 寫到 openspec/changes/ 下,而不是只停留在草稿說明。", + "propose_from_description_prompt": "根據下面的描述生成一個 OpenSpec 變更。整理目標、範圍、關鍵需求和驗收標準;缺失資訊明確列為待確認項;並直接把 proposal、design、specs、tasks 寫到 openspec/changes/ 下,而不是只停留在草稿說明。" }, "upload": { "upload_file": "上傳檔案", diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 9abbf4038..ba92cdca6 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -27,22 +27,22 @@ vi.mock('react-i18next', () => ({ if (key === 'openspec.propose_from_discussion_action') return 'propose_from_discussion_action'; if (key === 'openspec.propose_from_description_action') return 'propose_from_description_action'; if (key === 'openspec.audit_implementation_prompt') { - return `audit implementation ${(opts?.reference as string) ?? ''}, fix code gaps`; + return `audit implementation ${(opts?.reference as string) ?? ''}, fix code gaps and update openspec files`; } if (key === 'openspec.audit_spec_prompt') { - return `audit spec ${(opts?.reference as string) ?? ''}, fix spec gaps`; + return `audit spec ${(opts?.reference as string) ?? ''}, update proposal design specs and tasks`; } if (key === 'openspec.implement_prompt') { - return `delegate ${(opts?.reference as string) ?? ''}, split tasks and accept`; + return `implement ${(opts?.reference as string) ?? ''}, keep openspec artifacts aligned while coding`; } if (key === 'openspec.achieve_prompt') { - return `complete ${(opts?.reference as string) ?? ''}, finish remaining work and archive if done`; + return `complete ${(opts?.reference as string) ?? ''}, update proposal design specs tasks and archive if done`; } if (key === 'openspec.propose_from_discussion_prompt') { - return 'generate openspec proposal from recent discussion'; + return 'generate openspec change from recent discussion and write proposal design specs tasks'; } if (key === 'openspec.propose_from_description_prompt') { - return 'generate openspec proposal from description below'; + return 'generate openspec change from description below and write proposal design specs tasks'; } if (key === 'session.transport_send_queued_collapsed') { return `${opts?.count ?? 0} queued · showing latest only`; @@ -744,7 +744,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'audit_action' })); fireEvent.click(screen.getByRole('button', { name: 'audit_implementation_action' })); - expect(screen.getByRole('textbox').textContent).toBe('audit implementation @openspec/changes/change-a, fix code gaps'); + expect(screen.getByRole('textbox').textContent).toBe('audit implementation @openspec/changes/change-a, fix code gaps and update openspec files'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -773,7 +773,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'audit_action' })); fireEvent.click(screen.getByRole('button', { name: 'audit_spec_action' })); - expect(screen.getByRole('textbox').textContent).toBe('audit spec @openspec/changes/change-a, fix spec gaps'); + expect(screen.getByRole('textbox').textContent).toBe('audit spec @openspec/changes/change-a, update proposal design specs and tasks'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -801,7 +801,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'implement_action' })); - expect(screen.getByRole('textbox').textContent).toBe('delegate @openspec/changes/change-a, split tasks and accept'); + expect(screen.getByRole('textbox').textContent).toBe('implement @openspec/changes/change-a, keep openspec artifacts aligned while coding'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -831,7 +831,7 @@ afterEach(() => { expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { sessionName: 'my-session', - text: 'complete @openspec/changes/change-a, finish remaining work and archive if done', + text: 'complete @openspec/changes/change-a, update proposal design specs tasks and archive if done', }); }); @@ -849,7 +849,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'propose_action' })); fireEvent.click(screen.getByRole('button', { name: 'propose_from_discussion_action' })); - expect(screen.getByRole('textbox').textContent).toBe('generate openspec proposal from recent discussion'); + expect(screen.getByRole('textbox').textContent).toBe('generate openspec change from recent discussion and write proposal design specs tasks'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); @@ -867,7 +867,7 @@ afterEach(() => { fireEvent.click(screen.getByRole('button', { name: 'propose_action' })); fireEvent.click(screen.getByRole('button', { name: 'propose_from_description_action' })); - expect(screen.getByRole('textbox').textContent).toBe('generate openspec proposal from description below'); + expect(screen.getByRole('textbox').textContent).toBe('generate openspec change from description below and write proposal design specs tasks'); expect(ws.sendSessionCommand).not.toHaveBeenCalled(); }); From b09787d631d1a6b18ca638b84dd960c9c9359f1e Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Fri, 10 Apr 2026 09:21:46 +0800 Subject: [PATCH 07/29] Link Windows tool-call file paths --- web/src/components/ChatMarkdown.tsx | 8 ++- web/src/components/ChatView.tsx | 85 ++++++++++++++++++------- web/test/chat-view-tool-format.test.tsx | 44 ++++++++++++- 3 files changed, 112 insertions(+), 25 deletions(-) diff --git a/web/src/components/ChatMarkdown.tsx b/web/src/components/ChatMarkdown.tsx index 52a2e67ce..3559a6f69 100644 --- a/web/src/components/ChatMarkdown.tsx +++ b/web/src/components/ChatMarkdown.tsx @@ -22,7 +22,7 @@ interface Props { /** Returns true if the path has a file extension (not a directory). */ function hasFileExtension(path: string): boolean { - const basename = path.split('/').pop() ?? ''; + const basename = path.split(/[/\\]/).pop() ?? ''; return /\.\w{1,10}$/.test(basename); } @@ -73,6 +73,10 @@ function renderToken( case 'paragraph': { const t = token as Tokens.Paragraph; + const plainEscapedParagraph = !inLink && Array.isArray(t.tokens) && t.tokens.every((child) => child.type === 'text' || child.type === 'escape'); + if (plainEscapedParagraph) { + return

{splitPathsAndUrlsInternal(t.raw, onPathClick, onUrlClick, onDownload)}

; + } return

{renderInlineTokens(t.tokens, onPathClick, onUrlClick, inLink, onDownload)}

; } @@ -250,7 +254,7 @@ function renderToken( // ── URL/Path detection (inline within text tokens) ────────────────────────── const URL_REGEX_INLINE = /https?:\/\/[^\s<>"\])}]+/g; -const PATH_REGEX_INLINE = /(\.{1,2}\/[\w\p{L}.\-~/]+|\/[\w\p{L}.\-~][\w\p{L}.\-~/]*|(? void; } +function hasFileExtension(path: string): boolean { + const basename = path.split(/[/\\]/).pop() ?? ''; + return /\.\w{1,10}$/.test(basename); +} + const TOOL_INPUT_SUMMARY_KEYS = [ 'query', 'command', @@ -808,9 +813,9 @@ export function ChatView({ events, loading, refreshing: _refreshing, loadingOlde onDownload={downloadHandler} /> ) : item.type === 'tool-group' ? ( - + ) : ( - + ); })} {!loading &&
} @@ -1012,7 +1017,17 @@ function ToolBlockFold({ children }: { children: preact.ComponentChildren }) { } /** Collapsible group of consecutive tool events. Shows first and last, folds middle. */ -function ToolCallGroup({ events, onPathClick }: { events: TimelineEvent[]; onPathClick?: (p: string) => void }) { +function ToolCallGroup({ + events, + onPathClick, + onDownload, + serverId, +}: { + events: TimelineEvent[]; + onPathClick?: (p: string) => void; + onDownload?: (path: string) => void; + serverId?: string; +}) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const first = events[0]; @@ -1021,18 +1036,18 @@ function ToolCallGroup({ events, onPathClick }: { events: TimelineEvent[]; onPat return (
- +
{middle.length > 0 && ( expanded ? ( - middle.map((ev) => ) + middle.map((ev) => ) ) : ( ) )} - {last && } + {last && } {expanded && middle.length > 0 && (
); @@ -1143,11 +1170,11 @@ const ChatEvent = memo(function ChatEvent({ event, nextTs, onPathClick, serverId
{'>'} {String(event.payload.tool ?? 'tool')} - {toolInput && {' '}{splitPathsAndUrls(toolInput, onPathClick)}} + {toolInput && {' '}{splitPathsAndUrls(toolInput, onPathClick, undefined, onDownload)}}
{toolOutput && (
- {splitPathsAndUrls(toolOutput, onPathClick)} + {splitPathsAndUrls(toolOutput, onPathClick, undefined, onDownload)}
)} {(callDetail || resultDetail) && ( @@ -1173,9 +1200,9 @@ const ChatEvent = memo(function ChatEvent({ event, nextTs, onPathClick, serverId
{'<'} {error ? ( - {`error: ${String(error)}`} - ) : output ? ( - {splitPathsAndUrls(output, onPathClick)} + {`error: ${String(error)}`} + ) : output ? ( + {splitPathsAndUrls(output, onPathClick, undefined, onDownload)} ) : ( done )} @@ -1296,15 +1323,16 @@ const ChatTime = memo(function ChatTime({ ts }: { ts: number }) { const URL_REGEX = /https?:\/\/[^\s<>"\])}]+/g; // Matches absolute paths (/foo/bar) and relative paths (docs/file.md, src/components/Foo.tsx). -const PATH_REGEX = /(\.{1,2}\/[\w\p{L}.\-~/]+|\/[\w\p{L}.\-~][\w\p{L}.\-~/]*|(? void, onUrlClick?: (url: string) => void, + onDownload?: (path: string) => void, ): h.JSX.Element[] { - if (!onPathClick && !onUrlClick) return [{text}]; + if (!onPathClick && !onUrlClick && !onDownload) return [{text}]; // Step 1: Split by URLs first (URLs take priority over path detection) const parts: preact.JSX.Element[] = []; @@ -1353,13 +1381,26 @@ function splitPathsAndUrls( if (path.length < 3) continue; if (pm.index > pathLast) parts.push({chunk.value.slice(pathLast, pm.index)}); parts.push( - onPathClick(path)} - title={path} - > - {path} + + onPathClick(path)} + title={path} + > + {path} + + {onDownload && hasFileExtension(path) && ( + + )} , ); pathLast = pm.index + pm[0].length; diff --git a/web/test/chat-view-tool-format.test.tsx b/web/test/chat-view-tool-format.test.tsx index 7c068f82f..c1a4f0cf6 100644 --- a/web/test/chat-view-tool-format.test.tsx +++ b/web/test/chat-view-tool-format.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { h } from 'preact'; -import { render, screen, cleanup, fireEvent } from '@testing-library/preact'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/preact'; if (!HTMLElement.prototype.scrollIntoView) { HTMLElement.prototype.scrollIntoView = vi.fn(); @@ -27,8 +27,13 @@ vi.mock('../src/components/FileBrowser.js', () => ({ FileBrowser: () => null, })); +vi.mock('../src/api.js', () => ({ + downloadAttachment: vi.fn().mockResolvedValue(undefined), +})); + import { ChatView } from '../src/components/ChatView.js'; import type { TimelineEvent } from '../src/ws-client.js'; +import { downloadAttachment } from '../src/api.js'; function makeEvent(overrides: Partial & { type: string; payload: Record }): TimelineEvent { return { @@ -146,6 +151,43 @@ describe('ChatView tool payload formatting', () => { expect(screen.getByText('output')).toBeDefined(); }); + it('connects Windows file paths in tool output to preview and download', async () => { + const fsReadFile = vi.fn(() => 'req-win-path'); + const onMessage = vi.fn(() => vi.fn()); + const events = [ + makeEvent({ + type: 'tool.result', + payload: { output: { path: 'C:\\Users\\admin\\screenshot.png' } }, + }), + ]; + + const { container } = render( + , + ); + + const link = container.querySelector('.chat-path-link') as HTMLElement | null; + const button = container.querySelector('.chat-dl-btn') as HTMLButtonElement | null; + expect(link?.textContent).toBe('C:\\Users\\admin\\screenshot.png'); + expect(button).not.toBeNull(); + + fireEvent.click(button!); + + expect(fsReadFile).toHaveBeenCalledWith('C:\\Users\\admin\\screenshot.png'); + onMessage.mock.calls[0][0]({ + type: 'fs.read_response', + requestId: 'req-win-path', + downloadId: 'dl-win-path', + }); + await waitFor(() => { + expect(downloadAttachment).toHaveBeenCalledWith('server-1', 'dl-win-path'); + }); + }); + it('renders OpenClaw transport tool rows for realistic sessions_send payloads', () => { const events = [ makeEvent({ From a144aac69da7abc370e708aa9496800691612f8c Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Fri, 10 Apr 2026 09:49:25 +0800 Subject: [PATCH 08/29] Fix cron chat history sent event --- src/daemon/cron-executor.ts | 6 +++++- test/daemon/cron-executor.test.ts | 33 +++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/daemon/cron-executor.ts b/src/daemon/cron-executor.ts index ae305634e..31c1836b0 100644 --- a/src/daemon/cron-executor.ts +++ b/src/daemon/cron-executor.ts @@ -96,7 +96,10 @@ export async function executeCronJob(msg: CronDispatchMessage, serverLink: Serve const runtime = getTransportRuntime(name); if (runtime) { try { - await runtime.send(action.command); + const result = await runtime.send(action.command); + if (result !== 'queued') { + timelineEmitter.emit(name, 'user.message', { text: action.command, allowDuplicate: true }); + } } catch (err) { logger.error({ jobId, sessionName: name, err }, 'Cron: transport send failed'); sendCommandResult(serverLink, { @@ -119,6 +122,7 @@ export async function executeCronJob(msg: CronDispatchMessage, serverLink: Serve } } else { await sendKeys(name, action.command, { cwd: session.projectDir }); + timelineEmitter.emit(name, 'user.message', { text: action.command, allowDuplicate: true }); } // Capture agent response: collect assistant.text events until session goes idle diff --git a/test/daemon/cron-executor.test.ts b/test/daemon/cron-executor.test.ts index c61021508..5ac9f5c5d 100644 --- a/test/daemon/cron-executor.test.ts +++ b/test/daemon/cron-executor.test.ts @@ -23,12 +23,14 @@ vi.mock('../../src/daemon/p2p-orchestrator.js', () => ({ startP2pRun: vi.fn(), })); -const { timelineOn } = vi.hoisted(() => ({ +const { timelineOn, timelineEmit } = vi.hoisted(() => ({ timelineOn: vi.fn(), + timelineEmit: vi.fn(), })); vi.mock('../../src/daemon/timeline-emitter.js', () => ({ timelineEmitter: { on: timelineOn, + emit: timelineEmit, }, })); @@ -108,6 +110,11 @@ describe('executeCronJob', () => { 'review the codebase', { cwd: '/home/user/myapp' }, ); + expect(timelineEmit).toHaveBeenCalledWith( + 'deck_myapp_brain', + 'user.message', + { text: 'review the codebase', allowDuplicate: true }, + ); }); // 2. Command to streaming session — skips (busy) @@ -213,7 +220,7 @@ describe('executeCronJob', () => { // 10. Transport session — skips busy check, calls runtime.send() it('sends command to transport session via runtime.send(), skipping busy check', async () => { - const mockRuntime = { send: vi.fn().mockResolvedValue(undefined) }; + const mockRuntime = { send: vi.fn().mockReturnValue('sent') }; (getSession as ReturnType).mockReturnValue( makeSession({ runtimeType: 'transport' }), ); @@ -224,6 +231,28 @@ describe('executeCronJob', () => { expect(detectStatusAsync).not.toHaveBeenCalled(); expect(mockRuntime.send).toHaveBeenCalledWith('review the codebase'); expect(sendKeys).not.toHaveBeenCalled(); + expect(timelineEmit).toHaveBeenCalledWith( + 'deck_myapp_brain', + 'user.message', + { text: 'review the codebase', allowDuplicate: true }, + ); + }); + + it('does not emit a user.message when a transport cron command is only queued', async () => { + const mockRuntime = { send: vi.fn().mockReturnValue('queued') }; + (getSession as ReturnType).mockReturnValue( + makeSession({ runtimeType: 'transport' }), + ); + (getTransportRuntime as ReturnType).mockReturnValue(mockRuntime); + + await executeCronJob(makeMsg(), mockServerLink); + + expect(mockRuntime.send).toHaveBeenCalledWith('review the codebase'); + expect(timelineEmit).not.toHaveBeenCalledWith( + 'deck_myapp_brain', + 'user.message', + expect.anything(), + ); }); // 11. Transport session with disconnected provider — skips, logs warning From bd166f426c727eff91cbc4a1fd3c59ef441ac149 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Fri, 10 Apr 2026 11:33:34 +0800 Subject: [PATCH 09/29] Fix startup black screen on slow networks --- web/src/app.tsx | 11 ++++++----- web/src/server-selection.ts | 15 +++++++++++++-- web/test/server-selection.test.ts | 26 ++++++++++++++++++++------ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/web/src/app.tsx b/web/src/app.tsx index 43f311a37..26de01c22 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -67,6 +67,7 @@ import { pickReadableSessionDisplay } from '@shared/session-display.js'; import { updateMainSessionLabel } from './session-label-api.js'; import { getSelectedServerName, + hasResolvedActiveSession, shouldResetSelectedServer, shouldShowInitialConnectingGate, } from './server-selection.js'; @@ -2246,8 +2247,8 @@ export function App() { selectedServerId, connected, sessionsLoaded, - serversLoaded, ); + const resolvedActiveSessionExists = hasResolvedActiveSession(activeSession, sessions); useEffect(() => { if (showInitialConnectingGate) { @@ -2559,7 +2560,7 @@ export function App() { /> {/* Desktop local preview shortcut — available even before a session is active */} - {!isMobile && selectedServerId && !activeSession && ( + {!isMobile && selectedServerId && !resolvedActiveSessionExists && (
', + }} + onConfirm={vi.fn()} + />, + ); + + const toggle = screen.getByTitle('Toggle diff view'); + expect(document.querySelector('.fb-diff')).toBeNull(); + expect(toggle.className).not.toContain('active'); + + await act(async () => { + fireEvent.click(toggle); + }); + + expect(document.querySelector('.fb-diff')).not.toBeNull(); + expect(toggle.className).toContain('active'); + + view.rerender( + diff after
', + }} + onConfirm={vi.fn()} + />, + ); + + expect(document.querySelector('.fb-diff')?.textContent).toContain('diff after'); + expect(screen.getByTitle('Toggle diff view').className).toContain('active'); + }); + it('fetches preview data when a floating preview is hydrated with a loading state', () => { const { ws } = makeWsFactory(); render( From 6cbee1b4d5e0bf3a5873d7422f805f7076aea963 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Sun, 12 Apr 2026 21:09:17 +0800 Subject: [PATCH 25/29] Refine footer thinking status icons --- web/src/components/UsageFooter.tsx | 13 +++++++++---- web/src/styles.css | 14 +++++++++++++- web/test/usage-footer.test.tsx | 31 ++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/web/src/components/UsageFooter.tsx b/web/src/components/UsageFooter.tsx index b2da896ec..e4308af78 100644 --- a/web/src/components/UsageFooter.tsx +++ b/web/src/components/UsageFooter.tsx @@ -95,7 +95,9 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model const monthlyCost = sessionCost > 0 ? getMonthlyCost() : 0; const modelLabel = shortModelLabel(displayModel); const inlineQuotaText = displayQuotaLabel; - const liveStatusMode = sessionState === 'running' ? 'running' : sessionState === 'idle' ? 'idle' : null; + const liveStatusMode = sessionState === 'running' + ? (statusText ? 'tool' : activeThinkingTs ? 'thinking' : 'running') + : sessionState === 'idle' ? 'idle' : null; const liveStatusText = useMemo(() => { if (sessionState === 'running') { if (statusText) return statusText; @@ -105,6 +107,7 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model if (sessionState === 'idle') return 'Agent idle — waiting for input'; return null; }, [activeThinkingTs, now, sessionState, statusText, t]); + const showInlineStatusText = liveStatusMode === 'running' || liveStatusMode === 'thinking' || liveStatusMode === 'tool'; const codexQuotaLines = (agentType === 'codex' || agentType === 'codex-sdk') ? (displayQuotaLabel ?? '').split(' · ').filter(Boolean) : []; @@ -128,9 +131,11 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model {showLiveStatus && liveStatusText && liveStatusMode && ( 🤖 - {liveStatusMode === 'running' - ? ⚙️ - : 💤} + {liveStatusMode === 'running' && ⚙️} + {liveStatusMode === 'thinking' && 💭} + {liveStatusMode === 'tool' && 🔍} + {liveStatusMode === 'idle' && 💤} + {showInlineStatusText && {liveStatusText}} )} diff --git a/web/src/styles.css b/web/src/styles.css index a533b44a7..f5559c6c7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -858,10 +858,13 @@ body { .session-ctx-input { position: absolute; left: 0; top: 0; height: 100%; background: #34d399; border-radius: 3px; } .session-ctx-cache { position: absolute; left: 0; top: 0; height: 100%; background: #818cf8; border-radius: 3px; } .session-usage-stats { display: flex; justify-content: space-between; font-size: 10px; color: #475569; } -.session-live-status-inline { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 20px; color: #818cf8; } +.session-live-status-inline { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 20px; color: #818cf8; min-width: 0; max-width: min(42vw, 240px); } .session-live-status-emoji { display: inline-block; font-size: 12px; line-height: 1; filter: saturate(1.1); } .session-live-status-emoji.robot { transform: translateY(0.2px); } +.session-live-status-text { color: #818cf8; font-size: 10px; line-height: 1.1; margin-left: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.92; } .session-live-status-inline.running .session-live-status-emoji.gear { animation: status-gear-spin 0.8s linear infinite; transform-origin: 50% 50%; } +.session-live-status-inline.thinking .session-live-status-emoji.thought { font-size: 10px; transform: translateY(-2px) translateX(-1px); opacity: 0.94; animation: status-thought-breathe 1.8s ease-in-out infinite; transform-origin: 50% 100%; } +.session-live-status-inline.tool .session-live-status-emoji.tool { animation: status-tool-peek 1.15s ease-in-out infinite; transform-origin: 50% 50%; } .session-live-status-inline.idle .session-live-status-emoji.sleep { font-size: 9px; transform: translateY(-3px) translateX(-1px); opacity: 0.9; animation: status-sleep-breathe 1.8s ease-in-out infinite; transform-origin: 50% 100%; } .session-usage-model { color: #a78bfa; font-size: 10px; font-weight: 500; margin-right: 6px; } .session-usage-tokens { color: #64748b; } @@ -869,6 +872,15 @@ body { .session-usage-quota-inline { color: #64748b; font-size: 9px; line-height: 1.4; white-space: nowrap; } .session-usage-cost { color: #94a3b8; } @keyframes status-gear-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +@keyframes status-thought-breathe { + 0%, 100% { transform: translateY(-2px) translateX(-1px) scale(0.9); opacity: 0.72; } + 50% { transform: translateY(-3px) translateX(-1px) scale(1.06); opacity: 1; } +} +@keyframes status-tool-peek { + 0%, 100% { transform: translateX(0) rotate(0deg) scale(0.98); } + 35% { transform: translateX(0.5px) rotate(-8deg) scale(1.03); } + 65% { transform: translateX(0.5px) rotate(8deg) scale(1.03); } +} @keyframes status-sleep-breathe { 0%, 100% { transform: translateY(-3px) translateX(-1px) scale(0.88); opacity: 0.72; } 50% { transform: translateY(-4px) translateX(-1px) scale(1.08); opacity: 1; } diff --git a/web/test/usage-footer.test.tsx b/web/test/usage-footer.test.tsx index 299cd34e2..cb0c2578f 100644 --- a/web/test/usage-footer.test.tsx +++ b/web/test/usage-footer.test.tsx @@ -34,7 +34,7 @@ afterEach(() => { }); describe('UsageFooter', () => { - it('renders emoji live status inline with footer stats and only animates while running', () => { + it('renders idle, thinking, and running live status inline with footer stats', () => { const { container, rerender } = render( { expect(idleStatus?.getAttribute('aria-label')).toContain('Agent idle'); expect(container.querySelector('.chat-thinking-dots')).toBeNull(); expect(container.querySelector('.session-live-status-inline.idle .session-live-status-emoji.sleep')).toBeTruthy(); + expect(container.querySelector('.session-live-status-text')).toBeNull(); rerender( { const runningStatus = container.querySelector('.session-live-status-inline') as HTMLSpanElement | null; expect(runningStatus?.textContent).toContain('🤖'); - expect(runningStatus?.textContent).toContain('⚙️'); + expect(runningStatus?.textContent).toContain('💭'); expect(runningStatus?.getAttribute('aria-label')).toContain('thinking'); - expect(container.querySelector('.session-live-status-inline.running')).toBeTruthy(); + expect(container.querySelector('.session-live-status-inline.thinking')).toBeTruthy(); + expect(container.querySelector('.session-live-status-inline.thinking .session-live-status-emoji.thought')).toBeTruthy(); + expect(container.querySelector('.session-live-status-text')?.textContent).toContain('thinking'); + + rerender( + , + ); + + const plainRunningStatus = container.querySelector('.session-live-status-inline') as HTMLSpanElement | null; + expect(plainRunningStatus?.textContent).toContain('🤖'); + expect(plainRunningStatus?.textContent).toContain('⚙️'); expect(container.querySelector('.session-live-status-inline.running .session-live-status-emoji.gear')).toBeTruthy(); }); - it('prefers explicit running status text in the live status row', () => { + it('shows tool-call icon when explicit running status text is present', () => { const { container } = render( { />, ); + expect((container.querySelector('.session-live-status-inline') as HTMLSpanElement | null)?.textContent).toContain('🔍'); + expect(container.querySelector('.session-live-status-inline.tool .session-live-status-emoji.tool')).toBeTruthy(); + expect(container.querySelector('.session-live-status-text')?.textContent).toBe('Reading file...'); expect((container.querySelector('.session-live-status-inline') as HTMLSpanElement | null)?.getAttribute('aria-label')).toBe('Reading file...'); }); From e01ee66457caff6c7b6088371bb5303c53a89c51 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Sun, 12 Apr 2026 21:38:35 +0800 Subject: [PATCH 26/29] Tighten live status and upgrade guards --- src/daemon/command-handler.ts | 27 ++++++++++++++++ test/daemon/command-handler-stop.test.ts | 30 ++++++++++++++++-- test/daemon/daemon-upgrade-guard.test.ts | 39 ++++++++++++++++++++++-- test/daemon/file-search.test.ts | 11 ++++++- web/src/app.tsx | 5 ++- web/src/components/SessionPane.tsx | 4 ++- web/src/components/SubSessionWindow.tsx | 4 ++- web/src/components/UsageFooter.tsx | 10 +++--- web/src/components/pinnedPanelTypes.tsx | 4 ++- web/src/i18n/locales/en.json | 3 +- web/src/i18n/locales/zh-CN.json | 3 +- web/src/i18n/locales/zh-TW.json | 3 +- web/src/thinking-utils.ts | 19 ++++++++++++ web/src/ws-client.ts | 1 + web/test/thinking-utils.test.ts | 36 ++++++++++++++-------- web/test/usage-footer.test.tsx | 1 + web/test/ws-client.test.ts | 11 +++++++ 17 files changed, 183 insertions(+), 28 deletions(-) diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index 56e0dfa95..03244c177 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -2360,6 +2360,23 @@ async function handleDaemonUpgrade(targetVersion?: string, serverLink?: ServerLi return; } + const activeTransportSessions = getActiveTransportSessionsBlockingDaemonUpgrade(); + if (activeTransportSessions.length > 0) { + logger.warn({ + targetVersion, + activeSessionNames: activeTransportSessions.map((session) => session.name), + activeSessionStates: activeTransportSessions.map((session) => session.state), + }, 'daemon.upgrade: blocked because transport sessions have active turns'); + try { + serverLink?.send({ + type: DAEMON_MSG.UPGRADE_BLOCKED, + reason: 'transport_busy', + activeSessionNames: activeTransportSessions.map((session) => session.name), + }); + } catch { /* ignore */ } + return; + } + const { spawn } = await import('child_process'); const { writeFileSync, mkdtempSync, existsSync } = await import('fs'); const { join, dirname } = await import('path'); @@ -2564,6 +2581,15 @@ export function getActiveP2pRunsBlockingDaemonUpgrade(runs = listP2pRuns()) { return runs.filter((run) => !P2P_TERMINAL_RUN_STATUSES.has(run.status)); } +export function getActiveTransportSessionsBlockingDaemonUpgrade(sessions = listSessions()) { + return sessions.filter((session) => { + if (session.runtimeType !== 'transport') return false; + const runtime = getTransportRuntime(session.name); + if (!runtime) return false; + return runtime.getStatus() !== 'idle' || runtime.sending || runtime.pendingCount > 0; + }); +} + async function handleFileSearch(cmd: Record, serverLink: ServerLink): Promise { const query = (cmd.query as string ?? '').trim(); const projectDir = cmd.projectDir as string | undefined; @@ -2602,6 +2628,7 @@ async function handleFileSearch(cmd: Record, serverLink: Server const fzf = new Fzf(allPaths, { fuzzy: allPaths.length > 20000 ? 'v1' : 'v2', forward: false, + casing: 'case-insensitive', tiebreakers: [fileSearchByBasenamePrefix, fileSearchByMatchPosFromEnd, fileSearchByLengthAsc], }); const results = fzf.find(query); diff --git a/test/daemon/command-handler-stop.test.ts b/test/daemon/command-handler-stop.test.ts index 71f8f253e..2a1ca0c8f 100644 --- a/test/daemon/command-handler-stop.test.ts +++ b/test/daemon/command-handler-stop.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock, buildSessionListMock } = vi.hoisted(() => ({ +const { stopProjectMock, stopSubSessionMock, loggerErrorMock, loggerWarnMock, buildSessionListMock, getTransportRuntimeMock } = vi.hoisted(() => ({ stopProjectMock: vi.fn(), stopSubSessionMock: vi.fn().mockResolvedValue({ ok: true, closed: ['deck_sub_worker'], failed: [] }), loggerErrorMock: vi.fn(), loggerWarnMock: vi.fn(), buildSessionListMock: vi.fn(async () => []), + getTransportRuntimeMock: vi.fn(() => undefined), })); vi.mock('../../src/store/session-store.js', () => ({ @@ -19,7 +20,7 @@ vi.mock('../../src/agent/session-manager.js', () => ({ startProject: vi.fn(), stopProject: stopProjectMock, teardownProject: vi.fn(), - getTransportRuntime: vi.fn(() => undefined), + getTransportRuntime: getTransportRuntimeMock, launchTransportSession: vi.fn(), isProviderSessionBound: vi.fn(() => false), persistSessionRecord: vi.fn(), @@ -170,6 +171,31 @@ describe('handleWebCommand shutdown failure paths', () => { }); }); + it('blocks daemon.upgrade when a transport session still has an active turn', async () => { + const { listSessions } = await import('../../src/store/session-store.js'); + vi.mocked(listSessions).mockReturnValue([ + { + name: 'deck_proj_brain', + runtimeType: 'transport', + state: 'running', + } as any, + ]); + getTransportRuntimeMock.mockReturnValue({ + getStatus: () => 'thinking', + sending: true, + pendingCount: 0, + } as any); + + handleWebCommand({ type: 'daemon.upgrade' }, serverLink as any); + await flushAsync(); + + expect(serverLink.send).toHaveBeenCalledWith({ + type: 'daemon.upgrade_blocked', + reason: 'transport_busy', + activeSessionNames: ['deck_proj_brain'], + }); + }); + it('updates the main-session project name and pushes a refreshed session_list on session.rename', async () => { const { getSession, upsertSession } = await import('../../src/store/session-store.js'); vi.mocked(getSession).mockReturnValue({ diff --git a/test/daemon/daemon-upgrade-guard.test.ts b/test/daemon/daemon-upgrade-guard.test.ts index b1a46c1b0..71e85a53a 100644 --- a/test/daemon/daemon-upgrade-guard.test.ts +++ b/test/daemon/daemon-upgrade-guard.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getActiveP2pRunsBlockingDaemonUpgrade } from '../../src/daemon/command-handler.js'; +import { getActiveP2pRunsBlockingDaemonUpgrade, getActiveTransportSessionsBlockingDaemonUpgrade } from '../../src/daemon/command-handler.js'; +import * as sessionManager from '../../src/agent/session-manager.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe('getActiveP2pRunsBlockingDaemonUpgrade', () => { it('returns active runs that should block daemon upgrades', () => { @@ -25,3 +30,33 @@ describe('getActiveP2pRunsBlockingDaemonUpgrade', () => { expect(blocked).toEqual([]); }); }); + +describe('getActiveTransportSessionsBlockingDaemonUpgrade', () => { + it('returns transport sessions that still have active turns', () => { + vi.spyOn(sessionManager, 'getTransportRuntime').mockImplementation((name: string) => { + if (name === 'deck_proj_brain') { + return { + getStatus: () => 'thinking', + sending: true, + pendingCount: 1, + } as any; + } + if (name === 'deck_proj_idle') { + return { + getStatus: () => 'idle', + sending: false, + pendingCount: 0, + } as any; + } + return undefined; + }); + + const blocked = getActiveTransportSessionsBlockingDaemonUpgrade([ + { name: 'deck_proj_brain', runtimeType: 'transport' }, + { name: 'deck_proj_idle', runtimeType: 'transport' }, + { name: 'deck_proj_worker', runtimeType: 'process' }, + ] as any); + + expect(blocked.map((session) => session.name)).toEqual(['deck_proj_brain']); + }); +}); diff --git a/test/daemon/file-search.test.ts b/test/daemon/file-search.test.ts index a4a52409c..80ffafc9b 100644 --- a/test/daemon/file-search.test.ts +++ b/test/daemon/file-search.test.ts @@ -111,12 +111,21 @@ describe('fzf integration', () => { it('handles case insensitive matching', async () => { const { Fzf } = await import('fzf'); const paths = ['src/ChatView.tsx', 'src/chatview.ts']; - const fzf = new Fzf(paths, { fuzzy: 'v2', forward: false, tiebreakers: [] }); + const fzf = new Fzf(paths, { fuzzy: 'v2', forward: false, casing: 'case-insensitive', tiebreakers: [] }); const results = fzf.find('chatview').map((r) => r.item); expect(results.length).toBe(2); }); + it('keeps matching case-insensitive even when query contains uppercase letters', async () => { + const { Fzf } = await import('fzf'); + const paths = ['src/chatview.ts', 'src/components/chat-tools.ts']; + const fzf = new Fzf(paths, { fuzzy: 'v2', forward: false, casing: 'case-insensitive', tiebreakers: [] }); + + const results = fzf.find('ChatView').map((r) => r.item); + expect(results).toContain('src/chatview.ts'); + }); + it('matches across path segments', async () => { const { Fzf } = await import('fzf'); const paths = [ diff --git a/web/src/app.tsx b/web/src/app.tsx index 2c07ba17a..b96dc4971 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1599,6 +1599,9 @@ export function App() { } } if (msg.type === DAEMON_MSG.UPGRADE_BLOCKED) { + const message = msg.reason === 'transport_busy' + ? trans('toast.upgrade_blocked_transport_busy') + : trans('toast.upgrade_blocked_p2p_active'); const id = Date.now() + Math.random(); setToasts((prev) => [...prev, { id, @@ -1606,7 +1609,7 @@ export function App() { project: '', kind: 'notification', title: trans('toast.upgrade_blocked_title'), - message: trans('toast.upgrade_blocked_p2p_active'), + message, }]); setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== id)), 8000); } diff --git a/web/src/components/SessionPane.tsx b/web/src/components/SessionPane.tsx index c54f63b47..1b21cf21b 100644 --- a/web/src/components/SessionPane.tsx +++ b/web/src/components/SessionPane.tsx @@ -12,7 +12,7 @@ import { ChatView } from './ChatView.js'; import { SessionControls } from './SessionControls.js'; import { UsageFooter } from './UsageFooter.js'; import { useTimeline } from '../hooks/useTimeline.js'; -import { getActiveThinkingTs, getActiveStatusText } from '../thinking-utils.js'; +import { getActiveThinkingTs, getActiveStatusText, hasActiveToolCall } from '../thinking-utils.js'; import { recordCost } from '../cost-tracker.js'; import type { UseQuickDataResult } from './QuickInputPanel.js'; import { formatLabel } from '../format-label.js'; @@ -140,6 +140,7 @@ export function SessionPane({ const activeThinkingTs = useMemo(() => getActiveThinkingTs(timelineEvents), [timelineEvents]); const statusText = useMemo(() => getActiveStatusText(timelineEvents), [timelineEvents]); + const activeToolCall = useMemo(() => hasActiveToolCall(timelineEvents), [timelineEvents]); const shouldShowFooter = !!( lastUsage || activeThinkingTs @@ -262,6 +263,7 @@ export function SessionPane({ showCost={!!lastCostEvent} activeThinkingTs={activeThinkingTs} statusText={statusText} + activeToolCall={activeToolCall} now={thinkingNow} /> )} diff --git a/web/src/components/SubSessionWindow.tsx b/web/src/components/SubSessionWindow.tsx index 2cd457558..0f221a6e4 100644 --- a/web/src/components/SubSessionWindow.tsx +++ b/web/src/components/SubSessionWindow.tsx @@ -4,7 +4,7 @@ */ import { useState, useRef, useCallback, useEffect, useMemo } from 'preact/hooks'; import { useTranslation } from 'react-i18next'; -import { getActiveThinkingTs, getActiveStatusText } from '../thinking-utils.js'; +import { getActiveThinkingTs, getActiveStatusText, hasActiveToolCall } from '../thinking-utils.js'; import { recordCost } from '../cost-tracker.js'; import { formatLabel } from '../format-label.js'; import { TerminalView } from './TerminalView.js'; @@ -107,6 +107,7 @@ export function SubSessionWindow({ // Extract active agent status (e.g. "Reading file...") const statusText = useMemo(() => getActiveStatusText(events), [events]); + const activeToolCall = useMemo(() => hasActiveToolCall(events), [events]); const [quotes, setQuotes] = useState([]); const addQuote = useCallback((text: string) => setQuotes((prev) => [...prev, text]), []); @@ -413,6 +414,7 @@ export function SubSessionWindow({ showCost={!!lastCostEvent} activeThinkingTs={activeThinkingTs} statusText={statusText} + activeToolCall={activeToolCall} now={thinkingNow} /> )} diff --git a/web/src/components/UsageFooter.tsx b/web/src/components/UsageFooter.tsx index e4308af78..f170e1d59 100644 --- a/web/src/components/UsageFooter.tsx +++ b/web/src/components/UsageFooter.tsx @@ -26,6 +26,8 @@ interface Props { activeThinkingTs?: number | null; /** Status text from agent (e.g. "Reading file..."). */ statusText?: string | null; + /** Whether the current live tail is an active tool call. */ + activeToolCall?: boolean; /** Current timestamp for thinking timer (updated every second). */ now?: number; } @@ -35,7 +37,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, now }: Props) { +export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, activeToolCall, now }: Props) { const { t } = useTranslation(); const isCodexFamily = agentType === 'codex' || agentType === 'codex-sdk'; const showLiveStatus = sessionState === 'running' || sessionState === 'idle'; @@ -96,17 +98,17 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model const modelLabel = shortModelLabel(displayModel); const inlineQuotaText = displayQuotaLabel; const liveStatusMode = sessionState === 'running' - ? (statusText ? 'tool' : activeThinkingTs ? 'thinking' : 'running') + ? (activeToolCall ? 'tool' : activeThinkingTs ? 'thinking' : 'running') : sessionState === 'idle' ? 'idle' : null; const liveStatusText = useMemo(() => { if (sessionState === 'running') { - if (statusText) return statusText; + if (activeToolCall) return statusText || 'Tool running...'; if (activeThinkingTs) return t('chat.thinking_running', { sec: Math.max(0, Math.round(((now ?? Date.now()) - activeThinkingTs) / 1000)) }); return 'Agent working...'; } if (sessionState === 'idle') return 'Agent idle — waiting for input'; return null; - }, [activeThinkingTs, now, sessionState, statusText, t]); + }, [activeThinkingTs, activeToolCall, now, sessionState, statusText, t]); const showInlineStatusText = liveStatusMode === 'running' || liveStatusMode === 'thinking' || liveStatusMode === 'tool'; const codexQuotaLines = (agentType === 'codex' || agentType === 'codex-sdk') ? (displayQuotaLabel ?? '').split(' · ').filter(Boolean) diff --git a/web/src/components/pinnedPanelTypes.tsx b/web/src/components/pinnedPanelTypes.tsx index 4a92e4191..14e8e285c 100644 --- a/web/src/components/pinnedPanelTypes.tsx +++ b/web/src/components/pinnedPanelTypes.tsx @@ -14,7 +14,7 @@ import { useMemo } from 'preact/hooks'; import { useTranslation } from 'react-i18next'; import { UsageFooter } from './UsageFooter.js'; import { extractLatestUsage } from '../usage-data.js'; -import { getActiveThinkingTs, getActiveStatusText } from '../thinking-utils.js'; +import { getActiveThinkingTs, getActiveStatusText, hasActiveToolCall } from '../thinking-utils.js'; import { useNowTicker } from '../hooks/useNowTicker.js'; import type { PinnedPanel } from '../app.js'; import type { PanelRenderContext } from './PinnedPanelRegistry.js'; @@ -38,6 +38,7 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende const lastUsage = useMemo(() => extractLatestUsage(events), [events]); const activeThinkingTs = useMemo(() => getActiveThinkingTs(events), [events]); const statusText = useMemo(() => getActiveStatusText(events), [events]); + const activeToolCall = useMemo(() => hasActiveToolCall(events), [events]); const thinkingNow = useNowTicker(!!activeThinkingTs); if (!liveSub) { @@ -82,6 +83,7 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende showCost={false} activeThinkingTs={activeThinkingTs} statusText={statusText} + activeToolCall={activeToolCall} now={thinkingNow} /> )} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7e0ac7945..24bc2e366 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -182,7 +182,8 @@ "toast": { "finished": "finished", "upgrade_blocked_title": "Upgrade blocked", - "upgrade_blocked_p2p_active": "P2P is still running. Stop it before upgrading the daemon." + "upgrade_blocked_p2p_active": "P2P is still running. Stop it before upgrading the daemon.", + "upgrade_blocked_transport_busy": "A transport session is still in a turn. Wait until it goes idle before upgrading the daemon." }, "discussion": { "role_critic": "Critic", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 63a49453a..9a6c0cd1c 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -182,7 +182,8 @@ "toast": { "finished": "完成了", "upgrade_blocked_title": "升级已阻止", - "upgrade_blocked_p2p_active": "P2P 仍在运行。请先停止,再升级 daemon。" + "upgrade_blocked_p2p_active": "P2P 仍在运行。请先停止,再升级 daemon。", + "upgrade_blocked_transport_busy": "还有 transport session 正在 turn 中。等它回到 idle 再升级 daemon。" }, "discussion": { "role_critic": "批判者", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index aae0a18d8..a647bbb3c 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -182,7 +182,8 @@ "toast": { "finished": "已完成", "upgrade_blocked_title": "升級已阻止", - "upgrade_blocked_p2p_active": "P2P 仍在執行中。請先停止,再升級 daemon。" + "upgrade_blocked_p2p_active": "P2P 仍在執行中。請先停止,再升級 daemon。", + "upgrade_blocked_transport_busy": "還有 transport session 正在 turn 中。等它回到 idle 再升級 daemon。" }, "discussion": { "role_critic": "評論者", diff --git a/web/src/thinking-utils.ts b/web/src/thinking-utils.ts index a6fb5896b..8daef0126 100644 --- a/web/src/thinking-utils.ts +++ b/web/src/thinking-utils.ts @@ -66,6 +66,25 @@ export function getActiveStatusText(events: Array<{ type: string; payload?: Reco return null; } +/** + * Detect whether the current live tail is inside an active tool call. + * Only a trailing tool.call counts. A trailing tool.result means the tool already finished. + */ +export function hasActiveToolCall(events: Array<{ type: string; payload?: Record }>): boolean { + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e.type === 'tool.call') return true; + if (e.type === 'tool.result') return false; + if (e.type === 'session.state') { + if (e.payload?.state === 'idle') return false; + continue; + } + if (e.type === 'assistant.thinking' || THINKING_SKIP_TYPES.has(e.type)) continue; + return false; + } + return false; +} + export function isRunningSessionState(sessionState: string | undefined): boolean { return sessionState === 'running'; } diff --git a/web/src/ws-client.ts b/web/src/ws-client.ts index 2eb616428..5ed98b36f 100644 --- a/web/src/ws-client.ts +++ b/web/src/ws-client.ts @@ -30,6 +30,7 @@ export type ServerMessage = | { type: typeof DAEMON_MSG.RECONNECTED } | { type: typeof DAEMON_MSG.DISCONNECTED } | { type: typeof DAEMON_MSG.UPGRADE_BLOCKED; reason: 'p2p_active'; activeRunIds?: string[] } + | { type: typeof DAEMON_MSG.UPGRADE_BLOCKED; reason: 'transport_busy'; activeSessionNames?: string[] } | { type: 'daemon.error'; kind: 'uncaughtException' | 'unhandledRejection' | 'warning'; message: string; stack?: string; ts: number } | { type: 'session_list'; daemonVersion?: string | null; sessions: Array<{ name: string; project: string; role: string; agentType: string; agentVersion?: string; state: string; projectDir?: string; runtimeType?: 'process' | 'transport'; label?: string; description?: string; qwenModel?: string; requestedModel?: string; activeModel?: string; qwenAuthType?: string; qwenAuthLimit?: string; qwenAvailableModels?: string[]; modelDisplay?: string; planLabel?: string; permissionLabel?: string; quotaLabel?: string; quotaUsageLabel?: string; quotaMeta?: import('../../shared/provider-quota.js').ProviderQuotaMeta | null; effort?: import('../../shared/effort-levels.js').TransportEffortLevel }> } | { type: 'outbound'; platform: string; channelId: string; content: string } diff --git a/web/test/thinking-utils.test.ts b/web/test/thinking-utils.test.ts index fd61cb4cb..8bb04f00c 100644 --- a/web/test/thinking-utils.test.ts +++ b/web/test/thinking-utils.test.ts @@ -1,19 +1,31 @@ import { describe, expect, it } from 'vitest'; -import { getActiveStatusText } from '../src/thinking-utils.js'; +import { hasActiveToolCall } from '../src/thinking-utils.js'; -describe('getActiveStatusText', () => { - it('returns the latest trailing status label', () => { - expect(getActiveStatusText([ - { type: 'assistant.text', payload: { text: 'done' } }, - { type: 'agent.status', payload: { status: 'compacting', label: 'Compacting conversation...' } }, - ])).toBe('Compacting conversation...'); +describe('hasActiveToolCall', () => { + it('does not treat trailing agent.status during thinking as a tool call', () => { + expect(hasActiveToolCall([ + { type: 'assistant.thinking', ts: 1 }, + { type: 'agent.status', payload: { label: 'thinking 4s' } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe(false); }); - it('treats an unlabeled trailing status as an explicit clear', () => { - expect(getActiveStatusText([ - { type: 'agent.status', payload: { status: 'compacting', label: 'Compacting conversation...' } }, - { type: 'agent.status', payload: { status: null, label: null } }, - ])).toBeNull(); + it('treats a trailing tool.call as active', () => { + expect(hasActiveToolCall([ + { type: 'assistant.thinking', ts: 1 }, + { type: 'tool.call', payload: { tool: 'Read' } }, + { type: 'agent.status', payload: { label: 'Reading file...' } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe(true); + }); + + it('does not treat a completed tool.result as an active tool call', () => { + expect(hasActiveToolCall([ + { type: 'tool.call', payload: { tool: 'Read' } }, + { type: 'tool.result', payload: { ok: true } }, + { type: 'agent.status', payload: { label: 'thinking 1s' } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe(false); }); }); diff --git a/web/test/usage-footer.test.tsx b/web/test/usage-footer.test.tsx index cb0c2578f..fbee81208 100644 --- a/web/test/usage-footer.test.tsx +++ b/web/test/usage-footer.test.tsx @@ -110,6 +110,7 @@ describe('UsageFooter', () => { sessionName="deck_test_brain" sessionState="running" statusText="Reading file..." + activeToolCall={true} />, ); diff --git a/web/test/ws-client.test.ts b/web/test/ws-client.test.ts index 8f9d455fd..f6e204b58 100644 --- a/web/test/ws-client.test.ts +++ b/web/test/ws-client.test.ts @@ -332,6 +332,17 @@ describe('WsClient', () => { expect(handler).toHaveBeenCalledWith({ type: DAEMON_MSG.UPGRADE_BLOCKED, reason: 'p2p_active', activeRunIds: ['run_1'] }); client.disconnect(); }); + + it('dispatches daemon.upgrade_blocked transport_busy to handlers', async () => { + const client = await connectClient(); + const handler = vi.fn(); + client.onMessage(handler); + handler.mockClear(); + + lastWs!.emit('message', { data: JSON.stringify({ type: DAEMON_MSG.UPGRADE_BLOCKED, reason: 'transport_busy', activeSessionNames: ['deck_proj_brain'] }) }); + expect(handler).toHaveBeenCalledWith({ type: DAEMON_MSG.UPGRADE_BLOCKED, reason: 'transport_busy', activeSessionNames: ['deck_proj_brain'] }); + client.disconnect(); + }); }); // ── fsListDir ───────────────────────────────────────────────────────── From 914783889ec008a74b9e215c0bc8b713484478fd Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Sun, 12 Apr 2026 21:43:24 +0800 Subject: [PATCH 27/29] Fix web test mock for live status helper --- web/test/components/SessionPane.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/test/components/SessionPane.test.tsx b/web/test/components/SessionPane.test.tsx index 4fc657058..486b54035 100644 --- a/web/test/components/SessionPane.test.tsx +++ b/web/test/components/SessionPane.test.tsx @@ -30,6 +30,7 @@ vi.mock('../../src/hooks/useTimeline.js', () => ({ vi.mock('../../src/thinking-utils.js', () => ({ getActiveThinkingTs: () => null, getActiveStatusText: () => null, + hasActiveToolCall: () => false, })); vi.mock('../../src/cost-tracker.js', () => ({ recordCost: vi.fn() })); vi.mock('../../src/format-label.js', () => ({ formatLabel: (x: string) => x })); From 9cd84087500add8d4aec69cad52a63479287b807 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Mon, 13 Apr 2026 09:14:06 +0800 Subject: [PATCH 28/29] Prevent stale idle status during active turns --- web/src/components/UsageFooter.tsx | 15 +++++++++------ web/src/timeline-running.ts | 1 + web/test/timeline-running.test.ts | 28 ++++++++-------------------- web/test/usage-footer.test.tsx | 15 +++++++-------- 4 files changed, 25 insertions(+), 34 deletions(-) diff --git a/web/src/components/UsageFooter.tsx b/web/src/components/UsageFooter.tsx index f170e1d59..a9bb9a3ae 100644 --- a/web/src/components/UsageFooter.tsx +++ b/web/src/components/UsageFooter.tsx @@ -40,7 +40,8 @@ const fmt = (n: number) => export function UsageFooter({ usage, sessionName, sessionState, agentType, modelOverride, planLabel, quotaLabel, quotaUsageLabel, quotaMeta, showCost, activeThinkingTs, statusText, activeToolCall, now }: Props) { const { t } = useTranslation(); const isCodexFamily = agentType === 'codex' || agentType === 'codex-sdk'; - const showLiveStatus = sessionState === 'running' || sessionState === 'idle'; + const hasActiveLiveWork = !!activeToolCall || !!activeThinkingTs; + const showLiveStatus = sessionState === 'running' || sessionState === 'idle' || hasActiveLiveWork; const [quotaNow, setQuotaNow] = useState(() => Date.now()); const displayModel = modelOverride ?? usage.model; @@ -97,18 +98,20 @@ export function UsageFooter({ usage, sessionName, sessionState, agentType, model const monthlyCost = sessionCost > 0 ? getMonthlyCost() : 0; const modelLabel = shortModelLabel(displayModel); const inlineQuotaText = displayQuotaLabel; - const liveStatusMode = sessionState === 'running' - ? (activeToolCall ? 'tool' : activeThinkingTs ? 'thinking' : 'running') - : sessionState === 'idle' ? 'idle' : null; + const liveStatusMode = hasActiveLiveWork + ? (activeToolCall ? 'tool' : 'thinking') + : sessionState === 'running' + ? 'running' + : sessionState === 'idle' ? 'idle' : null; const liveStatusText = useMemo(() => { - if (sessionState === 'running') { + if (hasActiveLiveWork || sessionState === 'running') { if (activeToolCall) return statusText || 'Tool running...'; if (activeThinkingTs) return t('chat.thinking_running', { sec: Math.max(0, Math.round(((now ?? Date.now()) - activeThinkingTs) / 1000)) }); return 'Agent working...'; } if (sessionState === 'idle') return 'Agent idle — waiting for input'; return null; - }, [activeThinkingTs, activeToolCall, now, sessionState, statusText, t]); + }, [activeThinkingTs, activeToolCall, hasActiveLiveWork, now, sessionState, statusText, t]); const showInlineStatusText = liveStatusMode === 'running' || liveStatusMode === 'thinking' || liveStatusMode === 'tool'; const codexQuotaLines = (agentType === 'codex' || agentType === 'codex-sdk') ? (displayQuotaLabel ?? '').split(' · ').filter(Boolean) diff --git a/web/src/timeline-running.ts b/web/src/timeline-running.ts index 90a710feb..2f3f7043f 100644 --- a/web/src/timeline-running.ts +++ b/web/src/timeline-running.ts @@ -1,6 +1,7 @@ import type { TimelineEvent } from '../../src/shared/timeline/types.js'; const RUNNING_TIMELINE_EVENT_TYPES = new Set([ + 'assistant.thinking', 'assistant.text', 'tool.call', 'tool.result', diff --git a/web/test/timeline-running.test.ts b/web/test/timeline-running.test.ts index decd21386..9924c1a55 100644 --- a/web/test/timeline-running.test.ts +++ b/web/test/timeline-running.test.ts @@ -1,26 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { isIdleSessionStateTimelineEvent, isRunningTimelineEvent } from '../src/timeline-running.js'; -describe('timeline session activity helpers', () => { - it('treats assistant and tool events as running signals', () => { - expect(isRunningTimelineEvent({ type: 'assistant.text' } as any)).toBe(true); - expect(isRunningTimelineEvent({ type: 'tool.call' } as any)).toBe(true); - expect(isRunningTimelineEvent({ type: 'tool.result' } as any)).toBe(true); - expect(isRunningTimelineEvent({ type: 'assistant.thinking' } as any)).toBe(false); +import { isRunningTimelineEvent } from '../src/timeline-running.js'; + +describe('isRunningTimelineEvent', () => { + it('treats assistant.thinking as a running signal', () => { + expect(isRunningTimelineEvent({ type: 'assistant.thinking' } as any)).toBe(true); }); - it('treats realtime session.state idle as an idle flash signal', () => { - expect(isIdleSessionStateTimelineEvent({ - type: 'session.state', - payload: { state: 'idle' }, - } as any)).toBe(true); - expect(isIdleSessionStateTimelineEvent({ - type: 'session.state', - payload: { state: 'running' }, - } as any)).toBe(false); - expect(isIdleSessionStateTimelineEvent({ - type: 'assistant.text', - payload: { text: 'done' }, - } as any)).toBe(false); + it('keeps tool.call and assistant.text as running signals', () => { + expect(isRunningTimelineEvent({ type: 'tool.call' } as any)).toBe(true); + expect(isRunningTimelineEvent({ type: 'assistant.text' } as any)).toBe(true); }); }); diff --git a/web/test/usage-footer.test.tsx b/web/test/usage-footer.test.tsx index fbee81208..454bb0ead 100644 --- a/web/test/usage-footer.test.tsx +++ b/web/test/usage-footer.test.tsx @@ -34,7 +34,7 @@ afterEach(() => { }); describe('UsageFooter', () => { - it('renders idle, thinking, and running live status inline with footer stats', () => { + it('prioritizes active thinking over stale idle state and renders running states inline', () => { const { container, rerender } = render( { />, ); - const idleStatus = container.querySelector('.session-live-status-inline') as HTMLSpanElement | null; - expect(idleStatus?.textContent).toContain('🤖'); - expect(idleStatus?.textContent).toContain('💤'); - expect(idleStatus?.getAttribute('aria-label')).toContain('Agent idle'); - expect(container.querySelector('.chat-thinking-dots')).toBeNull(); - expect(container.querySelector('.session-live-status-inline.idle .session-live-status-emoji.sleep')).toBeTruthy(); - expect(container.querySelector('.session-live-status-text')).toBeNull(); + const staleIdleStatus = container.querySelector('.session-live-status-inline') as HTMLSpanElement | null; + expect(staleIdleStatus?.textContent).toContain('🤖'); + expect(staleIdleStatus?.textContent).toContain('💭'); + expect(staleIdleStatus?.getAttribute('aria-label')).toContain('thinking'); + expect(container.querySelector('.session-live-status-inline.thinking .session-live-status-emoji.thought')).toBeTruthy(); + expect(container.querySelector('.session-live-status-inline.idle')).toBeNull(); rerender( Date: Mon, 13 Apr 2026 09:41:28 +0800 Subject: [PATCH 29/29] Fix stale footer idle state during active turns --- web/src/components/SessionPane.tsx | 12 ++++-- web/src/components/SubSessionWindow.tsx | 10 +++-- web/src/components/pinnedPanelTypes.tsx | 12 ++++-- web/src/thinking-utils.ts | 17 ++++++++ web/test/components/SessionPane.test.tsx | 43 ++++++++++++++++++- web/test/components/SubSessionWindow.test.tsx | 41 +++++++++++++++++- web/test/thinking-utils.test.ts | 19 +++++++- 7 files changed, 138 insertions(+), 16 deletions(-) diff --git a/web/src/components/SessionPane.tsx b/web/src/components/SessionPane.tsx index 1b21cf21b..3718599d3 100644 --- a/web/src/components/SessionPane.tsx +++ b/web/src/components/SessionPane.tsx @@ -12,7 +12,7 @@ import { ChatView } from './ChatView.js'; import { SessionControls } from './SessionControls.js'; import { UsageFooter } from './UsageFooter.js'; import { useTimeline } from '../hooks/useTimeline.js'; -import { getActiveThinkingTs, getActiveStatusText, hasActiveToolCall } from '../thinking-utils.js'; +import { getActiveThinkingTs, getActiveStatusText, getTailSessionState, hasActiveToolCall } from '../thinking-utils.js'; import { recordCost } from '../cost-tracker.js'; import type { UseQuickDataResult } from './QuickInputPanel.js'; import { formatLabel } from '../format-label.js'; @@ -141,12 +141,16 @@ export function SessionPane({ const activeThinkingTs = useMemo(() => getActiveThinkingTs(timelineEvents), [timelineEvents]); const statusText = useMemo(() => getActiveStatusText(timelineEvents), [timelineEvents]); const activeToolCall = useMemo(() => hasActiveToolCall(timelineEvents), [timelineEvents]); + const liveSessionState = useMemo( + () => getTailSessionState(timelineEvents) ?? session.state ?? null, + [timelineEvents, session.state], + ); const shouldShowFooter = !!( lastUsage || activeThinkingTs || statusText - || session.state === 'running' - || session.state === 'idle' + || liveSessionState === 'running' + || liveSessionState === 'idle' || session.planLabel || session.quotaLabel || session.quotaUsageLabel @@ -253,7 +257,7 @@ export function SessionPane({ getActiveStatusText(events), [events]); const activeToolCall = useMemo(() => hasActiveToolCall(events), [events]); + const liveSessionState = useMemo( + () => getTailSessionState(events) ?? sub.state ?? null, + [events, sub.state], + ); const [quotes, setQuotes] = useState([]); const addQuote = useCallback((text: string) => setQuotes((prev) => [...prev, text]), []); @@ -400,11 +404,11 @@ export function SubSessionWindow({
{/* Usage footer — shared component */} - {(lastUsage || activeThinkingTs || statusText || sessionInfo?.state === 'running' || sessionInfo?.state === 'idle' || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel || sessionInfo?.quotaMeta) && ( + {(lastUsage || activeThinkingTs || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || sessionInfo?.planLabel || sessionInfo?.quotaLabel || sessionInfo?.quotaUsageLabel || sessionInfo?.quotaMeta) && ( getActiveThinkingTs(events), [events]); const statusText = useMemo(() => getActiveStatusText(events), [events]); const activeToolCall = useMemo(() => hasActiveToolCall(events), [events]); + const liveSessionState = useMemo( + () => getTailSessionState(events) ?? liveSub?.state ?? null, + [events, liveSub?.state], + ); const thinkingNow = useNowTicker(!!activeThinkingTs); if (!liveSub) { @@ -62,18 +66,18 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende loading={false} refreshing={refreshing} sessionId={sessionName} - sessionState={liveSub.state} + sessionState={liveSessionState ?? undefined} ws={ctx.ws} workdir={liveSub.cwd ?? null} serverId={ctx.serverId} onQuote={ctx.onQuote} /> )} - {(lastUsage || activeThinkingTs || statusText || liveSub.state === 'running' || liveSub.state === 'idle' || liveSub.planLabel || liveSub.quotaLabel || liveSub.quotaUsageLabel || liveSub.quotaMeta) && ( + {(lastUsage || activeThinkingTs || statusText || liveSessionState === 'running' || liveSessionState === 'idle' || liveSub.planLabel || liveSub.quotaLabel || liveSub.quotaUsageLabel || liveSub.quotaMeta) && ( }>, +): string | null { + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e.type !== 'session.state') continue; + const state = e.payload?.state; + return typeof state === 'string' && state ? state : null; + } + return null; +} + export function isRunningSessionState(sessionState: string | undefined): boolean { return sessionState === 'running'; } diff --git a/web/test/components/SessionPane.test.tsx b/web/test/components/SessionPane.test.tsx index 486b54035..00a94e18d 100644 --- a/web/test/components/SessionPane.test.tsx +++ b/web/test/components/SessionPane.test.tsx @@ -6,6 +6,7 @@ import { render, screen, cleanup, fireEvent } from '@testing-library/preact'; import { h } from 'preact'; const addOptimisticUserMessageMock = vi.fn(); +let timelineEventsMock: any[] = []; vi.mock('../../src/components/TerminalView.js', () => ({ TerminalView: () => null })); vi.mock('../../src/components/ChatView.js', () => ({ ChatView: () => null })); @@ -18,7 +19,7 @@ vi.mock('../../src/components/SessionControls.js', () => ({ })); vi.mock('../../src/hooks/useTimeline.js', () => ({ useTimeline: () => ({ - events: [], + events: timelineEventsMock, loading: false, refreshing: false, loadingOlder: false, @@ -31,11 +32,17 @@ vi.mock('../../src/thinking-utils.js', () => ({ getActiveThinkingTs: () => null, getActiveStatusText: () => null, hasActiveToolCall: () => false, + getTailSessionState: (events: Array<{ type: string; payload?: Record }>) => { + for (let i = events.length - 1; i >= 0; i--) { + if (events[i].type === 'session.state') return String(events[i].payload?.state ?? ''); + } + return null; + }, })); vi.mock('../../src/cost-tracker.js', () => ({ recordCost: vi.fn() })); vi.mock('../../src/format-label.js', () => ({ formatLabel: (x: string) => x })); vi.mock('../../src/components/UsageFooter.js', () => ({ - UsageFooter: (props: any) =>
{props.quotaLabel ?? props.planLabel ?? 'footer'}
, + UsageFooter: (props: any) =>
{props.quotaLabel ?? props.planLabel ?? 'footer'}
, })); import { SessionPane } from '../../src/components/SessionPane.js'; @@ -43,6 +50,7 @@ import { SessionPane } from '../../src/components/SessionPane.js'; describe('SessionPane', () => { beforeEach(() => { addOptimisticUserMessageMock.mockReset(); + timelineEventsMock = []; }); afterEach(() => { @@ -131,4 +139,35 @@ describe('SessionPane', () => { fireEvent.click(screen.getByRole('button', { name: 'send' })); expect(addOptimisticUserMessageMock).toHaveBeenCalledWith('queued text'); }); + + it('prefers timeline tail running state over stale outer idle state for footer status', () => { + timelineEventsMock = [ + { type: 'session.state', payload: { state: 'running' } }, + { type: 'tool.result', payload: { ok: true } }, + ]; + + render( + , + ); + + expect(screen.getByTestId('usage-footer').getAttribute('data-state')).toBe('running'); + }); }); diff --git a/web/test/components/SubSessionWindow.test.tsx b/web/test/components/SubSessionWindow.test.tsx index e81268b78..bfd408b38 100644 --- a/web/test/components/SubSessionWindow.test.tsx +++ b/web/test/components/SubSessionWindow.test.tsx @@ -20,7 +20,8 @@ vi.mock('../../src/components/ChatView.js', () => ({ })); const sessionControlsSpy = vi.fn((props: any) =>
); -const usageFooterSpy = vi.fn((props: any) =>
); +const usageFooterSpy = vi.fn((props: any) =>
); +let timelineEventsMock: any[] = []; vi.mock('../../src/components/SessionControls.js', () => ({ SessionControls: (props: any) => sessionControlsSpy(props), @@ -32,7 +33,7 @@ vi.mock('../../src/components/UsageFooter.js', () => ({ vi.mock('../../src/hooks/useTimeline.js', () => ({ useTimeline: () => ({ - events: [], + events: timelineEventsMock, refreshing: false, }), })); @@ -90,6 +91,7 @@ describe('SubSessionWindow metadata wiring', () => { beforeEach(() => { cleanup(); vi.clearAllMocks(); + timelineEventsMock = []; }); it('passes model, level, and quota metadata through for transport sub-sessions', async () => { @@ -159,6 +161,41 @@ describe('SubSessionWindow metadata wiring', () => { expect(controls?.dataset.queued).toBe('queued one|queued two'); }); }); + + it('prefers timeline tail running state over stale outer idle state for footer status', async () => { + timelineEventsMock = [ + { type: 'session.state', payload: { state: 'running' } }, + { type: 'tool.result', payload: { ok: true } }, + ]; + + const sub = makeSubSession({ + type: 'codex-sdk', + runtimeType: 'transport' as any, + state: 'idle', + } as any); + + render( + , + ); + + await waitFor(() => { + const footer = document.querySelector('[data-testid="usage-footer"]') as HTMLElement | null; + expect(footer?.dataset.state).toBe('running'); + }); + }); }); describe('SubSessionWindow terminal subscription raw mode', () => { diff --git a/web/test/thinking-utils.test.ts b/web/test/thinking-utils.test.ts index 8bb04f00c..0de8684db 100644 --- a/web/test/thinking-utils.test.ts +++ b/web/test/thinking-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { hasActiveToolCall } from '../src/thinking-utils.js'; +import { getTailSessionState, hasActiveToolCall } from '../src/thinking-utils.js'; describe('hasActiveToolCall', () => { it('does not treat trailing agent.status during thinking as a tool call', () => { @@ -29,3 +29,20 @@ describe('hasActiveToolCall', () => { ] as any)).toBe(false); }); }); + +describe('getTailSessionState', () => { + it('returns the latest authoritative session state from the timeline tail', () => { + expect(getTailSessionState([ + { type: 'session.state', payload: { state: 'idle' } }, + { type: 'tool.result', payload: { ok: true } }, + { type: 'session.state', payload: { state: 'running' } }, + ] as any)).toBe('running'); + }); + + it('returns null when no session.state event exists', () => { + expect(getTailSessionState([ + { type: 'assistant.thinking', ts: 1 }, + { type: 'tool.call', payload: { tool: 'Read' } }, + ] as any)).toBe(null); + }); +});