From 37db0a2a3cc24d52ca1a9bd80c3763c4d5d97e14 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 15:52:13 +0800 Subject: [PATCH 01/92] Fix stuck p2p timeout handling --- src/daemon/p2p-orchestrator.ts | 7 +- test/daemon/p2p-orchestrator.test.ts | 62 +++++++++++ web/src/app.tsx | 4 +- web/src/components/P2pProgressCard.tsx | 111 +++++++++++++------ web/test/components/P2pProgressCard.test.tsx | 30 ++++- 5 files changed, 176 insertions(+), 38 deletions(-) diff --git a/src/daemon/p2p-orchestrator.ts b/src/daemon/p2p-orchestrator.ts index 51365bd52..f1fbe75ee 100644 --- a/src/daemon/p2p-orchestrator.ts +++ b/src/daemon/p2p-orchestrator.ts @@ -473,7 +473,8 @@ export async function cancelP2pRun(runId: string, serverLink: ServerLink | null) return true; } - return false; + activeRuns.delete(runId); + return true; } // ── Resume after daemon restart ─────────────────────────────────────────── @@ -1124,6 +1125,9 @@ function transition(run: P2pRun, status: P2pRunStatus, serverLink: ServerLink | } else if (status === 'failed' || status === 'timed_out') { run.runPhase = 'failed'; } + if (P2P_TERMINAL_RUN_STATUSES.has(status)) { + run.completedAt = run.completedAt ?? new Date().toISOString(); + } run.updatedAt = new Date().toISOString(); logger.info({ runId: run.id, status }, 'P2P run state transition'); pushState(run, serverLink); @@ -1131,6 +1135,7 @@ function transition(run: P2pRun, status: P2pRunStatus, serverLink: ServerLink | function failRun(run: P2pRun, errorType: string, message: string, serverLink: ServerLink | null): void { run.error = `${errorType}: ${message}`; + run.completedAt = run.completedAt ?? new Date().toISOString(); run.updatedAt = new Date().toISOString(); const status: P2pRunStatus = errorType === 'timed_out' ? 'timed_out' : 'failed'; run.status = status; diff --git a/test/daemon/p2p-orchestrator.test.ts b/test/daemon/p2p-orchestrator.test.ts index 07ce9aacc..1db36de08 100644 --- a/test/daemon/p2p-orchestrator.test.ts +++ b/test/daemon/p2p-orchestrator.test.ts @@ -330,6 +330,39 @@ describe('P2P orchestrator — parallel rounds', () => { expect(done.summaryPhase).toBe('completed'); }); + it('completes the discussion when a single hop times out', async () => { + detectStatusAsyncMock.mockImplementation(async (session: string) => ( + session === 'deck_proj_w1' ? 'running' : 'idle' + )); + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + if (session === 'deck_proj_w1') return; + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nBRAIN-${session}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun( + 'deck_proj_brain', + [{ session: 'deck_proj_w1', mode: 'audit' }], + 'single hop timeout should not fail the run', + [], + serverLinkMock as any, + 1, + undefined, + undefined, + 120, + ); + + const done = await waitForStatus(run.id, ['completed']); + expect(done.status).toBe('completed'); + expect(done.hopStates).toHaveLength(1); + expect(done.hopStates[0].status).toBe('timed_out'); + expect(done.summaryPhase).toBe('completed'); + const content = await readFile(done.contextFilePath, 'utf8'); + expect(content).toContain('BRAIN-deck_proj_brain'); + }); + it('preserves completed evidence and still summarizes on partial hop failure', async () => { sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { const filePath = pathFromPrompt(prompt); @@ -451,6 +484,35 @@ describe('P2P orchestrator — parallel rounds', () => { expect(sendKeyMock).toHaveBeenCalled(); }); + it('treats cancel on a terminal run as close and removes it from memory', async () => { + sendKeysDelayedEnterMock.mockImplementation(async (session: string, prompt: string) => { + if (session === 'deck_proj_w1') return; + const filePath = pathFromPrompt(prompt); + const heading = headingFromPrompt(prompt); + await appendFile(filePath, `\n## ${heading}\n\nBRAIN-${session}\n`, 'utf8'); + setTimeout(() => notifySessionIdle(session), 20); + }); + + const run = await startP2pRun( + 'deck_proj_brain', + [{ session: 'deck_proj_w1', mode: 'audit' }], + 'close failed/timed-out p2p', + [], + serverLinkMock as any, + 1, + undefined, + undefined, + 120, + ); + + await waitForStatus(run.id, ['completed']); + expect(getP2pRun(run.id)?.status).toBe('completed'); + + const closed = await cancelP2pRun(run.id, serverLinkMock as any); + expect(closed).toBe(true); + expect(getP2pRun(run.id)).toBeUndefined(); + }); + it('emits additive hop/run payload fields without breaking legacy fields', async () => { const run = await startP2pRun( 'deck_proj_brain', diff --git a/web/src/app.tsx b/web/src/app.tsx index 705065721..a20427027 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1330,6 +1330,9 @@ export function App() { }, 120_000); } } + if (msg.type === 'p2p.cancel_response' && msg.ok && msg.runId) { + setDiscussions((prev) => prev.filter((d) => d.id !== `p2p_${msg.runId}`)); + } if (msg.type === 'p2p.status_response') { const runs = Array.isArray(msg.runs) ? msg.runs @@ -1341,7 +1344,6 @@ export function App() { setDiscussions((prev) => { const retained = prev.filter((d) => { if (!d.id.startsWith('p2p_')) return true; - if (d.state === 'done' || d.state === 'failed') return true; return activeIds.has(d.id); }); const merged = [...retained]; diff --git a/web/src/components/P2pProgressCard.tsx b/web/src/components/P2pProgressCard.tsx index 233021678..66e9044c2 100644 --- a/web/src/components/P2pProgressCard.tsx +++ b/web/src/components/P2pProgressCard.tsx @@ -43,6 +43,12 @@ interface Props { onStopDiscussion?: (id: string) => void; } +interface ActionButtonProps { + active: boolean; + compact: boolean; + onAction: () => void; +} + function statusClassName(status: P2pProgressNode['status']): string { return status === 'done' ? 'is-done' @@ -92,10 +98,67 @@ function useHopTimer(hopKey: string | null, active: boolean, serverStartMs?: num return useElapsedTimer(serverStartMs ?? fallbackStart, active); } +function DiscussionActionButton({ active, compact, onAction }: ActionButtonProps) { + const { t } = useTranslation(); + const [confirming, setConfirming] = useState(false); + + useEffect(() => { + if (!active || !confirming) return; + const timer = setTimeout(() => setConfirming(false), 3000); + return () => clearTimeout(timer); + }, [active, confirming]); + + if (!active) { + return ( + + ); + } + + if (compact && confirming) { + return ( + + ); + } + + return ( + + ); +} + export function P2pProgressCard({ discussion, compact = false, mobile = false, hidden = false, onToggleHide, onClick, onStopDiscussion }: Props) { const { t } = useTranslation(); const nodes = discussion.nodes ?? []; const isActive = discussion.state !== 'done' && discussion.state !== 'failed'; + const showActionButton = discussion.state === 'failed' || isActive; const totalHopsPerRound = discussion.totalHops ?? 0; const completedRoundHops = useMemo(() => { if (totalHopsPerRound <= 0) return 0; @@ -158,31 +221,13 @@ export function P2pProgressCard({ discussion, compact = false, mobile = false, h {hidden ? '▼' : '▲'} )} - {isActive && onStopDiscussion && (() => { - const [confirming, setConfirming] = useState(false); - useEffect(() => { - if (!confirming) return; - const timer = setTimeout(() => setConfirming(false), 3000); - return () => clearTimeout(timer); - }, [confirming]); - return confirming ? ( - - ) : ( - - ); - })()} + {showActionButton && onStopDiscussion && ( + onStopDiscussion(discussion.id)} + /> + )} {!hidden && (
{discussion.topic || t('p2p.discussions.untitled')}
@@ -243,16 +288,12 @@ export function P2pProgressCard({ discussion, compact = false, mobile = false, h
{discussion.topic || t('p2p.discussions.untitled')}
{isActive && {totalElapsed}} - {isActive && onStopDiscussion && ( - + {showActionButton && onStopDiscussion && ( + onStopDiscussion(discussion.id)} + /> )} diff --git a/web/test/components/P2pProgressCard.test.tsx b/web/test/components/P2pProgressCard.test.tsx index efa874dd7..b567bdd0a 100644 --- a/web/test/components/P2pProgressCard.test.tsx +++ b/web/test/components/P2pProgressCard.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { h } from 'preact'; -import { render, screen, cleanup } from '@testing-library/preact'; +import { render, screen, cleanup, fireEvent } from '@testing-library/preact'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -42,4 +42,32 @@ describe('P2pProgressCard', () => { expect(screen.getAllByText('H2/2').length).toBeGreaterThan(0); expect(screen.queryByText('H4/2')).toBeNull(); }); + + it('shows a close action for failed discussions', () => { + const onStopDiscussion = vi.fn(); + + render( + , + ); + + const closeButton = screen.getByText(/close/i); + expect(screen.queryByText(/cancel/i)).toBeNull(); + fireEvent.click(closeButton); + expect(onStopDiscussion).toHaveBeenCalledWith('p2p_run_failed'); + }); }); From 3a0ca77162b1bbe8159a0c57bc8b7b1a665ccdee Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 15:56:21 +0800 Subject: [PATCH 02/92] Route session errors and polish P2P controls --- src/agent/session-manager.ts | 14 ++- src/agent/tmux.ts | 20 ++++- src/daemon/codex-watcher.ts | 22 +++-- src/daemon/command-handler.ts | 9 +- src/daemon/provider-sessions.ts | 8 ++ src/daemon/session-error.ts | 17 ++++ src/daemon/terminal-streamer.ts | 7 ++ src/daemon/transport-relay.ts | 2 +- src/store/session-store.ts | 9 +- test/agent/qwen-provider.test.ts | 4 +- test/daemon/codex-watcher.test.ts | 10 ++- test/daemon/provider-sessions.test.ts | 18 ++-- test/daemon/session-manager-restore.test.ts | 86 +++++++++++++++++-- test/daemon/subsession-manager.test.ts | 6 ++ .../daemon/terminal-streamer-snapshot.test.ts | 31 ++++++- test/e2e/daemon-reconnect.test.ts | 2 +- test/e2e/sdk-transport-flow.test.ts | 41 +++++++++ test/util/windows-launch-artifacts.test.ts | 18 ++-- web/src/components/SessionControls.tsx | 21 +++-- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/es.json | 1 + web/src/i18n/locales/ja.json | 1 + web/src/i18n/locales/ko.json | 1 + web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/zh-CN.json | 1 + web/src/i18n/locales/zh-TW.json | 1 + web/src/pages/NativeAuthBridge.tsx | 12 ++- web/src/styles.css | 9 ++ web/test/components/P2pConfigPanel.test.tsx | 6 +- 29 files changed, 313 insertions(+), 66 deletions(-) create mode 100644 src/daemon/provider-sessions.ts create mode 100644 src/daemon/session-error.ts diff --git a/src/agent/session-manager.ts b/src/agent/session-manager.ts index dffe61a5a..31b79cde3 100644 --- a/src/agent/session-manager.ts +++ b/src/agent/session-manager.ts @@ -23,6 +23,7 @@ import { } from '../store/session-store.js'; import logger from '../util/logger.js'; import { timelineEmitter } from '../daemon/timeline-emitter.js'; +import { emitSessionInlineError } from '../daemon/session-error.js'; import { startWatching, startWatchingFile, stopWatching, isWatching, findJsonlPathBySessionId } from '../daemon/jsonl-watcher.js'; import { startWatching as startCodexWatching, startWatchingSpecificFile as startCodexWatchingFile, startWatchingById as startCodexWatchingById, stopWatching as stopCodexWatching, isWatching as isCodexWatching, findRolloutPathByUuid } from '../daemon/codex-watcher.js'; import { startWatching as startGeminiWatching, startWatchingLatest as startGeminiWatchingLatest, stopWatching as stopGeminiWatching, isWatching as isGeminiWatching } from '../daemon/gemini-watcher.js'; @@ -112,6 +113,11 @@ export function setSessionEventCallback(cb: SessionEventCallback): void { function emitSessionEvent(event: 'started' | 'stopped' | 'error', session: string, state: string): void { try { _onSessionEvent?.(event, session, state); } catch { /* ignore */ } + if (event === 'error') { + emitSessionInlineError(session, state); + timelineEmitter.emit(session, 'session.state', { state: event, error: state }); + return; + } timelineEmitter.emit(session, 'session.state', { state: event }); } @@ -413,6 +419,7 @@ export async function restoreFromStore(): Promise { try { await restartSession(hydrated); } catch (err) { logger.error({ err, session: hydrated.name }, 'Failed to restart session on restore — skipping (tmux may be unavailable)'); updateSessionState(hydrated.name, 'error'); + emitSessionEvent('error', hydrated.name, err instanceof Error ? err.message : String(err)); } } else if (isLiveSession && !paneAlive) { // Session exists (remain-on-exit) but process is dead — respawn instead of creating a new session @@ -420,6 +427,7 @@ export async function restoreFromStore(): Promise { try { await respawnSession(hydrated); } catch (err) { logger.error({ err, session: hydrated.name }, 'Failed to respawn session on restore — skipping'); updateSessionState(hydrated.name, 'error'); + emitSessionEvent('error', hydrated.name, err instanceof Error ? err.message : String(err)); } } else if (hydrated.agentType === 'claude-code' && hydrated.projectDir && !isWatching(hydrated.name)) { if (hydrated.ccSessionId) { @@ -553,9 +561,10 @@ export async function restartSession(record: SessionRecord): Promise { const recentRestarts = record.restartTimestamps.filter((t) => t > windowStart); if (recentRestarts.length >= MAX_RESTARTS) { + const message = `Restart loop detected: more than ${MAX_RESTARTS} restarts within 5 minutes`; logger.error({ session: record.name }, 'Restart loop detected — marking as error'); updateSessionState(record.name, 'error'); - emitSessionEvent('error', record.name, 'error'); + emitSessionEvent('error', record.name, message); return false; } @@ -608,9 +617,10 @@ export async function respawnSession(record: SessionRecord): Promise { const recentRestarts = record.restartTimestamps.filter((t) => t > windowStart); if (recentRestarts.length >= MAX_RESTARTS) { + const message = `Restart loop detected: more than ${MAX_RESTARTS} restarts within 5 minutes`; logger.error({ session: record.name }, 'Restart loop detected — marking as error'); updateSessionState(record.name, 'error'); - emitSessionEvent('error', record.name, 'error'); + emitSessionEvent('error', record.name, message); return false; } diff --git a/src/agent/tmux.ts b/src/agent/tmux.ts index 8ea1f86e3..8358dc216 100644 --- a/src/agent/tmux.ts +++ b/src/agent/tmux.ts @@ -762,7 +762,18 @@ export async function startPipePaneStream(session: string, paneId: string): Prom // well-tested libuv pipe path. fd = fs.openSync(fifoPath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK); const cat = spawn('cat', [fifoPath], { stdio: ['ignore', 'pipe', 'ignore'] }); - stream = cat.stdout!; + const catReady = new Promise((resolve, reject) => { + cat.once('spawn', () => resolve()); + cat.once('error', (err) => reject(err)); + }); + cat.on('error', (err) => { + if (stream && !stream.destroyed) stream.destroy(err); + }); + await catReady; + if (!cat.stdout) { + throw new Error('pipe-pane cat reader missing stdout pipe'); + } + stream = cat.stdout; needsManualClose = true; catProc = cat; @@ -804,7 +815,12 @@ export async function startPipePaneStream(session: string, paneId: string): Prom } catch (err) { // Rollback: destroy stream + close fd if needed, clean up files if (stream) { destroyPipeStream(stream, fd, needsManualClose, catProc); } - else if (fd >= 0) { try { fs.closeSync(fd); } catch { /* ignore */ } } + else { + if (catProc) { + try { catProc.kill('SIGTERM'); } catch { /* ignore */ } + } + if (fd >= 0) { try { fs.closeSync(fd); } catch { /* ignore */ } } + } await execFile('tmux', ['pipe-pane', '-t', paneId]).catch(() => {}); await fsp.unlink(fifoPath).catch(() => {}); await fsp.rmdir(dir).catch(() => {}); diff --git a/src/daemon/codex-watcher.ts b/src/daemon/codex-watcher.ts index 58b05c1d0..a6b29bb4d 100644 --- a/src/daemon/codex-watcher.ts +++ b/src/daemon/codex-watcher.ts @@ -476,20 +476,24 @@ export async function startWatching(sessionName: string, workDir: string, model? watchers.set(sessionName, state); const control = watcherControl(sessionName); registerWatcherControl(sessionName, control); - - for (const dir of recentSessionDirs()) { - const found = await findLatestRollout(dir, workDir); - if (found) { - const s = await stat(found); + startPoll(sessionName, state); + void watchDir(sessionName, state, state.workDir || codexSessionDir(new Date())); + void (async () => { + for (const dir of recentSessionDirs()) { + if (state.stopped) return; + const found = await findLatestRollout(dir, workDir); + if (!found || state.stopped) continue; + const s = await stat(found).catch(() => null); + if (!s || state.stopped) continue; state.activeFile = found; state.fileOffset = s.size; claimedFiles.set(found, sessionName); await emitRecentHistory(sessionName, found, model); - break; + return; } - } - startPoll(sessionName, state); - void watchDir(sessionName, state, state.workDir || codexSessionDir(new Date())); + })().catch((err) => { + logger.debug({ err, sessionName, workDir }, 'codex-watcher: initial rollout scan failed'); + }); return control; } diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index 64ff6c77a..e416f291d 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -11,6 +11,7 @@ import { terminalStreamer, type StreamSubscriber } from './terminal-streamer.js' import type { ServerLink } from './server-link.js'; import { timelineEmitter } from './timeline-emitter.js'; import { timelineStore } from './timeline-store.js'; +import { emitSessionInlineError } from './session-error.js'; import { startSubSession, stopSubSession, @@ -32,7 +33,6 @@ import { getComboRoundCount, parseModePipeline, P2P_CONFIG_MODE, type P2pSession import { CRON_MSG } from '../../shared/cron-types.js'; import { executeCronJob } from './cron-executor.js'; import { TRANSPORT_MSG } from '../../shared/transport-events.js'; -import { getProvider } from '../agent/provider-registry.js'; import { copyFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import { ensureImcDir, imcSubDir } from '../util/imc-dir.js'; @@ -202,6 +202,7 @@ import { getQwenRuntimeConfig } from '../agent/qwen-runtime-config.js'; import { getQwenDisplayMetadata } from '../agent/provider-display.js'; import { buildSessionList } from './session-list.js'; import { getQwenOAuthQuotaUsageLabel, recordQwenOAuthRequest } from '../agent/provider-quota.js'; +import { listProviderSessions as listProviderSessionsImpl } from './provider-sessions.js'; function describeTransportSendError(err: unknown): string { if (err && typeof err === 'object') { @@ -874,6 +875,7 @@ async function handleRestart(cmd: Record, serverLink: ServerLin } catch (err) { logger.error({ project, err }, 'session.restart failed'); const message = err instanceof Error ? err.message : String(err); + emitSessionInlineError(brain.name, message); try { serverLink.send({ type: 'session.error', project, message }); } catch { /* ignore */ } } } @@ -2880,10 +2882,7 @@ export function fileSearchByLengthAsc(a: FzfEntry, b: FzfEntry): number { /** Reusable: fetch remote sessions from a provider. */ export async function listProviderSessions(providerId: string): Promise> { - const provider = getProvider(providerId); - if (!provider) return []; - if (!provider.capabilities.sessionRestore || !provider.listSessions) return []; - return provider.listSessions(); + return listProviderSessionsImpl(providerId); } // ── CC env presets ──────────────────────────────────────────────────────── diff --git a/src/daemon/provider-sessions.ts b/src/daemon/provider-sessions.ts new file mode 100644 index 000000000..297599286 --- /dev/null +++ b/src/daemon/provider-sessions.ts @@ -0,0 +1,8 @@ +import { getProvider } from '../agent/provider-registry.js'; + +export async function listProviderSessions(providerId: string): Promise> { + const provider = getProvider(providerId); + if (!provider) return []; + if (!provider.capabilities.sessionRestore || !provider.listSessions) return []; + return provider.listSessions(); +} diff --git a/src/daemon/session-error.ts b/src/daemon/session-error.ts new file mode 100644 index 000000000..f2c4c2ed5 --- /dev/null +++ b/src/daemon/session-error.ts @@ -0,0 +1,17 @@ +import type { TimelineSource } from './timeline-event.js'; +import { timelineEmitter } from './timeline-emitter.js'; + +export function formatSessionErrorMessage(message: string): string { + return message.startsWith('⚠️') ? message : `⚠️ Error: ${message}`; +} + +export function emitSessionInlineError( + sessionId: string, + message: string, + source: TimelineSource = 'daemon', +): void { + timelineEmitter.emit(sessionId, 'assistant.text', { + text: formatSessionErrorMessage(message), + streaming: false, + }, { source, confidence: 'high' }); +} diff --git a/src/daemon/terminal-streamer.ts b/src/daemon/terminal-streamer.ts index d1c054ba4..85355c37b 100644 --- a/src/daemon/terminal-streamer.ts +++ b/src/daemon/terminal-streamer.ts @@ -24,6 +24,7 @@ import { isWatching as isCodexWatching } from './codex-watcher.js'; import { isWatching as isGeminiWatching } from './gemini-watcher.js'; import logger from '../util/logger.js'; import { timelineEmitter } from './timeline-emitter.js'; +import { emitSessionInlineError } from './session-error.js'; import type { TerminalDiff, TerminalHistory } from '../shared/transport/terminal.js'; const IDLE_THRESHOLD_MS = 5_000; // 5s without raw bytes → idle (Stop hook fires immediately; this is fallback) @@ -293,6 +294,7 @@ export class TerminalStreamer { } if (!paneId) { logger.error({ sessionName }, 'Cannot start pipe-pane: paneId not available — restart session to fix'); + this.emitSessionStreamError(sessionName, 'Terminal stream unavailable: pane id not available. Restart the session to fix.'); // Do not remove subscribers: they can still receive on-demand snapshots return; } @@ -481,6 +483,7 @@ export class TerminalStreamer { private errorAllSubscribers(sessionName: string, err: Error): void { const subs = this.subscribers.get(sessionName); if (!subs) return; + this.emitSessionStreamError(sessionName, err.message); for (const [sub] of subs) { try { sub.onError?.(err); } catch { /* ignore */ } } @@ -489,6 +492,10 @@ export class TerminalStreamer { this.clearIdleTimer(sessionName); } + private emitSessionStreamError(sessionName: string, message: string): void { + emitSessionInlineError(sessionName, message); + } + // ── Idle detection ────────────────────────────────────────────────────────── private resetIdleTimer(sessionName: string): void { diff --git a/src/daemon/transport-relay.ts b/src/daemon/transport-relay.ts index 3d5a767b1..3110705c9 100644 --- a/src/daemon/transport-relay.ts +++ b/src/daemon/transport-relay.ts @@ -209,7 +209,7 @@ export function broadcastProviderStatus(providerId: string, connected: boolean): /** Fetch remote sessions from a provider and broadcast to browsers + sync to server DB. */ async function pushProviderSessions(providerId: string): Promise { try { - const { listProviderSessions } = await import('./command-handler.js'); + const { listProviderSessions } = await import('./provider-sessions.js'); const sessions = await listProviderSessions(providerId); if (!sendToServer) return; // Send via sync_sessions — bridge handles this: caches, persists to DB, broadcasts to browsers diff --git a/src/store/session-store.ts b/src/store/session-store.ts index 74939562e..2982b77aa 100644 --- a/src/store/session-store.ts +++ b/src/store/session-store.ts @@ -141,7 +141,13 @@ async function probeSessionStates(): Promise { function scheduleWrite(): void { if (writeTimer) clearTimeout(writeTimer); writeTimer = setTimeout(async () => { - await writeFile(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); + try { + await mkdir(STORE_DIR, { recursive: true }); + await writeFile(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); + } catch { + // Tests may tear down temp HOME dirs while a debounced write is pending. + // Losing that best-effort write is fine; a later flush/load will recreate it. + } writeTimer = null; }, DEBOUNCE_MS); } @@ -183,5 +189,6 @@ export async function flushStore(): Promise { clearTimeout(writeTimer); writeTimer = null; } + await mkdir(STORE_DIR, { recursive: true }); await writeFile(STORE_PATH, JSON.stringify(store, null, 2), 'utf8'); } diff --git a/test/agent/qwen-provider.test.ts b/test/agent/qwen-provider.test.ts index 51d691b61..e7af72c42 100644 --- a/test/agent/qwen-provider.test.ts +++ b/test/agent/qwen-provider.test.ts @@ -274,7 +274,7 @@ describe('QwenProvider', () => { // First send dispatches immediately runtime.send('first'); - await new Promise((resolve) => setTimeout(resolve, 25)); + await waitForSpawnCount(1); const first = lastSpawn(); first.child.stdout.write(`${JSON.stringify({ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-queue-1' } } })}\n`); first.child.stdout.write(`${JSON.stringify({ type: 'assistant', message: { id: 'assistant-queue-1', content: [{ type: 'text', text: 'Still running' }] } })}\n`); @@ -305,7 +305,7 @@ describe('QwenProvider', () => { provider.onError((_sid, err) => errors.push(err.message)); runtime.send('first'); - await new Promise((resolve) => setTimeout(resolve, 25)); + await waitForSpawnCount(1); const first = lastSpawn(); first.child.stdout.write(`${JSON.stringify({ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-queue-close-1' } } })}\n`); first.child.stdout.write(`${JSON.stringify({ type: 'result', is_error: false, result: 'done' })}\n`); diff --git a/test/daemon/codex-watcher.test.ts b/test/daemon/codex-watcher.test.ts index 02aa18825..4b16897c9 100644 --- a/test/daemon/codex-watcher.test.ts +++ b/test/daemon/codex-watcher.test.ts @@ -314,8 +314,16 @@ describe('readCwd', () => { // ── startWatching / stopWatching / isWatching ───────────────────────────────── describe('isWatching / stopWatching', () => { + const originalHome = process.env.HOME; + + beforeEach(async () => { + process.env.HOME = await mkdtemp(join(tmpdir(), 'codex-watcher-home-')); + }); + afterEach(() => { stopWatching('session-x'); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; }); it('isWatching returns false before startWatching', () => { @@ -323,7 +331,7 @@ describe('isWatching / stopWatching', () => { }); it('isWatching returns true after startWatching', async () => { - // Use a workDir that won't match any real file so watcher just idles + // Use an isolated HOME so the watcher does not scan the developer's real Codex history. await startWatching('session-x', '/tmp/__nonexistent_codex_dir__'); expect(isWatching('session-x')).toBe(true); }); diff --git a/test/daemon/provider-sessions.test.ts b/test/daemon/provider-sessions.test.ts index 573e100e5..e0908e28b 100644 --- a/test/daemon/provider-sessions.test.ts +++ b/test/daemon/provider-sessions.test.ts @@ -3,8 +3,13 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { TRANSPORT_MSG } from '../../shared/transport-events.js'; +import { setTransportRelaySend, broadcastProviderStatus } from '../../src/daemon/transport-relay.js'; describe('provider session listing', () => { + beforeEach(() => { + vi.resetModules(); + }); + it('TRANSPORT_MSG has LIST_SESSIONS and SESSIONS_RESPONSE constants', () => { expect(TRANSPORT_MSG.LIST_SESSIONS).toBe('provider.list_sessions'); expect(TRANSPORT_MSG.SESSIONS_RESPONSE).toBe('provider.sessions_response'); @@ -15,7 +20,7 @@ describe('provider session listing', () => { vi.doMock('../../src/agent/provider-registry.js', () => ({ getProvider: () => undefined, })); - const { listProviderSessions } = await import('../../src/daemon/command-handler.js'); + const { listProviderSessions } = await import('../../src/daemon/provider-sessions.js'); const sessions = await listProviderSessions('openclaw'); expect(sessions).toEqual([]); vi.doUnmock('../../src/agent/provider-registry.js'); @@ -27,7 +32,7 @@ describe('provider session listing', () => { capabilities: { sessionRestore: false }, }), })); - const mod = await import('../../src/daemon/command-handler.js'); + const mod = await import('../../src/daemon/provider-sessions.js'); // Re-import to pick up new mock const sessions = await mod.listProviderSessions('openclaw'); expect(sessions).toEqual([]); @@ -36,15 +41,6 @@ describe('provider session listing', () => { }); describe('broadcastProviderStatus auto-push', () => { - let setTransportRelaySend: typeof import('../../src/daemon/transport-relay.js')['setTransportRelaySend']; - let broadcastProviderStatus: typeof import('../../src/daemon/transport-relay.js')['broadcastProviderStatus']; - - beforeEach(async () => { - const mod = await import('../../src/daemon/transport-relay.js'); - setTransportRelaySend = mod.setTransportRelaySend; - broadcastProviderStatus = mod.broadcastProviderStatus; - }); - it('sends provider.status message to server', () => { const sent: Record[] = []; setTransportRelaySend((msg) => sent.push(msg)); diff --git a/test/daemon/session-manager-restore.test.ts b/test/daemon/session-manager-restore.test.ts index 8866ca28a..c0a283794 100644 --- a/test/daemon/session-manager-restore.test.ts +++ b/test/daemon/session-manager-restore.test.ts @@ -7,14 +7,15 @@ * main session's JSONL file and emitting its events under the sub-session name. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── All mocks hoisted so factories can reference them ───────────────────────── const { storeMock, tmuxListMock, startWatchingMock, startWatchingFileMock, - isWatchingMock, restartSessionMock, getPaneStartCommandMock, upsertSessionMock, discoverLatestOpenCodeSessionIdMock, - opencodeStartWatchingMock, opencodeIsWatchingMock, + isWatchingMock, restartSessionMock, getPaneStartCommandMock, upsertSessionMock, updateSessionStateMock, + discoverLatestOpenCodeSessionIdMock, opencodeStartWatchingMock, opencodeIsWatchingMock, + newSessionMock, timelineEmitMock, } = vi.hoisted(() => ({ storeMock: vi.fn(), tmuxListMock: vi.fn().mockResolvedValue(['deck_Cd_brain', 'deck_sub_5907196l']), @@ -24,20 +25,25 @@ const { restartSessionMock: vi.fn().mockResolvedValue(undefined), getPaneStartCommandMock: vi.fn().mockResolvedValue('claude --dangerously-skip-permissions'), upsertSessionMock: vi.fn(), + updateSessionStateMock: vi.fn(), discoverLatestOpenCodeSessionIdMock: vi.fn().mockResolvedValue(undefined), opencodeStartWatchingMock: vi.fn().mockResolvedValue(undefined), opencodeIsWatchingMock: vi.fn().mockReturnValue(false), + newSessionMock: vi.fn().mockResolvedValue(undefined), + timelineEmitMock: vi.fn(), })); vi.mock('../../src/store/session-store.js', () => ({ listSessions: storeMock, // session-manager imports `listSessions as storeSessions` upsertSession: upsertSessionMock, + updateSessionState: updateSessionStateMock, getSession: vi.fn(() => null), + removeSession: vi.fn(), })); vi.mock('../../src/agent/tmux.js', () => ({ listSessions: tmuxListMock, - newSession: vi.fn().mockResolvedValue(undefined), + newSession: newSessionMock, killSession: vi.fn().mockResolvedValue(undefined), sessionExists: vi.fn().mockResolvedValue(true), isPaneAlive: vi.fn().mockResolvedValue(true), @@ -77,6 +83,7 @@ vi.mock('../../src/daemon/codex-watcher.js', () => ({ vi.mock('../../src/agent/detect.js', () => ({ detectStatus: vi.fn(() => 'idle'), + isTransportAgent: vi.fn(() => false), })); vi.mock('../../src/daemon/opencode-history.js', () => ({ @@ -90,7 +97,7 @@ vi.mock('../../src/daemon/opencode-watcher.js', () => ({ })); vi.mock('../../src/daemon/timeline-emitter.js', () => ({ - timelineEmitter: { emit: vi.fn(), on: vi.fn(() => () => {}), epoch: 0, replay: vi.fn(() => ({ events: [], truncated: false })) }, + timelineEmitter: { emit: timelineEmitMock, on: vi.fn(() => () => {}), epoch: 0, replay: vi.fn(() => ({ events: [], truncated: false })) }, })); vi.mock('../../src/daemon/timeline-store.js', () => ({ @@ -101,12 +108,23 @@ vi.mock('../../src/agent/brain-dispatcher.js', () => ({ BrainDispatcher: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })), })); -import { restoreFromStore } from '../../src/agent/session-manager.js'; +import { restoreFromStore, restartSession, respawnSession } from '../../src/agent/session-manager.js'; import { startWatching, startWatchingFile } from '../../src/daemon/jsonl-watcher.js'; // ── Tests ───────────────────────────────────────────────────────────────────── describe('restoreFromStore — sub-session JSONL watcher regression', () => { + beforeEach(() => { + vi.clearAllMocks(); + tmuxListMock.mockResolvedValue(['deck_Cd_brain', 'deck_sub_5907196l']); + isWatchingMock.mockReturnValue(false); + getPaneStartCommandMock.mockResolvedValue('claude --dangerously-skip-permissions'); + discoverLatestOpenCodeSessionIdMock.mockResolvedValue(undefined); + opencodeStartWatchingMock.mockResolvedValue(undefined); + opencodeIsWatchingMock.mockReturnValue(false); + newSessionMock.mockResolvedValue(undefined); + }); + it('does NOT call startWatching for deck_sub_* sessions (prevents JSONL file stealing)', async () => { storeMock.mockReturnValue([ // Main brain session — has ccSessionId @@ -196,4 +214,60 @@ describe('restoreFromStore — sub-session JSONL watcher regression', () => { opencodeSessionId: 'oc-sub-sqlite-789', })); }); + + it('emits a session-scoped error when restartSession hits loop protection', async () => { + const now = Date.now(); + const result = await restartSession({ + name: 'deck_loop_brain', + projectName: 'loop', + role: 'brain', + agentType: 'shell', + projectDir: '/proj', + state: 'running', + restarts: 3, + restartTimestamps: [now - 60_000, now - 120_000, now - 180_000], + createdAt: now, + updatedAt: now, + }); + + expect(result).toBe(false); + expect(updateSessionStateMock).toHaveBeenCalledWith('deck_loop_brain', 'error'); + expect(timelineEmitMock).toHaveBeenCalledWith( + 'deck_loop_brain', + 'assistant.text', + expect.objectContaining({ + text: '⚠️ Error: Restart loop detected: more than 3 restarts within 5 minutes', + streaming: false, + }), + expect.any(Object), + ); + }); + + it('emits a session-scoped error when respawnSession hits loop protection', async () => { + const now = Date.now(); + const result = await respawnSession({ + name: 'deck_loop_w1', + projectName: 'loop', + role: 'w1', + agentType: 'shell', + projectDir: '/proj', + state: 'running', + restarts: 3, + restartTimestamps: [now - 60_000, now - 120_000, now - 180_000], + createdAt: now, + updatedAt: now, + }); + + expect(result).toBe(false); + expect(updateSessionStateMock).toHaveBeenCalledWith('deck_loop_w1', 'error'); + expect(timelineEmitMock).toHaveBeenCalledWith( + 'deck_loop_w1', + 'assistant.text', + expect.objectContaining({ + text: '⚠️ Error: Restart loop detected: more than 3 restarts within 5 minutes', + streaming: false, + }), + expect.any(Object), + ); + }); }); diff --git a/test/daemon/subsession-manager.test.ts b/test/daemon/subsession-manager.test.ts index 5b2f5590a..7d0072aa1 100644 --- a/test/daemon/subsession-manager.test.ts +++ b/test/daemon/subsession-manager.test.ts @@ -10,6 +10,7 @@ const { codexStartWatchingByIdMock, codexIsWatchingMock, codexIsFileClaimedMock, removeSessionMock, resolveGeminiSessionIdMock, injectGeminiMemoryMock, launchTransportSessionMock, getTransportRuntimeMock, + getAgentVersionMock, } = vi.hoisted(() => ({ upsertSessionMock: vi.fn(), startWatchingMock: vi.fn().mockResolvedValue(undefined), @@ -31,6 +32,7 @@ const { injectGeminiMemoryMock: vi.fn().mockResolvedValue(undefined), launchTransportSessionMock: vi.fn().mockResolvedValue(undefined), getTransportRuntimeMock: vi.fn().mockReturnValue(null), + getAgentVersionMock: vi.fn().mockResolvedValue(undefined), })); vi.mock('../../src/store/session-store.js', () => ({ @@ -82,6 +84,10 @@ vi.mock('../../src/agent/session-manager.js', () => ({ getTransportRuntime: getTransportRuntimeMock, })); +vi.mock('../../src/agent/agent-version.js', () => ({ + getAgentVersion: getAgentVersionMock, +})); + vi.mock('../../src/agent/drivers/gemini.js', () => ({ GeminiDriver: vi.fn().mockImplementation(() => ({ resolveSessionId: resolveGeminiSessionIdMock, diff --git a/test/daemon/terminal-streamer-snapshot.test.ts b/test/daemon/terminal-streamer-snapshot.test.ts index b8cb57e7e..844e16ef7 100644 --- a/test/daemon/terminal-streamer-snapshot.test.ts +++ b/test/daemon/terminal-streamer-snapshot.test.ts @@ -18,7 +18,8 @@ vi.mock('../../src/store/session-store.js', () => ({ upsertSession: vi.fn(), })); -import { capturePaneVisible, capturePaneHistory, getPaneSize, startPipePaneStream } from '../../src/agent/tmux.js'; +import { capturePaneVisible, capturePaneHistory, getPaneSize, startPipePaneStream, sessionExists } from '../../src/agent/tmux.js'; +import { getSession } from '../../src/store/session-store.js'; import { TerminalStreamer } from '../../src/daemon/terminal-streamer.js'; import { TimelineEmitter } from '../../src/daemon/timeline-emitter.js'; @@ -30,6 +31,8 @@ const mockCapture = capturePaneVisible as ReturnType; const mockHistory = capturePaneHistory as ReturnType; const mockSize = getPaneSize as ReturnType; const mockStartPipe = startPipePaneStream as ReturnType; +const mockSessionExists = sessionExists as ReturnType; +const mockGetSession = getSession as ReturnType; /** Flush all pending timers + microtasks so the capture loop runs. */ const flush = () => vi.advanceTimersByTimeAsync(200); @@ -45,6 +48,8 @@ describe('TerminalStreamer — snapshot behavior', () => { mockSize.mockResolvedValue({ cols: 80, rows: 4 }); mockCapture.mockResolvedValue('line0\nline1\nline2\nline3'); mockHistory.mockResolvedValue(''); + mockSessionExists.mockResolvedValue(true); + mockGetSession.mockReturnValue({ paneId: '%1' }); // Mock startPipePaneStream to return a no-op stream (never emits data) const noopStream = { on: vi.fn(), destroy: vi.fn() }; @@ -166,4 +171,28 @@ describe('TerminalStreamer — snapshot behavior', () => { // No new diffs after unsubscribe expect(received.length).toBe(countAfterFirst); }); + + it('emits a session-scoped error after pipe-pane rebind retries are exhausted', async () => { + const session = 'broken-stream-session'; + mockStartPipe.mockRejectedValue(new Error('cat spawn failed')); + + streamer.subscribe({ + sessionName: session, + send: () => {}, + onError: () => {}, + }); + + await flush(); + await vi.advanceTimersByTimeAsync(61_000); + + expect(emitSpy).toHaveBeenCalledWith( + session, + 'assistant.text', + expect.objectContaining({ + text: '⚠️ Error: Terminal stream unavailable after max retries', + streaming: false, + }), + expect.any(Object), + ); + }); }); diff --git a/test/e2e/daemon-reconnect.test.ts b/test/e2e/daemon-reconnect.test.ts index d458e3763..8fc0698e3 100644 --- a/test/e2e/daemon-reconnect.test.ts +++ b/test/e2e/daemon-reconnect.test.ts @@ -132,7 +132,7 @@ describe.skipIf(SKIP)('Daemon reconnect resilience (e2e)', () => { // Session should still be there with the same pane expect(await sessionExists(name)).toBe(true); expect(await isPaneAlive(name)).toBe(true); - }); + }, 45_000); // ── 2. Dead pane is detected and session marked on restore ─────────────── diff --git a/test/e2e/sdk-transport-flow.test.ts b/test/e2e/sdk-transport-flow.test.ts index a7391507a..c8eec6f33 100644 --- a/test/e2e/sdk-transport-flow.test.ts +++ b/test/e2e/sdk-transport-flow.test.ts @@ -235,6 +235,7 @@ vi.mock('../../src/agent/brain-dispatcher.js', () => ({ BrainDispatcher: vi.fn() import { launchSession } from '../../src/agent/session-manager.js'; import { disconnectAll } from '../../src/agent/provider-registry.js'; import { handleWebCommand } from '../../src/daemon/command-handler.js'; +import { newSession } from '../../src/agent/tmux.js'; describe('sdk transport flow e2e', () => { @@ -312,6 +313,46 @@ describe('sdk transport flow e2e', () => { expect(mocks.store.get('deck_cxsdk_main_brain')?.codexSessionId).toBe('old-codex-thread-id'); }); + it('emits a brain-session inline error when session.restart fails', async () => { + const tmuxNewSession = newSession as ReturnType; + tmuxNewSession.mockRejectedValueOnce(new Error('tmux create failed')); + + mocks.store.set('deck_restart_fail_brain', { + name: 'deck_restart_fail_brain', + projectName: 'restart_fail', + role: 'brain', + agentType: 'shell', + projectDir: '/tmp/restart-fail', + state: 'running', + restarts: 0, + restartTimestamps: [], + createdAt: 1, + updatedAt: 1, + }); + + const serverLink = { send: vi.fn() } as any; + handleWebCommand({ + type: 'session.restart', + project: 'restart_fail', + }, serverLink); + await flushAsync(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(serverLink.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'session.error', + project: 'restart_fail', + message: 'tmux create failed', + })); + expect(mocks.emitted).toContainEqual(expect.objectContaining({ + session: 'deck_restart_fail_brain', + type: 'assistant.text', + payload: { + text: '⚠️ Error: tmux create failed', + streaming: false, + }, + })); + }); + it('switches claude-code-sdk model through /model and updates display metadata', async () => { await launchSession({ name: SESSION_CC, diff --git a/test/util/windows-launch-artifacts.test.ts b/test/util/windows-launch-artifacts.test.ts index 6a30fba37..791592708 100644 --- a/test/util/windows-launch-artifacts.test.ts +++ b/test/util/windows-launch-artifacts.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + UPGRADE_LOCK_FILE, + encodeCmdAsUtf8Bom, + encodeVbsAsUtf16, + writeVbsLauncher, + writeWatchdogCmd, +} from '../../src/util/windows-launch-artifacts.js'; // ── Mock fs so writeWatchdogCmd doesn't touch disk ───────────────────────── @@ -47,7 +54,6 @@ describe('writeWatchdogCmd', () => { }); it('generates watchdog with upgrade lock check', async () => { - const { writeWatchdogCmd, UPGRADE_LOCK_FILE } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: 'C:\\Program Files\\nodejs\\node.exe', imcodesScript: 'C:\\Users\\X\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', @@ -77,7 +83,6 @@ describe('writeWatchdogCmd', () => { }); it('uses npm global shim instead of hard-coded node+script paths', async () => { - const { writeWatchdogCmd } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: 'C:\\Program Files\\nodejs\\node.exe', imcodesScript: 'C:\\Users\\X\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', @@ -99,7 +104,6 @@ describe('writeWatchdogCmd', () => { const { existsSync } = await import('fs'); (existsSync as ReturnType).mockReturnValue(false); - const { writeWatchdogCmd } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: 'C:\\Program Files\\nodejs\\node.exe', imcodesScript: 'C:\\dev\\imcodes\\dist\\src\\index.js', @@ -117,7 +121,6 @@ describe('writeWatchdogCmd', () => { }); it('watchdog is an infinite loop with 5s retry', async () => { - const { writeWatchdogCmd } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: 'node.exe', imcodesScript: 'C:\\npm\\node_modules\\imcodes\\dist\\src\\index.js', @@ -142,7 +145,6 @@ describe('writeVbsLauncher', () => { }); it('runs watchdog CMD hidden (window style 0)', async () => { - const { writeVbsLauncher } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: '', imcodesScript: '', logPath: '', watchdogPath: 'C:\\Users\\X\\.imcodes\\daemon-watchdog.cmd', @@ -159,7 +161,6 @@ describe('writeVbsLauncher', () => { }); it('writes VBS as UTF-16 LE with BOM (required for non-ASCII paths)', async () => { - const { writeVbsLauncher } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: '', imcodesScript: '', logPath: '', watchdogPath: 'C:\\Users\\云科I\\.imcodes\\daemon-watchdog.cmd', @@ -183,7 +184,6 @@ describe('writeVbsLauncher', () => { }); it('uses On Error Resume Next so wscript never pops up an error dialog', async () => { - const { writeVbsLauncher } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: '', imcodesScript: '', logPath: '', watchdogPath: 'C:\\nonexistent\\path.cmd', @@ -198,7 +198,6 @@ describe('writeVbsLauncher', () => { describe('encodeCmdAsUtf8Bom', () => { it('prepends UTF-8 BOM (EF BB BF)', async () => { - const { encodeCmdAsUtf8Bom } = await import('../../src/util/windows-launch-artifacts.js'); const buf = encodeCmdAsUtf8Bom('@echo off\r\necho 云科\r\n'); expect(buf[0]).toBe(0xEF); expect(buf[1]).toBe(0xBB); @@ -211,7 +210,6 @@ describe('encodeCmdAsUtf8Bom', () => { describe('encodeVbsAsUtf16', () => { it('prepends UTF-16 LE BOM (FF FE)', async () => { - const { encodeVbsAsUtf16 } = await import('../../src/util/windows-launch-artifacts.js'); const buf = encodeVbsAsUtf16('WScript.Echo "云科"'); expect(buf[0]).toBe(0xFF); expect(buf[1]).toBe(0xFE); @@ -227,7 +225,6 @@ describe('writeWatchdogCmd encoding', () => { }); it('writes watchdog .cmd as UTF-8 with BOM (required for non-ASCII paths)', async () => { - const { writeWatchdogCmd } = await import('../../src/util/windows-launch-artifacts.js'); const paths = { nodeExe: 'C:\\Program Files\\nodejs\\node.exe', imcodesScript: 'C:\\Users\\云科I\\AppData\\Roaming\\npm\\node_modules\\imcodes\\dist\\src\\index.js', @@ -254,7 +251,6 @@ describe('writeWatchdogCmd encoding', () => { describe('UPGRADE_LOCK_FILE', () => { it('is under .imcodes directory', async () => { - const { UPGRADE_LOCK_FILE } = await import('../../src/util/windows-launch-artifacts.js'); expect(UPGRADE_LOCK_FILE).toContain('.imcodes'); expect(UPGRADE_LOCK_FILE).toContain('upgrade.lock'); }); diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 795d8854a..a982e43e1 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -83,7 +83,7 @@ const CODEX_MODELS: CodexModelChoice[] = [...CODEX_MODEL_IDS] as CodexModelChoic const SINGLE_P2P_MODES: string[] = ['solo', 'audit', 'review', 'plan', 'brainstorm', 'discuss']; const P2P_MODES: string[] = [...SINGLE_P2P_MODES, ...COMBO_PRESETS.map((c) => c.key), P2P_CONFIG_MODE]; const P2P_MODE_I18N: Record = { solo: 'p2p.mode_solo', audit: 'p2p.mode_audit', review: 'p2p.mode_review', plan: 'p2p.mode_plan', brainstorm: 'p2p.mode_brainstorm', discuss: 'p2p.mode_discuss', [P2P_CONFIG_MODE]: 'p2p.mode_config' }; -const P2P_SINGLE_COLORS: Record = { solo: '#6b7280', audit: '#f59e0b', review: '#3b82f6', plan: '#06b6d4', brainstorm: '#a78bfa', discuss: '#22c55e', [P2P_CONFIG_MODE]: '#94a3b8' }; +const P2P_SINGLE_COLORS: Record = { solo: '#dbe7f5', audit: '#f59e0b', review: '#3b82f6', plan: '#06b6d4', brainstorm: '#a78bfa', discuss: '#22c55e', [P2P_CONFIG_MODE]: '#94a3b8' }; function getP2pModeColor(mode: string): string { if (P2P_SINGLE_COLORS[mode]) return P2P_SINGLE_COLORS[mode]; @@ -107,6 +107,11 @@ function getP2pModeLabel(mode: string, t: (key: string) => string): string { return mode; } +function getP2pMenuItemColor(mode: string, active: boolean): string { + if (mode === 'solo') return active ? '#f8fafc' : '#dbe7f5'; + return getP2pModeColor(mode); +} + interface PendingAtTarget { session: string; mode: string; @@ -1070,24 +1075,24 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on {/* P2P mode selector — hidden for shell/script sessions */} {!isShellLike &&
- {/* Gear button for P2P config panel — always visible */} {p2pOpen && ( )} -
+
{/* Shortcut row — hidden in chat mode and compact mode */} {!hideShortcuts && !compact &&
diff --git a/web/src/components/SubSessionCard.tsx b/web/src/components/SubSessionCard.tsx index 08436c8b5..e9141db27 100644 --- a/web/src/components/SubSessionCard.tsx +++ b/web/src/components/SubSessionCard.tsx @@ -107,18 +107,6 @@ export function SubSessionCard({ sub, ws, connected, isOpen, isFocused, onOpen, const busy = useMemo(() => isVisuallyBusy(sub.state, !!getActiveThinkingTs(events)), [events, sub.state]); - // Flash red when sub-session transitions to idle - const [idleFlash, setIdleFlash] = useState(false); - const prevStateRef = useRef(sub.state); - useEffect(() => { - if (prevStateRef.current === 'running' && sub.state === 'idle') { - setIdleFlash(true); - const t = setTimeout(() => setIdleFlash(false), 3000); - return () => clearTimeout(t); - } - prevStateRef.current = sub.state; - }, [sub.state]); - // Preview cards always follow the latest content. const [showScrollBtn, setShowScrollBtn] = useState(false); useEffect(() => { @@ -204,7 +192,7 @@ export function SubSessionCard({ sub, ws, connected, isOpen, isFocused, onOpen, return (
{ if (!draggingRef.current) onOpen(); }} > diff --git a/web/src/components/SubSessionWindow.tsx b/web/src/components/SubSessionWindow.tsx index 2948403bc..9b10682ff 100644 --- a/web/src/components/SubSessionWindow.tsx +++ b/web/src/components/SubSessionWindow.tsx @@ -122,17 +122,6 @@ export function SubSessionWindow({ const [viewMode, setViewMode] = useState(isShell ? 'terminal' : isTransport ? 'chat' : initial.viewMode); // confirmClose removed — × now minimizes instead of terminating - // Flash red when sub-session transitions to idle - const [idleFlash, setIdleFlash] = useState(false); - const prevStateRef = useRef(sub.state); - useEffect(() => { - if (prevStateRef.current === 'running' && sub.state === 'idle') { - setIdleFlash(true); - const t = setTimeout(() => setIdleFlash(false), 3000); - return () => clearTimeout(t); - } - prevStateRef.current = sub.state; - }, [sub.state]); const inputRef = useRef(null); const termFitFnRef = useRef<(() => void) | null>(null); const geomRef = useRef(geom); @@ -344,7 +333,7 @@ export function SubSessionWindow({ : { position: 'fixed', left: geom.x, top: geom.y, width: geom.w, height: geom.h, zIndex }; return ( -
+
{/* 8-direction resize handles (desktop only) */} {!isMobile && (['n','s','e','w','ne','nw','se','sw'] as ResizeDir[]).map((dir) => (
diff --git a/web/src/styles.css b/web/src/styles.css index bc86a0729..e689d309d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -546,7 +546,7 @@ body { @keyframes subcard-sci-fi { 0% { border-color: #334155; box-shadow: 0 0 4px #00e5ff22; } 40% { border-color: #00e5ff; box-shadow: 0 0 12px #00e5ff88, 0 0 24px #00e5ff33; } 70% { border-color: #818cf8; box-shadow: 0 0 12px #818cf888, 0 0 24px #818cf833; } 100% { border-color: #334155; box-shadow: 0 0 4px #00e5ff22; } } .subcard-running-pulse { animation: subcard-sci-fi 3.6s ease-in-out infinite; } @keyframes subcard-focus-glow { 0%, 100% { box-shadow: 0 0 8px #60a5faaa, 0 0 20px #3b82f644; } 50% { box-shadow: 0 0 16px #60a5facc, 0 0 32px #3b82f666; } } -.subcard-focused:not(.subcard-running-pulse) { border-color: #60a5fa; border-width: 2px; animation: subcard-focus-glow 2s ease-in-out infinite; } +.subcard-focused:not(.subcard-running-pulse) { border-color: #60a5fa; border-width: 2px; box-shadow: 0 0 8px #60a5faaa, 0 0 20px #3b82f644; } .subcard-focused.subcard-running-pulse { border-width: 2px; box-shadow: 0 0 12px #60a5faaa, 0 0 24px #3b82f644; } @keyframes subcard-idle-flash { 0%, 100% { border-color: #334155; box-shadow: none; } 50% { border-color: #ef4444; box-shadow: 0 0 16px #ef444488, 0 0 4px #ef444444; } } .subcard-idle-flash { animation: subcard-idle-flash 0.5s ease-in-out 6 !important; } diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index e6d343b48..0737ba2f6 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -111,6 +111,29 @@ describe('SessionControls', () => { expect(menuBtn).toBeDefined(); }); + it('only shows the scan sweep while the session is running', () => { + const idleView = render( + , + ); + expect(idleView.container.querySelector('.controls-wrapper-running')).toBeNull(); + idleView.unmount(); + + const runningView = render( + , + ); + expect(runningView.container.querySelector('.controls-wrapper-running')).toBeTruthy(); + }); + it('send button is disabled when input is empty', () => { render(); const sendBtn = screen.getByRole('button', { name: /send/i }); diff --git a/web/test/components/SubSessionCard.test.tsx b/web/test/components/SubSessionCard.test.tsx index 6a4545f52..db789f118 100644 --- a/web/test/components/SubSessionCard.test.tsx +++ b/web/test/components/SubSessionCard.test.tsx @@ -84,4 +84,24 @@ describe('SubSessionCard', () => { expect(preview.scrollTop).toBe(1500); }); }); + + it('does not apply running or idle-flash classes to idle cards', () => { + const { container } = render( + , + ); + + const card = container.querySelector('.subcard') as HTMLDivElement; + expect(card.className).toContain('subcard-focused'); + expect(card.className).not.toContain('subcard-running-pulse'); + expect(card.className).not.toContain('subcard-idle-flash'); + }); }); From 5ac817e3c4dfc3e7cf6a5f4d4ae4e9a1bbd30c95 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 16:28:48 +0800 Subject: [PATCH 05/92] Revert "fix(push): use dedicated dispatcher for relay to avoid stale keep-alive sockets" This reverts commit 9c58ad694a9fda827d0d7dbc5857eb909e3089be. --- server/src/routes/push.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/server/src/routes/push.ts b/server/src/routes/push.ts index b16e38b2b..ef8b54abb 100644 --- a/server/src/routes/push.ts +++ b/server/src/routes/push.ts @@ -7,25 +7,12 @@ * Android: FCM legacy HTTP API */ import { Hono } from 'hono'; -import { Agent } from 'undici'; import type { Env } from '../env.js'; import type { Database } from '../db/client.js'; import { requireAuth } from '../security/authorization.js'; import { SignJWT, importPKCS8 } from 'jose'; import logger from '../util/logger.js'; -// Dedicated dispatcher for relay calls. Cloudflare edge sometimes silently -// half-closes long-lived keep-alive sockets, leaving undici's pool with stale -// connections that produce a persistent 502 storm until the process restarts. -// Short keepAliveTimeout + small max connections forces fresh sockets often -// enough to recover automatically. -const relayDispatcher = new Agent({ - keepAliveTimeout: 4_000, - keepAliveMaxTimeout: 10_000, - connections: 8, - pipelining: 0, -}); - export const pushRoutes = new Hono<{ Bindings: Env; Variables: { userId: string; role: string } }>(); // Auth required for register/unregister but NOT for relay (self-hosted servers call relay without auth) @@ -197,8 +184,6 @@ async function relayPush(relayBaseUrl: string, token: string, platform: string, data: payload.data, }), signal: AbortSignal.timeout(10_000), - // @ts-expect-error undici dispatcher option, not in standard fetch types - dispatcher: relayDispatcher, }); if (!res.ok) { From 785a810b84c5016c682c9e1fd16589495cf7eb86 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 16:44:02 +0800 Subject: [PATCH 06/92] ci(docker): smoke-test image before push to block crash-on-startup releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker job previously built and pushed in a single step. If the resulting image crashed on startup (e.g. ERR_MODULE_NOT_FOUND from a missing prod dep that only existed transitively in dev install), CI still happily published the image to ghcr.io and it auto-deployed. Split the build into three steps: build → load locally → docker run an import-only smoke test against the actual production image → cache-hit push. The smoke test invokes the image's node entry to import index.js, which statically pulls in every route module under production deps. Any top-level eval failure now blocks the push instead of shipping it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e5debfa2..96ee7491a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -413,7 +413,38 @@ jobs: fi echo "npm_version=${NPM_VERSION}" >> "$GITHUB_OUTPUT" - - name: Build and push + # Build the image into the local docker daemon first so we can smoke-test + # it BEFORE pushing. Without this step, an image whose Node process + # crashes on startup (e.g. ERR_MODULE_NOT_FOUND from a missing prod dep) + # would still get pushed and auto-deployed to production. + - name: Build (load to local docker for smoke test) + uses: docker/build-push-action@v6 + with: + context: . + file: server/Dockerfile + platforms: linux/amd64 + tags: imcodes-smoke:test + build-args: | + BUILD_TIME=${{ steps.ts.outputs.value }} + OTA_VERSION=${{ steps.ota.outputs.version }} + APP_VERSION=${{ steps.version_meta.outputs.npm_version }} + cache-from: type=gha + cache-to: type=gha,mode=max + load: true + push: false + + - name: Container startup smoke test + run: | + set -euo pipefail + # Override entrypoint to run an import-only check inside the actual + # production image. index.ts has an isMain guard so the import + # resolves all static deps (including all routes/* modules) without + # binding ports or hitting the database. Any ERR_MODULE_NOT_FOUND or + # other top-level eval failure surfaces here, before the image ships. + docker run --rm --entrypoint node imcodes-smoke:test \ + -e "import('./dist/server/src/index.js').then(() => { console.log('OK: image loads cleanly'); process.exit(0); }).catch(e => { console.error('FAIL:', e.message); console.error(e.stack); process.exit(1); })" + + - name: Build and push (cache hit — only pushes layers) uses: docker/build-push-action@v6 with: context: . From e597fa8c871bf2fec67668fceb2aff5ab00cefbd Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 17:18:11 +0800 Subject: [PATCH 07/92] fix(push): log every silently swallowed error Three silent catches in the push pipeline made today's APNS_KEY=12-char secret outage take hours to diagnose. Each one swallowed an exception without a single log line, leaving 78 server with "Relay 502" but the K8s pod logs completely empty: - push.ts relay route: importPKCS8() throw was caught and turned into a 502 JSON response with no log - bridge.ts dispatchEventPush: dynamic import failure (e.g. missing prod dep) silently disabled all push notifications - push.ts dispatchPush: badge_count UPDATE failure was a bare catch All three now log via the existing pino logger so the next failure surfaces immediately in `kubectl logs` instead of silently dropping notifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/routes/push.ts | 8 +++++++- server/src/ws/bridge.ts | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/routes/push.ts b/server/src/routes/push.ts index ef8b54abb..f58142fe1 100644 --- a/server/src/routes/push.ts +++ b/server/src/routes/push.ts @@ -92,6 +92,10 @@ pushRoutes.post('/relay', async (c) => { } catch (err) { const msg = err instanceof Error ? err.message : String(err); const unregistered = err instanceof PushError && err.unregistered; + logger.warn( + { err, platform: body.platform, token: body.token.slice(0, 10) + '...', unregistered }, + 'Push relay failed', + ); return c.json({ error: msg, unregistered }, unregistered ? 410 : 502); } }); @@ -145,7 +149,9 @@ export async function dispatchPush(payload: PushPayload, envOrDb: Env | Database [payload.userId], ); if (rows.length > 0) badgeCount = rows[0].badge_count; - } catch { /* fallback to 1 */ } + } catch (err) { + logger.warn({ err, userId: payload.userId }, 'Failed to increment badge_count — falling back to 1'); + } payload.badge = badgeCount; const hasApns = !!(env.APNS_KEY && env.APNS_KEY_ID && env.APNS_TEAM_ID); diff --git a/server/src/ws/bridge.ts b/server/src/ws/bridge.ts index 3f2fa454b..007d2a7a5 100644 --- a/server/src/ws/bridge.ts +++ b/server/src/ws/bridge.ts @@ -2137,7 +2137,10 @@ export class WsBridge { const server = await db.queryOne<{ user_id: string; name: string }>('SELECT user_id, name FROM servers WHERE id = $1', [this.serverId]); if (!server) return; - const { dispatchPush } = await import('../routes/push.js').catch(() => ({ dispatchPush: null })); + const { dispatchPush } = await import('../routes/push.js').catch((err) => { + logger.error({ err }, 'Failed to import push module — push notifications disabled'); + return { dispatchPush: null }; + }); if (!dispatchPush) return; const sessionName = String(msg.session ?? msg.sessionId ?? ''); From ecb39e0ab64ed4712ec49cdd10e5d193188977a9 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 17:58:56 +0800 Subject: [PATCH 08/92] Fix running-only visual activity semantics --- web/src/components/P2pProgressCard.tsx | 31 +++--- web/src/components/SessionControls.tsx | 5 +- web/src/components/SubSessionBar.tsx | 28 +++--- web/src/i18n/locales/en.json | 18 ++++ web/src/i18n/locales/es.json | 18 ++++ web/src/i18n/locales/ja.json | 18 ++++ web/src/i18n/locales/ko.json | 18 ++++ web/src/i18n/locales/ru.json | 18 ++++ web/src/i18n/locales/zh-CN.json | 18 ++++ web/src/i18n/locales/zh-TW.json | 18 ++++ web/src/styles.css | 14 ++- web/src/thinking-utils.ts | 7 +- web/test/components/P2pProgressCard.test.tsx | 59 ++++++++++- web/test/components/SubSessionBar.test.tsx | 100 +++++++++++++++++++ 14 files changed, 335 insertions(+), 35 deletions(-) create mode 100644 web/test/components/SubSessionBar.test.tsx diff --git a/web/src/components/P2pProgressCard.tsx b/web/src/components/P2pProgressCard.tsx index 66e9044c2..9399d208e 100644 --- a/web/src/components/P2pProgressCard.tsx +++ b/web/src/components/P2pProgressCard.tsx @@ -59,6 +59,11 @@ function statusClassName(status: P2pProgressNode['status']): string { : 'is-pending'; } +function progressStatusClassName(status: P2pProgressNode['status'], animateActive: boolean): string { + if (status !== 'active') return statusClassName(status); + return animateActive ? 'is-active' : 'is-active-static'; +} + // ── Elapsed timer ────────────────────────────────────────────────────────── function formatElapsed(ms: number): string { @@ -157,7 +162,9 @@ function DiscussionActionButton({ active, compact, onAction }: ActionButtonProps export function P2pProgressCard({ discussion, compact = false, mobile = false, hidden = false, onToggleHide, onClick, onStopDiscussion }: Props) { const { t } = useTranslation(); const nodes = discussion.nodes ?? []; - const isActive = discussion.state !== 'done' && discussion.state !== 'failed'; + const isRunning = discussion.state === 'running'; + const isTerminal = discussion.state === 'done' || discussion.state === 'failed'; + const isActive = !isTerminal; const showActionButton = discussion.state === 'failed' || isActive; const totalHopsPerRound = discussion.totalHops ?? 0; const completedRoundHops = useMemo(() => { @@ -183,10 +190,10 @@ export function P2pProgressCard({ discussion, compact = false, mobile = false, h ), [discussion.activePhase, t]); // ── Timers ─────────────────────────────────────────────────────────────── - const totalElapsed = useElapsedTimer(discussion.startedAt, isActive); + const totalElapsed = useElapsedTimer(discussion.startedAt, isRunning); // Hop timer resets when round+hop+phase changes - const hopKey = isActive ? `${discussion.currentRound}:${discussion.activeHop}:${discussion.activePhase}` : null; - const hopElapsed = useHopTimer(hopKey, isActive, discussion.hopStartedAt); + const hopKey = isRunning ? `${discussion.currentRound}:${discussion.activeHop}:${discussion.activePhase}` : null; + const hopElapsed = useHopTimer(hopKey, isRunning, discussion.hopStartedAt); // ── Mobile ultra-compact: single-line summary ────────────────────────── if (mobile) { @@ -201,17 +208,17 @@ export function P2pProgressCard({ discussion, compact = false, mobile = false, h P2P {roundText} {hopText && {hopText}} - {isActive && {totalElapsed}} + {isRunning && {totalElapsed}} {phaseLabel && {phaseLabel}} {!hidden && activeNode && ( - + {activeNode.displayLabel ?? activeNode.label} {activeNode.phase && {t(`p2p.discussions.phase_${activeNode.phase}`)}} )} - {isActive && {hopElapsed}} + {isRunning && {hopElapsed}} {onToggleHide && (
{discussion.topic || t('p2p.discussions.untitled')}
- {isActive && {totalElapsed}} + {isRunning && {totalElapsed}} {showActionButton && onStopDiscussion && ( {roundText} {hopText && {hopText}} - {isActive && {hopElapsed}} + {isRunning && {hopElapsed}} {phaseLabel && ( {phaseLabel} )} @@ -321,7 +328,7 @@ export function P2pProgressCard({ discussion, compact = false, mobile = false, h {roundSegments.map((seg) => (
{seg.roundNum} @@ -342,7 +349,7 @@ export function P2pProgressCard({ discussion, compact = false, mobile = false, h {hopSegments.map((seg) => (
{seg.hopNum} @@ -357,7 +364,7 @@ export function P2pProgressCard({ discussion, compact = false, mobile = false, h {nodes.length > 0 && (
{nodes.map((node, idx) => ( -
+
{node.displayLabel ?? node.label} {node.mode && {t(`p2p.mode.${node.mode}`, node.mode)}} diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index ba5242b0e..9546e07f9 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -13,6 +13,7 @@ import { VoiceOverlay } from './VoiceOverlay.js'; import { AtPicker } from './AtPicker.js'; import { P2pConfigPanel } from './P2pConfigPanel.js'; import { uploadFile, getUserPref, saveUserPref } from '../api.js'; +import { isRunningSessionState } from '../thinking-utils.js'; import { P2P_CONFIG_MODE, COMBO_PRESETS, COMBO_SEPARATOR } from '@shared/p2p-modes.js'; import type { P2pSavedConfig } from '@shared/p2p-modes.js'; import { getQwenAuthTier, QWEN_AUTH_TIERS } from '@shared/qwen-auth.js'; @@ -46,7 +47,7 @@ interface Props { onSubRestart?: () => void; onSubNew?: () => void; onSubStop?: () => void; - /** When true, show the scan-sweep animation even if session state is not 'running'. */ + /** Legacy prop retained for callers that still pass thinking state for labels/timers. */ activeThinking?: boolean; /** Mobile: open full-screen file browser overlay. */ mobileFileBrowserOpen?: boolean; @@ -257,7 +258,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on const p2pRef = useRef(null); const quickWrapRef = useRef(null); const confirmTimerRef = useRef | null>(null); - const showRunningSweep = !compact && activeSession?.state === 'running'; + const showRunningSweep = !compact && isRunningSessionState(activeSession?.state); // Internal ref for contenteditable — also written to the external inputRef const divRef = useRef(null); // History navigation state diff --git a/web/src/components/SubSessionBar.tsx b/web/src/components/SubSessionBar.tsx index 2d4ff4f15..7c257a0b5 100644 --- a/web/src/components/SubSessionBar.tsx +++ b/web/src/components/SubSessionBar.tsx @@ -260,22 +260,22 @@ export function SubSessionBar({ subSessions, openIds, onOpen, onClose, onRestart
{/* Toolbar */}
- {!collapsed && ( <> - - Subs ({subSessions.length}) + {t('subsessionBar.subs_count', { count: subSessions.length })} {/* Desktop: full stats in expanded toolbar */} {stats && ( @@ -324,9 +324,9 @@ export function SubSessionBar({ subSessions, openIds, onOpen, onClose, onRestart ); })()} - + {onViewDiscussions && ( - )} @@ -335,7 +335,7 @@ export function SubSessionBar({ subSessions, openIds, onOpen, onClose, onRestart class="subcard-toolbar-btn" data-onboarding="repo-page" onClick={() => onViewRepo()} - title="Repository" + title={t('subsessionBar.repository')} style={{ marginLeft: 4, fontSize: 11, @@ -345,7 +345,7 @@ export function SubSessionBar({ subSessions, openIds, onOpen, onClose, onRestart )} {onViewCron && ( - )} @@ -354,9 +354,9 @@ export function SubSessionBar({ subSessions, openIds, onOpen, onClose, onRestart {/* Size settings panel */} {!collapsed && showSizePanel && (
- Card size + {t('subsessionBar.card_size')} - - + +
)} {/* Empty state: no sub-sessions and expanded */} {!collapsed && subSessions.length === 0 && discussions.length === 0 && (
- No sub-sessions — click + to add one + {t('subsessionBar.empty_prefix')} + {t('subsessionBar.empty_suffix')}
)} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4ada75e8a..06740f2a4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -582,6 +582,24 @@ "collapse_all": "Collapse all", "drag_to_resize": "Drag to resize" }, + "subsessionBar": { + "show": "Show", + "hide": "Hide", + "layout_double": "Double row", + "layout_single": "Single row", + "card_size": "Card size", + "subs_count": "Subs ({{count}})", + "new_sub_session": "New sub-session", + "p2p_discussions": "P2P discussions", + "repository": "Repository", + "scheduled_tasks": "Scheduled Tasks", + "width_short": "W", + "height_short": "H", + "apply": "Apply", + "reset": "Reset", + "empty_prefix": "No sub-sessions — click", + "empty_suffix": "to add one" + }, "localWebPreview": { "title": "Local Web Preview", "port": "Port", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index 4813d2e1b..fd9dab988 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -582,6 +582,24 @@ "collapse_all": "Contraer todo", "drag_to_resize": "Arrastra para cambiar el tamaño" }, + "subsessionBar": { + "show": "Mostrar", + "hide": "Ocultar", + "layout_double": "Doble fila", + "layout_single": "Fila única", + "card_size": "Tamaño de tarjeta", + "subs_count": "Subs ({{count}})", + "new_sub_session": "Nueva sub-sesión", + "p2p_discussions": "Discusiones P2P", + "repository": "Repositorio", + "scheduled_tasks": "Tareas programadas", + "width_short": "A", + "height_short": "H", + "apply": "Aplicar", + "reset": "Restablecer", + "empty_prefix": "No hay sub-sesiones; haz clic en", + "empty_suffix": "para agregar una" + }, "localWebPreview": { "title": "Vista previa web local", "port": "Puerto", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 90681305d..99e3d986f 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -582,6 +582,24 @@ "collapse_all": "すべて折りたたむ", "drag_to_resize": "ドラッグしてサイズ変更" }, + "subsessionBar": { + "show": "表示", + "hide": "非表示", + "layout_double": "2段表示", + "layout_single": "1段表示", + "card_size": "カードサイズ", + "subs_count": "Subs ({{count}})", + "new_sub_session": "新しいサブセッション", + "p2p_discussions": "P2P ディスカッション", + "repository": "リポジトリ", + "scheduled_tasks": "スケジュールされたタスク", + "width_short": "幅", + "height_short": "高", + "apply": "適用", + "reset": "リセット", + "empty_prefix": "サブセッションはまだありません。", + "empty_suffix": "をクリックして追加" + }, "localWebPreview": { "title": "ローカルWebプレビュー", "port": "ポート", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index d5fe2050a..6d7219694 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -582,6 +582,24 @@ "collapse_all": "모두 접기", "drag_to_resize": "드래그하여 크기 조절" }, + "subsessionBar": { + "show": "표시", + "hide": "숨기기", + "layout_double": "2줄", + "layout_single": "1줄", + "card_size": "카드 크기", + "subs_count": "Subs ({{count}})", + "new_sub_session": "새 하위 세션", + "p2p_discussions": "P2P 토론", + "repository": "저장소", + "scheduled_tasks": "예약 작업", + "width_short": "너비", + "height_short": "높이", + "apply": "적용", + "reset": "재설정", + "empty_prefix": "하위 세션이 없습니다. ", + "empty_suffix": "를 눌러 추가하세요" + }, "localWebPreview": { "title": "로컬 웹 미리보기", "port": "포트", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f7df4f212..36fbfb94f 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -582,6 +582,24 @@ "collapse_all": "Свернуть все", "drag_to_resize": "Перетащите для изменения размера" }, + "subsessionBar": { + "show": "Показать", + "hide": "Скрыть", + "layout_double": "Две строки", + "layout_single": "Одна строка", + "card_size": "Размер карточки", + "subs_count": "Subs ({{count}})", + "new_sub_session": "Новая подсессия", + "p2p_discussions": "P2P-обсуждения", + "repository": "Репозиторий", + "scheduled_tasks": "Запланированные задачи", + "width_short": "Ш", + "height_short": "В", + "apply": "Применить", + "reset": "Сбросить", + "empty_prefix": "Подсессий пока нет — нажмите", + "empty_suffix": "чтобы добавить" + }, "localWebPreview": { "title": "Локальный веб‑предпросмотр", "port": "Порт", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 8275775af..17c5646dd 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -582,6 +582,24 @@ "collapse_all": "全部折叠", "drag_to_resize": "拖动调整宽度" }, + "subsessionBar": { + "show": "显示", + "hide": "隐藏", + "layout_double": "双行", + "layout_single": "单行", + "card_size": "卡片尺寸", + "subs_count": "Subs({{count}})", + "new_sub_session": "新建子会话", + "p2p_discussions": "P2P 讨论", + "repository": "仓库", + "scheduled_tasks": "定时任务", + "width_short": "宽", + "height_short": "高", + "apply": "应用", + "reset": "重置", + "empty_prefix": "还没有子会话,点击", + "empty_suffix": "添加一个" + }, "localWebPreview": { "title": "本地网页预览", "port": "端口", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 7b4bbcee4..b244daa75 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -582,6 +582,24 @@ "collapse_all": "全部折疊", "drag_to_resize": "拖動調整寬度" }, + "subsessionBar": { + "show": "顯示", + "hide": "隱藏", + "layout_double": "雙行", + "layout_single": "單行", + "card_size": "卡片尺寸", + "subs_count": "Subs({{count}})", + "new_sub_session": "新增子會話", + "p2p_discussions": "P2P 討論", + "repository": "儲存庫", + "scheduled_tasks": "排程任務", + "width_short": "寬", + "height_short": "高", + "apply": "套用", + "reset": "重設", + "empty_prefix": "目前沒有子會話,點擊", + "empty_suffix": "新增一個" + }, "localWebPreview": { "title": "本機網頁預覽", "port": "連接埠", diff --git a/web/src/styles.css b/web/src/styles.css index e689d309d..a6348a252 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1033,14 +1033,17 @@ body { opacity: 0.95; } .discussions-progress-segment.is-done { background: linear-gradient(90deg, #16a34a, #4ade80); box-shadow: 0 0 10px rgba(74, 222, 128, 0.35); } -.discussions-progress-segment.is-active { +.discussions-progress-segment.is-active, +.discussions-progress-segment.is-active-static { background: linear-gradient(90deg, #2563eb, #38bdf8); box-shadow: 0 0 12px rgba(56, 189, 248, 0.45); - animation: discussions-active-pulse 3s ease-in-out infinite; - will-change: opacity, transform; outline: 1px solid rgba(125, 211, 252, 0.75); z-index: 1; } +.discussions-progress-segment.is-active { + animation: discussions-active-pulse 3s ease-in-out infinite; + will-change: opacity, transform; +} .discussions-progress-segment.is-skipped { background: linear-gradient(90deg, #dc2626, #f87171); box-shadow: 0 0 10px rgba(248, 113, 113, 0.35); } .discussions-progress-segment.is-pending { background: linear-gradient(90deg, #1e293b, #475569); } .discussions-progress-segment-index { @@ -1088,10 +1091,13 @@ body { color: #64748b; } .discussions-progress-node.is-done { color: #4ade80; border-color: rgba(34, 197, 94, 0.25); } -.discussions-progress-node.is-active { +.discussions-progress-node.is-active, +.discussions-progress-node.is-active-static { color: #60a5fa; border-color: rgba(59, 130, 246, 0.4); box-shadow: 0 0 16px rgba(37, 99, 235, 0.14); +} +.discussions-progress-node.is-active { animation: discussions-active-chip 3s ease-in-out infinite; will-change: opacity; } diff --git a/web/src/thinking-utils.ts b/web/src/thinking-utils.ts index d815d932e..140c02754 100644 --- a/web/src/thinking-utils.ts +++ b/web/src/thinking-utils.ts @@ -63,7 +63,10 @@ export function getActiveStatusText(events: Array<{ type: string; payload?: Reco return null; } -export function isVisuallyBusy(sessionState: string | undefined, _activeThinking: boolean): boolean { - if (!sessionState || sessionState === 'idle' || sessionState === 'stopped') return false; +export function isRunningSessionState(sessionState: string | undefined): boolean { return sessionState === 'running'; } + +export function isVisuallyBusy(sessionState: string | undefined, _activeThinking: boolean): boolean { + return isRunningSessionState(sessionState); +} diff --git a/web/test/components/P2pProgressCard.test.tsx b/web/test/components/P2pProgressCard.test.tsx index b567bdd0a..833de12bb 100644 --- a/web/test/components/P2pProgressCard.test.tsx +++ b/web/test/components/P2pProgressCard.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { h } from 'preact'; import { render, screen, cleanup, fireEvent } from '@testing-library/preact'; @@ -14,6 +14,10 @@ vi.mock('react-i18next', () => ({ import { P2pProgressCard } from '../../src/components/P2pProgressCard.js'; describe('P2pProgressCard', () => { + beforeEach(() => { + HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -70,4 +74,57 @@ describe('P2pProgressCard', () => { fireEvent.click(closeButton); expect(onStopDiscussion).toHaveBeenCalledWith('p2p_run_failed'); }); + + it('keeps active progress highlighted but static once the discussion is no longer running', () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('.is-active').length).toBe(0); + expect(container.querySelectorAll('.is-active-static').length).toBeGreaterThan(0); + expect(container.querySelector('.p2p-timer-total')).toBeNull(); + }); + + it('preserves animated progress while the discussion is running', () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('.is-active').length).toBeGreaterThan(0); + expect(container.querySelector('.p2p-timer-total')).toBeTruthy(); + }); }); diff --git a/web/test/components/SubSessionBar.test.tsx b/web/test/components/SubSessionBar.test.tsx new file mode 100644 index 000000000..fd1b94f43 --- /dev/null +++ b/web/test/components/SubSessionBar.test.tsx @@ -0,0 +1,100 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { h } from 'preact'; +import { cleanup, fireEvent, render } from '@testing-library/preact'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, vars?: Record) => { + if (key === 'subsessionBar.subs_count') return `Subs (${vars?.count ?? 0})`; + return key; + }, + }), +})); + +vi.mock('../../src/components/SubSessionCard.js', () => ({ + SubSessionCard: () => null, +})); + +vi.mock('../../src/components/P2pProgressCard.js', () => ({ + P2pProgressCard: () => null, +})); + +vi.mock('../../src/api.js', () => ({ + reorderSubSessions: vi.fn(), +})); + +import { SubSessionBar } from '../../src/components/SubSessionBar.js'; +import type { SubSession } from '../../src/hooks/useSubSessions.js'; + +function makeSubSession(overrides: Partial = {}): SubSession { + return { + id: 'sub-1', + serverId: 'srv-1', + type: 'codex', + shellBin: null, + cwd: '/tmp', + label: 'worker', + ccSessionId: null, + geminiSessionId: null, + parentSession: 'deck_proj_brain', + ccPresetId: null, + sessionName: 'deck_sub_sub-1', + state: 'idle', + ...overrides, + }; +} + +describe('SubSessionBar', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('only applies the running pulse to collapsed mini cards while the sub-session is running', () => { + const idleView = render( + , + ); + + fireEvent.click(idleView.container.querySelector('.subcard-toolbar-btn') as HTMLButtonElement); + const idleCard = idleView.container.querySelector('.subsession-card') as HTMLButtonElement; + expect(idleCard.className).not.toContain('subcard-running-pulse'); + idleView.unmount(); + + const runningView = render( + , + ); + + fireEvent.click(runningView.container.querySelector('.subcard-toolbar-btn') as HTMLButtonElement); + const runningCard = runningView.container.querySelector('.subsession-card') as HTMLButtonElement; + expect(runningCard.className).toContain('subcard-running-pulse'); + }); +}); From d82293014d363af55c0a7d4781ea2bc9abf38af5 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 18:11:53 +0800 Subject: [PATCH 09/92] Prefer SDK defaults for sub-session start --- package.json | 2 +- web/src/components/StartSubSessionDialog.tsx | 6 +++--- .../components/StartSubSessionDialog.test.tsx | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 53cc891bf..c63c41db6 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test:web": "vitest run --project web", "test:e2e": "vitest run --project e2e", "test:integration": "vitest run --workspace vitest.integration.config.ts", - "test:coverage": "vitest run --coverage && node scripts/write-coverage-summary.mjs", + "test:coverage": "vitest run --coverage --no-file-parallelism --maxWorkers 1 --testTimeout 60000 --hookTimeout 60000 && node scripts/write-coverage-summary.mjs", "test:watch": "vitest", "lint": "eslint src/", "typecheck": "tsc --noEmit", diff --git a/web/src/components/StartSubSessionDialog.tsx b/web/src/components/StartSubSessionDialog.tsx index 001117054..ac1d67f10 100644 --- a/web/src/components/StartSubSessionDialog.tsx +++ b/web/src/components/StartSubSessionDialog.tsx @@ -20,10 +20,10 @@ interface Props { } const BASE_AGENT_TYPES = [ - { id: 'claude-code', label: 'Claude Code', icon: '⚡' }, { id: 'claude-code-sdk', label: 'Claude Code SDK', icon: '⚡' }, - { id: 'codex', label: 'Codex', icon: '📦' }, + { id: 'claude-code', label: 'Claude Code', icon: '⚡' }, { id: 'codex-sdk', label: 'Codex SDK', icon: '📦' }, + { id: 'codex', label: 'Codex', icon: '📦' }, { id: 'opencode', label: 'OpenCode', icon: '🔆' }, { id: 'gemini', label: 'Gemini CLI', icon: '♊' }, { id: 'qwen', label: 'Qwen Code', icon: '千' }, @@ -37,7 +37,7 @@ type OpenClawMode = 'new' | 'bind'; export function StartSubSessionDialog({ ws, defaultCwd, isProviderConnected: _isProviderConnected, getRemoteSessions, refreshSessions, onStart, onClose }: Props) { const { t } = useTranslation(); - const [type, setType] = useState('claude-code'); + const [type, setType] = useState('claude-code-sdk'); const [shells, setShells] = useState([]); const [shellBin, setShellBin] = useState('/bin/bash'); const [cwd, setCwd] = useState(defaultCwd ?? ''); diff --git a/web/test/components/StartSubSessionDialog.test.tsx b/web/test/components/StartSubSessionDialog.test.tsx index c55311b72..9e1135bff 100644 --- a/web/test/components/StartSubSessionDialog.test.tsx +++ b/web/test/components/StartSubSessionDialog.test.tsx @@ -48,6 +48,27 @@ describe('StartSubSessionDialog', () => { expect(screen.getByRole('button', { name: /codex_sdk/i })).toBeDefined(); }); + it('defaults to claude-code-sdk and keeps sdk options on the left', () => { + const { container } = render( + false} + getRemoteSessions={() => []} + refreshSessions={vi.fn()} + onStart={vi.fn()} + onClose={vi.fn()} + />, + ); + + const activeBtn = container.querySelector('.subsession-type-btn.active') as HTMLButtonElement | null; + expect(activeBtn?.textContent).toMatch(/claude_code_sdk/i); + + const typeButtons = Array.from(container.querySelectorAll('.subsession-type-btn')).map((el) => el.textContent ?? ''); + expect(typeButtons.indexOf('⚡ claude_code_sdk')).toBeLessThan(typeButtons.indexOf('⚡ Claude Code')); + expect(typeButtons.indexOf('📦 codex_sdk')).toBeLessThan(typeButtons.indexOf('📦 Codex')); + }); + it('defaults level to high for supported transports', () => { render( Date: Tue, 7 Apr 2026 18:22:55 +0800 Subject: [PATCH 10/92] Refine combo send confirmation UX --- web/src/components/SessionControls.tsx | 87 ++++++++++++++++++-- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/es.json | 3 + web/src/i18n/locales/ja.json | 3 + web/src/i18n/locales/ko.json | 3 + web/src/i18n/locales/ru.json | 3 + web/src/i18n/locales/zh-CN.json | 3 + web/src/i18n/locales/zh-TW.json | 3 + web/src/styles.css | 11 +++ web/test/components/SessionControls.test.tsx | 72 +++++++++++++++- 10 files changed, 184 insertions(+), 7 deletions(-) diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 9546e07f9..072f0b769 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -14,7 +14,7 @@ import { AtPicker } from './AtPicker.js'; import { P2pConfigPanel } from './P2pConfigPanel.js'; import { uploadFile, getUserPref, saveUserPref } from '../api.js'; import { isRunningSessionState } from '../thinking-utils.js'; -import { P2P_CONFIG_MODE, COMBO_PRESETS, COMBO_SEPARATOR } from '@shared/p2p-modes.js'; +import { P2P_CONFIG_MODE, COMBO_PRESETS, COMBO_SEPARATOR, isComboMode } from '@shared/p2p-modes.js'; import type { P2pSavedConfig } from '@shared/p2p-modes.js'; import { getQwenAuthTier, QWEN_AUTH_TIERS } from '@shared/qwen-auth.js'; import { getKnownQwenModelDescription, getKnownQwenModelOptions } from '@shared/qwen-models.js'; @@ -79,6 +79,7 @@ type P2pMode = string; // 'solo' | single modes | combo pipelines like 'brainsto const MODEL_STORAGE_KEY = 'imcodes-model'; const CODEX_MODEL_STORAGE_KEY = 'imcodes-codex-model'; const QWEN_MODEL_STORAGE_KEY = 'imcodes-qwen-model'; +const P2P_COMBO_CONFIRM_SKIP_PREF_KEY = 'p2p_combo_direct_send_skip_confirm'; const CODEX_MODELS: CodexModelChoice[] = [...CODEX_MODEL_IDS] as CodexModelChoice[]; const SINGLE_P2P_MODES: string[] = ['solo', 'audit', 'review', 'plan', 'brainstorm', 'discuss']; const P2P_MODES: string[] = [...SINGLE_P2P_MODES, ...COMBO_PRESETS.map((c) => c.key), P2P_CONFIG_MODE]; @@ -123,6 +124,11 @@ interface PendingSendPayload { extra: Record; } +interface PendingComboSendConfirmation { + payload: PendingSendPayload; + modeLabel: string; +} + type ManualP2pTargetCandidate = { session: string; aliases: string[]; @@ -252,6 +258,9 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on const [queuedNoticeVisible, setQueuedNoticeVisible] = useState(false); const [confirm, setConfirm] = useState(null); const [confirmLevel, setConfirmLevel] = useState(0); // 0=none, 1=first warning, 2=second warning (sub-session only) + const [skipComboSendConfirm, setSkipComboSendConfirm] = useState(false); + const [pendingComboSendConfirm, setPendingComboSendConfirm] = useState(null); + const [rememberComboSendChoice, setRememberComboSendChoice] = useState(false); const menuRef = useRef(null); const modelRef = useRef(null); const thinkingRef = useRef(null); @@ -397,8 +406,18 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on }); }, []); + useEffect(() => { + void getUserPref(P2P_COMBO_CONFIRM_SKIP_PREF_KEY).then((raw) => { + if (raw === true || raw === 'true') setSkipComboSendConfirm(true); + }); + }, []); + // Reset P2P mode on session change useEffect(() => { setP2pMode('solo'); setP2pOpen(false); }, [activeSession?.name]); + useEffect(() => { + setPendingComboSendConfirm(null); + setRememberComboSendChoice(false); + }, [activeSession?.name]); // Close menus when clicking outside useEffect(() => { @@ -645,6 +664,12 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on if (draftKey) sessionStorage.removeItem(draftKey); }, [activeSession, draftKey, onRemoveQuote, onSend, quickData, quotes, ws]); + const maybePersistComboSendSkip = useCallback(() => { + if (!rememberComboSendChoice) return; + setSkipComboSendConfirm(true); + void saveUserPref(P2P_COMBO_CONFIRM_SKIP_PREF_KEY, true).catch(() => {}); + }, [rememberComboSendChoice]); + useEffect(() => { if (!activeSession || activeSession.runtimeType !== 'transport' || activeSession.state !== 'running') { setQueuedNoticeVisible(false); @@ -654,8 +679,32 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on const handleSend = useCallback(() => { const payload = buildSendPayload(); if (!payload) return; + const comboMode = typeof payload.extra.p2pMode === 'string' ? payload.extra.p2pMode : null; + if (comboMode && isComboMode(comboMode) && !skipComboSendConfirm) { + setRememberComboSendChoice(false); + setPendingComboSendConfirm({ + payload, + modeLabel: getP2pModeLabel(comboMode, t), + }); + return; + } finalizeSend(payload); - }, [buildSendPayload, finalizeSend]); + }, [buildSendPayload, finalizeSend, skipComboSendConfirm, t]); + + const handleComboSendCancel = useCallback(() => { + maybePersistComboSendSkip(); + setPendingComboSendConfirm(null); + setRememberComboSendChoice(false); + }, [maybePersistComboSendSkip]); + + const handleComboSendConfirm = useCallback(() => { + const pending = pendingComboSendConfirm; + if (!pending) return; + maybePersistComboSendSkip(); + setPendingComboSendConfirm(null); + setRememberComboSendChoice(false); + finalizeSend(pending.payload); + }, [finalizeSend, maybePersistComboSendSkip, pendingComboSendConfirm]); // Voice overlay send handler — applies same P2P mode as text send const handleVoiceSend = useCallback((voiceText: string) => { @@ -1096,7 +1145,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on {t('p2p.settings_button')} {p2pOpen && ( - + {pendingComboSendConfirm && ( +
+
+
+

{t('p2p.combo_send_confirm_title')}

+ +
+
+
+ {t('p2p.combo_send_confirm_body', { mode: pendingComboSendConfirm.modeLabel })} +
+ +
+ + +
+
+
+
+ )} setVoiceOpen(false)} onSend={handleVoiceSend} initialText={divRef.current?.textContent ?? ''} /> {p2pConfigOpen && ( { }); }); + it('keeps the send button label unchanged after selecting a combo mode', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); + fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); + + expect(screen.getByRole('button', { name: /^send$/i })).toBeDefined(); + expect(screen.getByRole('button', { name: /mode_audit→mode_plan/i })).toBeDefined(); + }); + + it('asks for confirmation before directly sending a selected combo mode', () => { + const ws = makeWs(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); + fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); + + const input = screen.getByRole('textbox') as HTMLDivElement; + input.textContent = 'run combo'; + fireEvent.input(input); + fireEvent.click(screen.getByRole('button', { name: /^send$/i })); + + expect(screen.getByText('combo_send_confirm_title')).toBeDefined(); + expect(ws.sendSessionCommand).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })); + expect(ws.sendSessionCommand).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /^send$/i })).toBeDefined(); + expect(screen.getByText(/P2P:mode_audit→mode_plan/i)).toBeDefined(); + }); + + it('remembers skipping combo confirmation across later sends', () => { + const ws = makeWs(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); + fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); + + const input = screen.getByRole('textbox') as HTMLDivElement; + input.textContent = 'first combo'; + fireEvent.input(input); + fireEvent.click(screen.getByRole('button', { name: /^send$/i })); + + const dialog = screen.getByText('combo_send_confirm_title').closest('.dialog') as HTMLElement; + fireEvent.click(within(dialog).getByRole('checkbox')); + fireEvent.click(within(dialog).getByRole('button', { name: /^send$/i })); + + expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + sessionName: 'my-session', + text: 'first combo', + p2pMode: 'audit>plan', + p2pExcludeSameType: true, + p2pLocale: 'en', + }); + expect(saveUserPrefMock).toHaveBeenCalledWith('p2p_combo_direct_send_skip_confirm', true); + + input.textContent = 'second combo'; + fireEvent.input(input); + fireEvent.click(screen.getByRole('button', { name: /^send$/i })); + + expect(screen.queryByText('combo_send_confirm_title')).toBeNull(); + expect(ws.sendSessionCommand).toHaveBeenLastCalledWith('send', { + sessionName: 'my-session', + text: 'second combo', + p2pMode: 'audit>plan', + p2pExcludeSameType: true, + p2pLocale: 'en', + }); + }); + it('clears input after send', () => { const ws = makeWs(); render(); From f5dbe4eca96e53d22dc477024319d81cf3a1cbc3 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 18:31:13 +0800 Subject: [PATCH 11/92] Gate footer thinking animation on running state --- web/src/components/SessionPane.tsx | 1 + web/src/components/SubSessionWindow.tsx | 1 + web/src/components/UsageFooter.tsx | 6 +++-- web/src/components/pinnedPanelTypes.tsx | 1 + web/test/usage-footer.test.tsx | 34 +++++++++++++++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/web/src/components/SessionPane.tsx b/web/src/components/SessionPane.tsx index b4b4d3d71..15ebe0fda 100644 --- a/web/src/components/SessionPane.tsx +++ b/web/src/components/SessionPane.tsx @@ -248,6 +248,7 @@ export function SessionPane({ : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n); -export function UsageFooter({ usage, sessionName, 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, now }: Props) { const { t } = useTranslation(); const isCodexFamily = agentType === 'codex' || agentType === 'codex-sdk'; + const showRunningStatus = sessionState === 'running' && !!(activeThinkingTs || statusText); const [quotaNow, setQuotaNow] = useState(() => Date.now()); const displayModel = modelOverride ?? usage.model; @@ -113,7 +115,7 @@ export function UsageFooter({ usage, sessionName, agentType, modelOverride, plan
)}
- {(activeThinkingTs || statusText) && ( + {showRunningStatus && ( ··· {' '}{activeThinkingTs diff --git a/web/src/components/pinnedPanelTypes.tsx b/web/src/components/pinnedPanelTypes.tsx index c107f0560..b7ba05138 100644 --- a/web/src/components/pinnedPanelTypes.tsx +++ b/web/src/components/pinnedPanelTypes.tsx @@ -72,6 +72,7 @@ function SubSessionContent({ panel, ctx }: { panel: PinnedPanel; ctx: PanelRende { }); describe('UsageFooter', () => { + it('only shows the animated thinking dots while the session is running', () => { + const { container, rerender } = render( + , + ); + + expect(container.querySelector('.chat-thinking-dots')).toBeNull(); + + rerender( + , + ); + + expect(container.querySelector('.chat-thinking-dots')).toBeTruthy(); + }); + it('renders explicit quota label inline in the ctx footer', () => { render( Date: Tue, 7 Apr 2026 18:41:36 +0800 Subject: [PATCH 12/92] Route combo confirmation through quick and voice send --- web/src/components/SessionControls.tsx | 95 ++++++++++++-------- web/test/components/SessionControls.test.tsx | 60 ++++++++++++- 2 files changed, 115 insertions(+), 40 deletions(-) diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 072f0b769..233cff665 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -127,6 +127,7 @@ interface PendingSendPayload { interface PendingComboSendConfirmation { payload: PendingSendPayload; modeLabel: string; + clearComposer: boolean; } type ManualP2pTargetCandidate = { @@ -638,7 +639,35 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on return { text, extra }; }, [attachments, activeSession, i18n?.language, onRemoveQuote, p2pExcludeSameType, p2pMode, p2pSavedConfig, quotes, sessions, subSessions, ws]); - const finalizeSend = useCallback((payload: PendingSendPayload) => { + const buildModeOnlySendPayload = useCallback((rawText: string): PendingSendPayload | null => { + const text = rawText.trim(); + if (!text || !ws || !activeSession) return null; + + const extra: Record = {}; + const manual = extractManualP2pTargets(text, buildManualP2pCandidates(sessions, subSessions)); + let cleanText = manual.cleanText; + + if (manual.orderedTargets.length > 0) { + extra.p2pAtTargets = manual.orderedTargets; + } else if (p2pMode !== 'solo' && !text.includes('@@')) { + extra.p2pMode = p2pMode === P2P_CONFIG_MODE ? 'config' : p2pMode; + if (p2pExcludeSameType && p2pMode !== P2P_CONFIG_MODE) extra.p2pExcludeSameType = true; + if (p2pMode === P2P_CONFIG_MODE && p2pSavedConfig) { + extra.p2pSessionConfig = p2pSavedConfig.sessions; + extra.p2pRounds = p2pSavedConfig.rounds ?? 1; + if (p2pSavedConfig.extraPrompt) extra.p2pExtraPrompt = p2pSavedConfig.extraPrompt; + if (p2pSavedConfig.hopTimeoutMinutes != null) extra.p2pHopTimeoutMs = Math.min(p2pSavedConfig.hopTimeoutMinutes * 60_000, 600_000); + } + } + + if (extra.p2pAtTargets || extra.p2pMode) { + extra.p2pLocale = i18n?.language ?? 'en'; + } + + return { text: cleanText, extra }; + }, [activeSession, i18n?.language, p2pExcludeSameType, p2pMode, p2pSavedConfig, sessions, subSessions, ws]); + + const finalizeSend = useCallback((payload: PendingSendPayload, options?: { clearComposer?: boolean }) => { if (!ws || !activeSession) return; // Transport sessions queue messages internally — no frontend notice needed quickData.recordHistory(payload.text, activeSession.name); @@ -647,21 +676,22 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on } catch { return; } - pendingAtTargetsRef.current = []; - pendingConfigOverrideRef.current = null; onSend?.(activeSession.name, payload.text); - if (divRef.current) divRef.current.textContent = ''; - setHasText(false); - setAttachments([]); - // Clear quotes after send - if (quotes && quotes.length > 0) { - for (let i = quotes.length - 1; i >= 0; i--) onRemoveQuote?.(i); + if (options?.clearComposer) { + pendingAtTargetsRef.current = []; + pendingConfigOverrideRef.current = null; + if (divRef.current) divRef.current.textContent = ''; + setHasText(false); + setAttachments([]); + if (quotes && quotes.length > 0) { + for (let i = quotes.length - 1; i >= 0; i--) onRemoveQuote?.(i); + } + atSelectionLockRef.current = false; + atSelectionSnapshotRef.current = ''; + histIdxRef.current = -1; + draftRef.current = ''; + if (draftKey) sessionStorage.removeItem(draftKey); } - atSelectionLockRef.current = false; - atSelectionSnapshotRef.current = ''; - histIdxRef.current = -1; - draftRef.current = ''; - if (draftKey) sessionStorage.removeItem(draftKey); }, [activeSession, draftKey, onRemoveQuote, onSend, quickData, quotes, ws]); const maybePersistComboSendSkip = useCallback(() => { @@ -676,8 +706,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on } }, [activeSession?.name, activeSession?.runtimeType, activeSession?.state]); - const handleSend = useCallback(() => { - const payload = buildSendPayload(); + const requestSend = useCallback((payload: PendingSendPayload | null, options?: { clearComposer?: boolean }) => { if (!payload) return; const comboMode = typeof payload.extra.p2pMode === 'string' ? payload.extra.p2pMode : null; if (comboMode && isComboMode(comboMode) && !skipComboSendConfirm) { @@ -685,11 +714,16 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on setPendingComboSendConfirm({ payload, modeLabel: getP2pModeLabel(comboMode, t), + clearComposer: !!options?.clearComposer, }); return; } - finalizeSend(payload); - }, [buildSendPayload, finalizeSend, skipComboSendConfirm, t]); + finalizeSend(payload, options); + }, [finalizeSend, skipComboSendConfirm, t]); + + const handleSend = useCallback(() => { + requestSend(buildSendPayload(), { clearComposer: true }); + }, [buildSendPayload, requestSend]); const handleComboSendCancel = useCallback(() => { maybePersistComboSendSkip(); @@ -703,28 +737,13 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on maybePersistComboSendSkip(); setPendingComboSendConfirm(null); setRememberComboSendChoice(false); - finalizeSend(pending.payload); + finalizeSend(pending.payload, { clearComposer: pending.clearComposer }); }, [finalizeSend, maybePersistComboSendSkip, pendingComboSendConfirm]); // Voice overlay send handler — applies same P2P mode as text send const handleVoiceSend = useCallback((voiceText: string) => { - if (!ws || !activeSession) return; - const extra: Record = {}; - if (p2pMode !== 'solo') { - extra.p2pMode = p2pMode === P2P_CONFIG_MODE ? 'config' : p2pMode; - if (p2pExcludeSameType && p2pMode !== P2P_CONFIG_MODE) extra.p2pExcludeSameType = true; - } - if (p2pMode === P2P_CONFIG_MODE && p2pSavedConfig) { - extra.p2pSessionConfig = p2pSavedConfig.sessions; - extra.p2pRounds = p2pSavedConfig.rounds ?? 1; - if (p2pSavedConfig.extraPrompt) extra.p2pExtraPrompt = p2pSavedConfig.extraPrompt; - } - quickData.recordHistory(voiceText, activeSession.name); - try { - ws.sendSessionCommand('send', { sessionName: activeSession.name, text: voiceText, ...extra }); - } catch { return; } - onSend?.(activeSession.name, voiceText); - }, [ws, activeSession, quickData, onSend, p2pMode, p2pExcludeSameType, p2pSavedConfig]); + requestSend(buildModeOnlySendPayload(voiceText)); + }, [buildModeOnlySendPayload, requestSend]); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && activeSession?.runtimeType === 'transport' && activeSession.state === 'running') { @@ -1281,9 +1300,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on onClose={() => setQuickOpen(false)} onSelect={fillInput} onSend={(text: string) => { - if (!ws || !activeSession) return; - quickData.recordHistory(text, activeSession.name); - ws.sendSessionCommand('send', { sessionName: activeSession.name, text }); + requestSend(buildModeOnlySendPayload(text)); }} agentType={activeSession?.agentType ?? 'claude-code'} sessionName={activeSession?.name ?? ''} diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 2281275c8..0434cefd3 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -19,7 +19,9 @@ vi.mock('react-i18next', () => ({ })); vi.mock('../../src/components/QuickInputPanel.js', () => ({ - QuickInputPanel: () => null, + QuickInputPanel: ({ open, onSend }: { open: boolean; onSend: (text: string) => void }) => open ? ( + + ) : null, EMPTY_QUICK_DATA: { history: [], sessionHistory: {}, commands: [], phrases: [] }, getNavigableHistory: (data: { history: string[]; sessionHistory: Record }, sessionName?: string) => { if (!sessionName) return data.history; @@ -28,6 +30,16 @@ vi.mock('../../src/components/QuickInputPanel.js', () => ({ }, })); +vi.mock('../../src/components/VoiceOverlay.js', () => ({ + VoiceOverlay: ({ open, onSend }: { open: boolean; onSend: (text: string) => void }) => open ? ( + + ) : null, +})); + +vi.mock('../../src/components/VoiceInput.js', () => ({ + isAvailable: () => true, +})); + const uploadFileMock = vi.fn(); const getUserPrefMock = vi.fn().mockResolvedValue(null); const saveUserPrefMock = vi.fn().mockResolvedValue(undefined); @@ -234,6 +246,52 @@ describe('SessionControls', () => { }); }); + it('routes quick panel sends through the same combo confirmation flow', () => { + const ws = makeWs(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); + fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); + fireEvent.click(screen.getByTitle('title')); + fireEvent.click(screen.getByText('quick-panel-send')); + + expect(screen.getByText('combo_send_confirm_title')).toBeDefined(); + expect(ws.sendSessionCommand).not.toHaveBeenCalled(); + + const dialog = screen.getByText('combo_send_confirm_title').closest('.dialog') as HTMLElement; + fireEvent.click(within(dialog).getByRole('button', { name: /^send$/i })); + expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + sessionName: 'my-session', + text: 'quick combo message', + p2pMode: 'audit>plan', + p2pExcludeSameType: true, + p2pLocale: 'en', + }); + }); + + it('routes voice sends through the same combo confirmation flow', () => { + const ws = makeWs(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); + fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); + fireEvent.click(screen.getByTitle('voice_input')); + fireEvent.click(screen.getByText('voice-overlay-send')); + + expect(screen.getByText('combo_send_confirm_title')).toBeDefined(); + expect(ws.sendSessionCommand).not.toHaveBeenCalled(); + + const dialog = screen.getByText('combo_send_confirm_title').closest('.dialog') as HTMLElement; + fireEvent.click(within(dialog).getByRole('button', { name: /^send$/i })); + expect(ws.sendSessionCommand).toHaveBeenCalledWith('send', { + sessionName: 'my-session', + text: 'voice combo message', + p2pMode: 'audit>plan', + p2pExcludeSameType: true, + p2pLocale: 'en', + }); + }); + it('clears input after send', () => { const ws = makeWs(); render(); From f0fd22d7913f4b44fb164163485e6b328562efbb Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 19:03:43 +0800 Subject: [PATCH 13/92] Disable combo modes without P2P participants --- web/src/components/SessionControls.tsx | 95 ++++++++++++++++++-- web/src/i18n/locales/en.json | 6 ++ web/src/i18n/locales/es.json | 6 ++ web/src/i18n/locales/ja.json | 6 ++ web/src/i18n/locales/ko.json | 6 ++ web/src/i18n/locales/ru.json | 6 ++ web/src/i18n/locales/zh-CN.json | 6 ++ web/src/i18n/locales/zh-TW.json | 6 ++ web/test/components/SessionControls.test.tsx | 68 ++++++++++++-- 9 files changed, 194 insertions(+), 11 deletions(-) diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 233cff665..cf19da7d6 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -279,7 +279,9 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); + const [sendWarning, setSendWarning] = useState(null); const [attachments, setAttachments] = useState>([]); + const sendWarningTimerRef = useRef | null>(null); // Keep external inputRef in sync so parent can call .focus() useEffect(() => { @@ -303,6 +305,23 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on onPendingPrefillApplied?.(); }, [pendingPrefillText, onPendingPrefillApplied]); + const clearSendWarning = useCallback(() => { + if (sendWarningTimerRef.current) { + clearTimeout(sendWarningTimerRef.current); + sendWarningTimerRef.current = null; + } + setSendWarning(null); + }, []); + + const showSendWarning = useCallback((message: string) => { + if (sendWarningTimerRef.current) clearTimeout(sendWarningTimerRef.current); + setSendWarning(message); + sendWarningTimerRef.current = setTimeout(() => { + sendWarningTimerRef.current = null; + setSendWarning(null); + }, 5000); + }, []); + // Persist input draft across unmount/remount (sub-session minimize/restore) const draftKey = activeSession ? `rcc_draft_${activeSession.name}` : null; useEffect(() => { @@ -318,6 +337,10 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on }; }, [draftKey]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => () => { + if (sendWarningTimerRef.current) clearTimeout(sendWarningTimerRef.current); + }, []); + // Auto-sync model selector with detected model from terminal/ctx // Detection is the real-time truth — always override the selector useEffect(() => { @@ -497,6 +520,10 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on const activeSub = (subSessions ?? []).find((s) => s.sessionName === activeSession?.name); const rootSession = activeSub?.parentSession || activeSession?.name || ''; + const hasConfiguredP2pParticipants = useMemo(() => { + if (!p2pSavedConfig?.sessions) return false; + return Object.values(p2pSavedConfig.sessions).some((entry) => entry?.enabled && entry.mode !== 'skip'); + }, [p2pSavedConfig]); // P2P config is per main-session (sub-sessions follow parent), stored on server for cross-device sync const p2pConfigKey = rootSession ? `p2p_session_config:${rootSession}` : null; @@ -521,6 +548,12 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on }); }, [p2pConfigKey]); + useEffect(() => { + if (!hasConfiguredP2pParticipants && isComboMode(p2pMode)) { + setP2pMode('solo'); + } + }, [hasConfiguredP2pParticipants, p2pMode]); + /** Build a short display label for the input box — prefer sub-session label over raw ID. */ const buildAgentLabel = (session: string, mode: string) => { const modeLabel = mode === P2P_CONFIG_MODE @@ -700,6 +733,28 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on void saveUserPref(P2P_COMBO_CONFIRM_SKIP_PREF_KEY, true).catch(() => {}); }, [rememberComboSendChoice]); + const getSendValidationError = useCallback((payload: PendingSendPayload): string | null => { + const text = payload.text.trim(); + const routedModes: string[] = []; + const directMode = payload.extra.p2pMode; + if (typeof directMode === 'string') routedModes.push(directMode); + const atTargets = payload.extra.p2pAtTargets; + if (Array.isArray(atTargets)) { + for (const target of atTargets) { + if (target && typeof target === 'object' && 'mode' in target && typeof target.mode === 'string') { + routedModes.push(target.mode); + } + } + } + if (!text && routedModes.some((mode) => isComboMode(mode))) { + return t('p2p.combo_empty_message_warning'); + } + if (!hasConfiguredP2pParticipants && routedModes.some((mode) => isComboMode(mode))) { + return t('p2p.combo_requires_participants_hint'); + } + return null; + }, [hasConfiguredP2pParticipants, t]); + useEffect(() => { if (!activeSession || activeSession.runtimeType !== 'transport' || activeSession.state !== 'running') { setQueuedNoticeVisible(false); @@ -708,6 +763,12 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on const requestSend = useCallback((payload: PendingSendPayload | null, options?: { clearComposer?: boolean }) => { if (!payload) return; + const validationError = getSendValidationError(payload); + if (validationError) { + showSendWarning(validationError); + return; + } + clearSendWarning(); const comboMode = typeof payload.extra.p2pMode === 'string' ? payload.extra.p2pMode : null; if (comboMode && isComboMode(comboMode) && !skipComboSendConfirm) { setRememberComboSendChoice(false); @@ -719,7 +780,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on return; } finalizeSend(payload, options); - }, [finalizeSend, skipComboSendConfirm, t]); + }, [clearSendWarning, finalizeSend, getSendValidationError, showSendWarning, skipComboSendConfirm, t]); const handleSend = useCallback(() => { requestSend(buildSendPayload(), { clearComposer: true }); @@ -1183,12 +1244,23 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on {/* Combo presets */} )} + {sendWarning && ( +
+ {sendWarning} +
+ )} + {/* Attachment badges — above input row */} {attachments.length > 0 && (
@@ -1444,6 +1528,7 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on onInput={() => { const currentText = divRef.current?.textContent ?? ''; setHasText(!!currentText.trim()); + if (sendWarning) clearSendWarning(); if (atSelectionLockRef.current && currentText !== atSelectionSnapshotRef.current) { atSelectionLockRef.current = false; atSelectionSnapshotRef.current = currentText; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e87c0e6b2..799654506 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -364,6 +364,10 @@ "mode_brainstorm": "Brainstorm", "mode_discuss": "Discuss", "combo_label": "Combo", + "combo_presets": "Preset Combos", + "combo_custom": "My Combos", + "combo_custom_empty": "No custom combos yet.", + "combo_builder": "Add New Combo", "exclude_same_type": "Exclude same type", "picker": { "back": "Back", @@ -398,6 +402,8 @@ "combo_send_confirm_title": "Confirm combo send", "combo_send_confirm_body": "This message will be sent directly with the combo flow {{mode}}. Continue?", "combo_send_confirm_skip": "Don't ask again, send directly next time", + "combo_empty_message_warning": "Combo send requires a real message. Add some prompt text before sending.", + "combo_requires_participants_hint": "Choose P2P participants in Settings to enable combo modes.", "settings_enabled": "Participate", "settings_mode": "Mode", "settings_skip": "Skip", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index 8767d41b9..2cda864b8 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -364,6 +364,10 @@ "mode_brainstorm": "Lluvia de ideas", "mode_discuss": "Discusión", "combo_label": "Combo", + "combo_presets": "Combos predefinidos", + "combo_custom": "Mis combos", + "combo_custom_empty": "Aun no hay combos personalizados.", + "combo_builder": "Agregar nuevo combo", "exclude_same_type": "Excluir mismo tipo", "picker": { "back": "Atrás", @@ -398,6 +402,8 @@ "combo_send_confirm_title": "Confirmar envío combinado", "combo_send_confirm_body": "Este mensaje se enviará directamente con el flujo combinado {{mode}}. ¿Continuar?", "combo_send_confirm_skip": "No volver a preguntar, enviar directamente la próxima vez", + "combo_empty_message_warning": "El envío combinado requiere un mensaje real. Agrega texto antes de enviarlo.", + "combo_requires_participants_hint": "Elige participantes de P2P en Configuración para habilitar los modos combinados.", "settings_enabled": "Participar", "settings_mode": "Modo", "settings_skip": "Omitir", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 24b956313..14e05635d 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -364,6 +364,10 @@ "mode_brainstorm": "ブレスト", "mode_discuss": "ディスカッション", "combo_label": "コンボ", + "combo_presets": "プリセットコンボ", + "combo_custom": "マイコンボ", + "combo_custom_empty": "カスタムコンボはまだありません。", + "combo_builder": "新しいコンボを追加", "exclude_same_type": "同タイプを除外", "picker": { "back": "戻る", @@ -398,6 +402,8 @@ "combo_send_confirm_title": "コンボ送信の確認", "combo_send_confirm_body": "このメッセージはコンボフロー {{mode}} で直接送信されます。続行しますか?", "combo_send_confirm_skip": "次回から確認せず直接送信する", + "combo_empty_message_warning": "コンボ送信には本文が必要です。実際の依頼内容を入力してから送信してください。", + "combo_requires_participants_hint": "コンボモードを有効にするには、先に P2P 設定で参加者を選んでください。", "settings_enabled": "参加", "settings_mode": "モード", "settings_skip": "スキップ", diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index d98410d0b..f2bc4fbe5 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -364,6 +364,10 @@ "mode_brainstorm": "브레인스토밍", "mode_discuss": "토론", "combo_label": "콤보", + "combo_presets": "기본 콤보", + "combo_custom": "내 콤보", + "combo_custom_empty": "아직 사용자 콤보가 없습니다.", + "combo_builder": "새 콤보 추가", "exclude_same_type": "같은 유형 제외", "picker": { "back": "뒤로", @@ -398,6 +402,8 @@ "combo_send_confirm_title": "콤보 전송 확인", "combo_send_confirm_body": "이 메시지는 콤보 흐름 {{mode}} 으로 바로 전송됩니다. 계속할까요?", "combo_send_confirm_skip": "다음부터 묻지 않고 바로 전송", + "combo_empty_message_warning": "콤보 전송에는 실제 메시지 본문이 필요합니다. 내용을 입력한 뒤 전송하세요.", + "combo_requires_participants_hint": "콤보 모드를 쓰려면 먼저 P2P 설정에서 참여자를 선택하세요.", "settings_enabled": "참여", "settings_mode": "모드", "settings_skip": "건너뛰기", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 72b220cc4..981d2ae86 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -364,6 +364,10 @@ "mode_brainstorm": "Мозговой штурм", "mode_discuss": "Обсуждение", "combo_label": "Комбо", + "combo_presets": "Готовые комбо", + "combo_custom": "Мои комбо", + "combo_custom_empty": "Пользовательских комбо пока нет.", + "combo_builder": "Добавить новое комбо", "exclude_same_type": "Исключить тот же тип", "picker": { "back": "Назад", @@ -398,6 +402,8 @@ "combo_send_confirm_title": "Подтвердить комбинированную отправку", "combo_send_confirm_body": "Это сообщение будет сразу отправлено с комбинированным сценарием {{mode}}. Продолжить?", "combo_send_confirm_skip": "Больше не спрашивать и отправлять сразу", + "combo_empty_message_warning": "Для комбинированной отправки нужен текст сообщения. Добавьте запрос перед отправкой.", + "combo_requires_participants_hint": "Сначала выберите участников в настройках P2P, чтобы включить комбинированные режимы.", "settings_enabled": "Участвовать", "settings_mode": "Режим", "settings_skip": "Пропустить", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index f94153231..6b89840f1 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -364,6 +364,10 @@ "mode_brainstorm": "头脑风暴", "mode_discuss": "讨论", "combo_label": "组合流程", + "combo_presets": "预设组合", + "combo_custom": "我的组合", + "combo_custom_empty": "还没有自定义组合。", + "combo_builder": "添加新组合", "exclude_same_type": "排除同类型", "picker": { "back": "返回", @@ -398,6 +402,8 @@ "combo_send_confirm_title": "确认组合流程发送", "combo_send_confirm_body": "这条消息将直接按组合流程 {{mode}} 发送。是否继续?", "combo_send_confirm_skip": "下次不再提示,直接发送", + "combo_empty_message_warning": "组合流程发送必须带正文。先补充实际问题或指令再发送。", + "combo_requires_participants_hint": "请先到 P2P 设置里选择参与者,再启用组合流程。", "settings_enabled": "参与", "settings_mode": "模式", "settings_skip": "跳过", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index fa119e3b2..3ef2f68db 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -364,6 +364,10 @@ "mode_brainstorm": "腦力激盪", "mode_discuss": "討論", "combo_label": "組合流程", + "combo_presets": "預設組合", + "combo_custom": "我的組合", + "combo_custom_empty": "還沒有自訂組合。", + "combo_builder": "新增組合", "exclude_same_type": "排除同類型", "picker": { "back": "返回", @@ -398,6 +402,8 @@ "combo_send_confirm_title": "確認組合流程傳送", "combo_send_confirm_body": "這則訊息將直接依組合流程 {{mode}} 傳送。是否繼續?", "combo_send_confirm_skip": "下次不再提示,直接傳送", + "combo_empty_message_warning": "組合流程傳送必須帶正文。請先補上實際問題或指令再傳送。", + "combo_requires_participants_hint": "請先到 P2P 設定裡選擇參與者,再啟用組合流程。", "settings_enabled": "參與", "settings_mode": "模式", "settings_skip": "跳過", diff --git a/web/test/components/SessionControls.test.tsx b/web/test/components/SessionControls.test.tsx index 0434cefd3..590116e40 100644 --- a/web/test/components/SessionControls.test.tsx +++ b/web/test/components/SessionControls.test.tsx @@ -52,6 +52,8 @@ vi.mock('../../src/api.js', () => ({ import { SessionControls } from '../../src/components/SessionControls.js'; import type { SessionInfo } from '../../src/types.js'; +const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 0)); + const makeWs = () => ({ sendSessionCommand: vi.fn(), sendInput: vi.fn(), @@ -106,7 +108,18 @@ describe('SessionControls', () => { beforeEach(() => { vi.clearAllMocks(); - getUserPrefMock.mockResolvedValue(null); + getUserPrefMock.mockImplementation(async (key: unknown) => { + if (typeof key === 'string' && key.startsWith('p2p_session_config:')) { + const sessionKey = key.slice('p2p_session_config:'.length); + return JSON.stringify({ + sessions: { + [sessionKey]: { enabled: true, mode: 'audit' }, + }, + rounds: 3, + }); + } + return null; + }); saveUserPrefMock.mockResolvedValue(undefined); }); @@ -176,8 +189,9 @@ describe('SessionControls', () => { }); }); - it('keeps the send button label unchanged after selecting a combo mode', () => { + it('keeps the send button label unchanged after selecting a combo mode', async () => { render(); + await flushAsync(); fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); @@ -186,9 +200,10 @@ describe('SessionControls', () => { expect(screen.getByRole('button', { name: /mode_audit→mode_plan/i })).toBeDefined(); }); - it('asks for confirmation before directly sending a selected combo mode', () => { + it('asks for confirmation before directly sending a selected combo mode', async () => { const ws = makeWs(); render(); + await flushAsync(); fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); @@ -207,9 +222,48 @@ describe('SessionControls', () => { expect(screen.getByText(/P2P:mode_audit→mode_plan/i)).toBeDefined(); }); - it('remembers skipping combo confirmation across later sends', () => { + it('blocks combo sends that only contain routing markup and shows a warning', () => { + const ws = makeWs(); + render(); + + const input = screen.getByRole('textbox') as HTMLDivElement; + input.textContent = '@@all(audit>plan)'; + fireEvent.input(input); + fireEvent.click(screen.getByRole('button', { name: /^send$/i })); + + expect(ws.sendSessionCommand).not.toHaveBeenCalled(); + expect(screen.queryByText('combo_send_confirm_title')).toBeNull(); + expect(screen.getByText('combo_empty_message_warning')).toBeDefined(); + }); + + it('disables combo modes when no participants are configured', async () => { + getUserPrefMock.mockImplementation(async (key: unknown) => { + if (typeof key === 'string' && key.startsWith('p2p_session_config:')) { + return JSON.stringify({ + sessions: { + 'my-session': { enabled: false, mode: 'audit' }, + }, + rounds: 3, + }); + } + return null; + }); + + render(); + await flushAsync(); + + fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); + + expect(screen.getByText('combo_requires_participants_hint')).toBeDefined(); + const comboBtn = screen.getByRole('button', { name: /mode_audit→mode_plan/i }) as HTMLButtonElement; + expect(comboBtn.disabled).toBe(true); + expect(comboBtn.title).toBe('combo_requires_participants_hint'); + }); + + it('remembers skipping combo confirmation across later sends', async () => { const ws = makeWs(); render(); + await flushAsync(); fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); @@ -246,9 +300,10 @@ describe('SessionControls', () => { }); }); - it('routes quick panel sends through the same combo confirmation flow', () => { + it('routes quick panel sends through the same combo confirmation flow', async () => { const ws = makeWs(); render(); + await flushAsync(); fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); @@ -269,9 +324,10 @@ describe('SessionControls', () => { }); }); - it('routes voice sends through the same combo confirmation flow', () => { + it('routes voice sends through the same combo confirmation flow', async () => { const ws = makeWs(); render(); + await flushAsync(); fireEvent.click(screen.getByRole('button', { name: /mode_solo/i })); fireEvent.click(screen.getByText(/mode_audit→mode_plan/i)); From 72b9c86e2936be3d2d42ed52c4a9e5d6312daf85 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 19:13:46 +0800 Subject: [PATCH 14/92] Harden Windows upgrade scripts for Unicode paths --- src/daemon/command-handler.ts | 3 +- src/util/windows-upgrade-script.ts | 102 +++++++++++++---------- test/util/windows-upgrade-script.test.ts | 37 ++++++-- 3 files changed, 90 insertions(+), 52 deletions(-) diff --git a/src/daemon/command-handler.ts b/src/daemon/command-handler.ts index e416f291d..adf7d22bc 100644 --- a/src/daemon/command-handler.ts +++ b/src/daemon/command-handler.ts @@ -2204,7 +2204,8 @@ launchctl load -w "${plist}"`; const cleanupPath = join(scriptDir, 'cleanup.cmd'); const cleanupVbsPath = join(scriptDir, 'cleanup.vbs'); const targetVer = targetVersion ?? 'latest'; - // .cmd files: UTF-8 + BOM so cmd.exe handles non-ASCII paths. + // .cmd files: UTF-8 + BOM, and the script itself switches to UTF-8 with + // `chcp 65001` before touching any non-ASCII paths. // .vbs files: UTF-16 LE + BOM so wscript handles non-ASCII paths. writeFileSync(cleanupPath, encodeCmdAsUtf8Bom(buildWindowsCleanupScript(scriptDir))); writeFileSync(cleanupVbsPath, encodeVbsAsUtf16(buildWindowsCleanupVbs(cleanupPath))); diff --git a/src/util/windows-upgrade-script.ts b/src/util/windows-upgrade-script.ts index 9d5247ff6..e5104ed0a 100644 --- a/src/util/windows-upgrade-script.ts +++ b/src/util/windows-upgrade-script.ts @@ -14,9 +14,13 @@ export interface WindowsUpgradeScriptInput { } export function buildWindowsCleanupScript(scriptDir: string): string { + void scriptDir; return `@echo off\r +chcp 65001 >nul 2>&1\r +setlocal\r timeout /t 120 /nobreak >nul\r -rmdir /s /q "${scriptDir}"\r +for %%I in ("%~dp0.") do set "SCRIPT_DIR=%%~fI"\r +rmdir /s /q "%SCRIPT_DIR%"\r `; } @@ -36,14 +40,24 @@ export function buildWindowsUpgradeVbs(batchPath: string): string { export function buildWindowsUpgradeBatch(input: WindowsUpgradeScriptInput): string { const { logFile, cleanupVbsPath, npmCmd, pkgSpec, targetVer, vbsLauncherPath, upgradeLockFile } = input; + void logFile; + void cleanupVbsPath; + void vbsLauncherPath; + void upgradeLockFile; return `@echo off\r +chcp 65001 >nul 2>&1\r setlocal EnableDelayedExpansion\r -echo === imcodes upgrade started at %date% %time% === >> "${logFile}"\r +for %%I in ("%~dp0.") do set "SCRIPT_DIR=%%~fI"\r +set "LOG_FILE=%SCRIPT_DIR%\\upgrade.log"\r +set "CLEANUP_VBS=%SCRIPT_DIR%\\cleanup.vbs"\r +set "VBS_LAUNCHER=%USERPROFILE%\\.imcodes\\daemon-launcher.vbs"\r +set "UPGRADE_LOCK=%USERPROFILE%\\.imcodes\\upgrade.lock"\r +echo === imcodes upgrade started at %date% %time% === >> "%LOG_FILE%"\r timeout /t 2 /nobreak > nul\r \r rem ── Create upgrade lock — watchdog will pause while this file exists ──\r -echo upgrade > "${upgradeLockFile}"\r -echo Upgrade lock created >> "${logFile}"\r +echo upgrade > "%UPGRADE_LOCK%"\r +echo Upgrade lock created >> "%LOG_FILE%"\r \r rem ── Kill daemon + old watchdog so npm can overwrite files cleanly ─────\r rem Old watchdog versions don't know about the lock file, so we must kill\r @@ -52,7 +66,7 @@ rem that respects the lock.\r set "PIDFILE=%USERPROFILE%\\.imcodes\\daemon.pid"\r if exist "%PIDFILE%" (\r set /p OLD_PID=<"%PIDFILE%"\r - echo Stopping daemon PID !OLD_PID! and old watchdog... >> "${logFile}"\r + echo Stopping daemon PID !OLD_PID! and old watchdog... >> "%LOG_FILE%"\r rem Find watchdog (parent of daemon) and tree-kill it\r for /f "tokens=2 delims==" %%a in ('wmic process where "ProcessId=!OLD_PID!" get ParentProcessId /format:list 2^>nul ^| find "="') do (\r set "WD_PID=%%a"\r @@ -64,72 +78,72 @@ if exist "%PIDFILE%" (\r timeout /t 2 /nobreak >nul\r )\r \r -echo Installing ${pkgSpec}... >> "${logFile}"\r -call "${npmCmd}" install -g ${pkgSpec} >> "${logFile}" 2>&1\r +echo Installing ${pkgSpec}... >> "%LOG_FILE%"\r +call "${npmCmd}" install -g ${pkgSpec} >> "%LOG_FILE%" 2>&1\r if %errorlevel% neq 0 (\r - echo Install FAILED — removing lock, watchdog will restart current version. >> "${logFile}"\r - echo === upgrade aborted at %date% %time% === >> "${logFile}"\r - del "${upgradeLockFile}" >nul 2>&1\r - if exist "${vbsLauncherPath}" wscript "${vbsLauncherPath}"\r - wscript "${cleanupVbsPath}" >nul 2>&1\r + echo Install FAILED — removing lock, watchdog will restart current version. >> "%LOG_FILE%"\r + echo === upgrade aborted at %date% %time% === >> "%LOG_FILE%"\r + del "%UPGRADE_LOCK%" >nul 2>&1\r + if exist "%VBS_LAUNCHER%" wscript "%VBS_LAUNCHER%"\r + wscript "%CLEANUP_VBS%" >nul 2>&1\r goto :done\r )\r \r set "NPM_PREFIX="\r for /f "usebackq delims=" %%p in (\`call "${npmCmd}" prefix -g 2^>nul\`) do if not defined NPM_PREFIX set "NPM_PREFIX=%%p"\r if not defined NPM_PREFIX (\r - echo Could not resolve npm global prefix after install. >> "${logFile}"\r - echo === upgrade aborted at %date% %time% === >> "${logFile}"\r - del "${upgradeLockFile}" >nul 2>&1\r - if exist "${vbsLauncherPath}" wscript "${vbsLauncherPath}"\r - wscript "${cleanupVbsPath}" >nul 2>&1\r + echo Could not resolve npm global prefix after install. >> "%LOG_FILE%"\r + echo === upgrade aborted at %date% %time% === >> "%LOG_FILE%"\r + del "%UPGRADE_LOCK%" >nul 2>&1\r + if exist "%VBS_LAUNCHER%" wscript "%VBS_LAUNCHER%"\r + wscript "%CLEANUP_VBS%" >nul 2>&1\r goto :done\r )\r \r set "CLI_SHIM=%NPM_PREFIX%\\imcodes.cmd"\r if not exist "%CLI_SHIM%" (\r - echo imcodes shim missing after install: %CLI_SHIM% >> "${logFile}"\r - echo === upgrade aborted at %date% %time% === >> "${logFile}"\r - del "${upgradeLockFile}" >nul 2>&1\r - if exist "${vbsLauncherPath}" wscript "${vbsLauncherPath}"\r - wscript "${cleanupVbsPath}" >nul 2>&1\r + echo imcodes shim missing after install: %CLI_SHIM% >> "%LOG_FILE%"\r + echo === upgrade aborted at %date% %time% === >> "%LOG_FILE%"\r + del "%UPGRADE_LOCK%" >nul 2>&1\r + if exist "%VBS_LAUNCHER%" wscript "%VBS_LAUNCHER%"\r + wscript "%CLEANUP_VBS%" >nul 2>&1\r goto :done\r )\r \r set "INSTALLED_VER="\r for /f "usebackq delims=" %%v in (\`call "%CLI_SHIM%" --version 2^>nul\`) do if not defined INSTALLED_VER set "INSTALLED_VER=%%v"\r -echo Install succeeded. Installed version: %INSTALLED_VER%, target: ${targetVer}, shim: %CLI_SHIM% >> "${logFile}"\r +echo Install succeeded. Installed version: %INSTALLED_VER%, target: ${targetVer}, shim: %CLI_SHIM% >> "%LOG_FILE%"\r if not "${targetVer}"=="latest" if /I not "%INSTALLED_VER%"=="${targetVer}" (\r - echo Version mismatch after install — removing lock, watchdog will restart. >> "${logFile}"\r - echo === upgrade aborted at %date% %time% === >> "${logFile}"\r - del "${upgradeLockFile}" >nul 2>&1\r - if exist "${vbsLauncherPath}" wscript "${vbsLauncherPath}"\r - wscript "${cleanupVbsPath}" >nul 2>&1\r + echo Version mismatch after install — removing lock, watchdog will restart. >> "%LOG_FILE%"\r + echo === upgrade aborted at %date% %time% === >> "%LOG_FILE%"\r + del "%UPGRADE_LOCK%" >nul 2>&1\r + if exist "%VBS_LAUNCHER%" wscript "%VBS_LAUNCHER%"\r + wscript "%CLEANUP_VBS%" >nul 2>&1\r goto :done\r )\r where imcodes >nul 2>&1\r if %errorlevel% neq 0 (\r - echo WARNING: imcodes not found on PATH >> "${logFile}"\r - echo To fix: setx PATH "%NPM_PREFIX%;%%PATH%%" >> "${logFile}"\r + echo WARNING: imcodes not found on PATH >> "%LOG_FILE%"\r + echo To fix: setx PATH "%NPM_PREFIX%;%%PATH%%" >> "%LOG_FILE%"\r )\r -echo Regenerating daemon launch chain... >> "${logFile}"\r -call "%CLI_SHIM%" repair-watchdog >> "${logFile}" 2>&1\r +echo Regenerating daemon launch chain... >> "%LOG_FILE%"\r +call "%CLI_SHIM%" repair-watchdog >> "%LOG_FILE%" 2>&1\r if %errorlevel% neq 0 (\r - echo WARNING: Launch chain regeneration failed >> "${logFile}"\r + echo WARNING: Launch chain regeneration failed >> "%LOG_FILE%"\r )\r \r rem ── Start new watchdog (lock-aware), then remove lock ─────────────────\r rem The new watchdog (generated by repair-watchdog) checks the lock file.\r rem It will loop/wait while the lock exists, then start the daemon once\r rem we delete it below.\r -echo Starting new watchdog via VBS... >> "${logFile}"\r -if exist "${vbsLauncherPath}" (\r - wscript "${vbsLauncherPath}"\r +echo Starting new watchdog via VBS... >> "%LOG_FILE%"\r +if exist "%VBS_LAUNCHER%" (\r + wscript "%VBS_LAUNCHER%"\r ) else (\r - echo WARNING: VBS launcher not found at ${vbsLauncherPath} >> "${logFile}"\r + echo WARNING: VBS launcher not found at %VBS_LAUNCHER% >> "%LOG_FILE%"\r )\r -echo Removing upgrade lock... >> "${logFile}"\r -del "${upgradeLockFile}" >nul 2>&1\r +echo Removing upgrade lock... >> "%LOG_FILE%"\r +del "%UPGRADE_LOCK%" >nul 2>&1\r \r rem Wait for new watchdog to start the daemon, then health-check\r timeout /t 10 /nobreak >nul\r @@ -137,15 +151,15 @@ if exist "%PIDFILE%" (\r set /p DAEMON_PID=<"%PIDFILE%"\r tasklist /fi "PID eq !DAEMON_PID!" /nh 2^>nul | find "!DAEMON_PID!" >nul\r if !errorlevel! equ 0 (\r - echo Health check PASSED: daemon PID !DAEMON_PID! alive >> "${logFile}"\r + echo Health check PASSED: daemon PID !DAEMON_PID! alive >> "%LOG_FILE%"\r ) else (\r - echo Health check FAILED: PID !DAEMON_PID! not running >> "${logFile}"\r + echo Health check FAILED: PID !DAEMON_PID! not running >> "%LOG_FILE%"\r )\r ) else (\r - echo Health check FAILED: daemon.pid not found >> "${logFile}"\r + echo Health check FAILED: daemon.pid not found >> "%LOG_FILE%"\r )\r -wscript "${cleanupVbsPath}" >nul 2>&1\r +wscript "%CLEANUP_VBS%" >nul 2>&1\r :done\r -echo === upgrade done at %date% %time% === >> "${logFile}"\r +echo === upgrade done at %date% %time% === >> "%LOG_FILE%"\r `; } diff --git a/test/util/windows-upgrade-script.test.ts b/test/util/windows-upgrade-script.test.ts index d74d0c421..3fb3ed60d 100644 --- a/test/util/windows-upgrade-script.test.ts +++ b/test/util/windows-upgrade-script.test.ts @@ -17,8 +17,10 @@ describe('buildWindowsCleanupScript', () => { it('generates a standalone cleanup cmd script', () => { const script = buildWindowsCleanupScript('C:\\Temp\\imcodes-upgrade-123'); expect(script).toContain('@echo off'); + expect(script).toContain('chcp 65001 >nul 2>&1'); expect(script).toContain('timeout /t 120 /nobreak >nul'); - expect(script).toContain('rmdir /s /q "C:\\Temp\\imcodes-upgrade-123"'); + expect(script).toContain('for %%I in ("%~dp0.") do set "SCRIPT_DIR=%%~fI"'); + expect(script).toContain('rmdir /s /q "%SCRIPT_DIR%"'); }); }); @@ -57,13 +59,21 @@ describe('buildWindowsUpgradeBatch', () => { // ── Lock file lifecycle ── it('creates upgrade lock BEFORE npm install', () => { - const lockIdx = batch.indexOf(`echo upgrade > "${INPUT.upgradeLockFile}"`); + const lockIdx = batch.indexOf('echo upgrade > "%UPGRADE_LOCK%"'); const installIdx = batch.indexOf(`call "${INPUT.npmCmd}" install`); expect(lockIdx).toBeGreaterThan(-1); expect(installIdx).toBeGreaterThan(-1); expect(lockIdx).toBeLessThan(installIdx); }); + it('switches cmd.exe to UTF-8 before touching path variables', () => { + expect(batch).toContain('chcp 65001 >nul 2>&1'); + expect(batch).toContain('set "LOG_FILE=%SCRIPT_DIR%\\upgrade.log"'); + expect(batch).toContain('set "CLEANUP_VBS=%SCRIPT_DIR%\\cleanup.vbs"'); + expect(batch).toContain('set "VBS_LAUNCHER=%USERPROFILE%\\.imcodes\\daemon-launcher.vbs"'); + expect(batch).toContain('set "UPGRADE_LOCK=%USERPROFILE%\\.imcodes\\upgrade.lock"'); + }); + it('every abort path deletes lock AND restarts VBS', () => { // Split on `goto :done` — each abort block must have both del lock + wscript const blocks = batch.split('goto :done'); @@ -72,7 +82,7 @@ describe('buildWindowsUpgradeBatch', () => { // At least 4 abort paths: install fail, no prefix, no shim, version mismatch expect(abortBlocks.length).toBeGreaterThanOrEqual(4); for (const block of abortBlocks) { - expect(block).toContain(`del "${INPUT.upgradeLockFile}"`); + expect(block).toContain('del "%UPGRADE_LOCK%"'); expect(block).toContain('wscript'); } }); @@ -156,9 +166,9 @@ describe('buildWindowsUpgradeBatch', () => { // No `start /min` either — flashes briefly in taskbar expect(batch).not.toContain('/min cmd /c'); // Cleanup must be invoked via wscript on the cleanup VBS - expect(batch).toContain(`wscript "${INPUT.cleanupVbsPath}"`); + expect(batch).toContain('wscript "%CLEANUP_VBS%"'); // Should be invoked at least 5 times (4 abort paths + 1 success) - const wscriptCleanupCalls = batch.match(new RegExp(`wscript "${INPUT.cleanupVbsPath.replace(/\\/g, '\\\\')}"`, 'g')) ?? []; + const wscriptCleanupCalls = batch.match(/wscript "%CLEANUP_VBS%"/g) ?? []; expect(wscriptCleanupCalls.length).toBeGreaterThanOrEqual(5); }); @@ -173,12 +183,25 @@ describe('buildWindowsUpgradeBatch', () => { const abortBlocks = batch.split('goto :done').slice(0, -1); for (const block of abortBlocks) { // Every abort must restart the daemon via VBS - expect(block).toContain(`wscript "${INPUT.vbsLauncherPath}"`); + expect(block).toContain('wscript "%VBS_LAUNCHER%"'); } // Success path must start new watchdog const successPath = batch.slice(batch.indexOf('Regenerating daemon launch chain')); - expect(successPath).toContain(`wscript "${INPUT.vbsLauncherPath}"`); + expect(successPath).toContain('wscript "%VBS_LAUNCHER%"'); + }); + + it('avoids embedding non-ASCII user paths directly in the batch body', () => { + const nonAscii = buildWindowsUpgradeBatch({ + ...INPUT, + logFile: 'C:\\Users\\云科1\\AppData\\Local\\Temp\\imcodes-upgrade-123\\upgrade.log', + cleanupVbsPath: 'C:\\Users\\云科1\\AppData\\Local\\Temp\\imcodes-upgrade-123\\cleanup.vbs', + vbsLauncherPath: 'C:\\Users\\云科1\\.imcodes\\daemon-launcher.vbs', + upgradeLockFile: 'C:\\Users\\云科1\\.imcodes\\upgrade.lock', + }); + expect(nonAscii).not.toContain('云科1'); + expect(nonAscii).toContain('%USERPROFILE%\\.imcodes\\daemon-launcher.vbs'); + expect(nonAscii).toContain('%SCRIPT_DIR%\\cleanup.vbs'); }); }); From 6b4c75bfb9b42eb5146f8c08238e609f3ec65e18 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Tue, 7 Apr 2026 19:15:12 +0800 Subject: [PATCH 15/92] Refine P2P settings layout and combo manager --- web/src/components/AtPicker.tsx | 165 ++------- web/src/components/P2pComboManager.tsx | 224 ++++++++++++ web/src/components/P2pConfigPanel.tsx | 357 ++++++++++++-------- web/src/components/p2p-combos.ts | 77 +++++ web/test/components/AtPicker.test.tsx | 69 +++- web/test/components/P2pConfigPanel.test.tsx | 34 ++ 6 files changed, 638 insertions(+), 288 deletions(-) create mode 100644 web/src/components/P2pComboManager.tsx create mode 100644 web/src/components/p2p-combos.ts diff --git a/web/src/components/AtPicker.tsx b/web/src/components/AtPicker.tsx index 1916bfed1..4ea95a8b0 100644 --- a/web/src/components/AtPicker.tsx +++ b/web/src/components/AtPicker.tsx @@ -6,7 +6,8 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'preact/hooks' import { useTranslation } from 'react-i18next'; import type { ServerMessage } from '../ws-client.js'; import { COMBO_PRESETS, COMBO_SEPARATOR, type P2pSavedConfig } from '@shared/p2p-modes.js'; -import { getUserPref, saveUserPref } from '../api.js'; +import { P2pComboManager } from './P2pComboManager.js'; +import { useP2pCustomCombos } from './p2p-combos.js'; interface SessionEntry { name: string; @@ -154,15 +155,6 @@ const MODE_COLORS: Record = { discuss: '#22c55e', }; -function comboModeColor(key: string): string { - const last = key.split(COMBO_SEPARATOR).pop()?.trim(); - return last ? (MODE_COLORS[last] ?? '#94a3b8') : '#94a3b8'; -} - -function comboModeLabel(key: string, t: (k: string) => string): string { - return key.split(COMBO_SEPARATOR).map((m) => t(`p2p.mode_${m.trim()}`)).join('→'); -} - function buildEffectiveConfig(config: P2pSavedConfig, modeOverride: string): P2pSavedConfig { if (modeOverride === 'config') return config; const overriddenSessions: P2pSavedConfig['sessions'] = {}; @@ -200,31 +192,8 @@ export function AtPicker({ const [configModeOverride, setConfigModeOverride] = useState('config'); const [configPickerFocus, setConfigPickerFocus] = useState<'mode' | 'rounds' | 'combo'>('rounds'); const [comboHighlight, setComboHighlight] = useState(0); - const [customCombos, setCustomCombos] = useState([]); - const [buildingCombo, setBuildingCombo] = useState([]); - const CUSTOM_COMBOS_PREF_KEY = 'p2p_custom_combos'; - const BUILDER_MODES = ['audit', 'review', 'plan', 'brainstorm', 'discuss'] as const; const CONFIG_ROUNDS_OPTIONS = [1, 2, 3, 5] as const; - - // Load custom combos from server on mount - useEffect(() => { - void getUserPref(CUSTOM_COMBOS_PREF_KEY).then((raw) => { - if (raw && typeof raw === 'string') { - try { setCustomCombos(JSON.parse(raw)); } catch { /* ignore */ } - } - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const saveCustomCombos = useCallback((combos: string[]) => { - setCustomCombos(combos); - void saveUserPref(CUSTOM_COMBOS_PREF_KEY, JSON.stringify(combos)).catch(() => {}); - }, []); - - const allCombos = useMemo(() => { - const presetKeys = new Set(COMBO_PRESETS.map((c) => c.key)); - const custom = customCombos.filter((k) => !presetKeys.has(k)); - return { presets: COMBO_PRESETS, custom }; - }, [customCombos]); + const { customCombos, saveCustomCombos, allCombos } = useP2pCustomCombos(); const debounceRef = useRef | null>(null); const requestIdRef = useRef(null); const containerRef = useRef(null); @@ -595,116 +564,26 @@ export function AtPicker({ marginTop: 6, color: configPickerFocus === 'combo' ? '#93c5fd' : groupLabelStyle.color, }}>{t('p2p.combo_label')}
-
- {/* Built-in presets */} - {COMBO_PRESETS.map((c, idx) => { - const color = comboModeColor(c.key); - const isHl = configPickerFocus === 'combo' && comboHighlight === idx; - return ( - - ); - })} - {/* User custom combos */} - {allCombos.custom.map((key) => { - const color = comboModeColor(key); + combo.key), ...allCombos.custom][comboHighlight] ?? null + : null} + onHoverCombo={(key) => { + const idx = [...COMBO_PRESETS.map((combo) => combo.key), ...allCombos.custom].indexOf(key); + if (idx >= 0) setComboHighlight(idx); + setConfigPickerFocus('combo'); + }} + onSelectCombo={(key) => { const pipeline = key.split(COMBO_SEPARATOR); - return ( - - - - - ); - })} -
- {/* Custom combo builder — hidden when at 5 custom limit */} - {(customCombos.length < 5 || buildingCombo.length > 0) && ( - <> -
- {buildingCombo.length > 0 && ( - - {buildingCombo.map((m) => t(`p2p.mode_${m}`)).join('→')} - - )} - {buildingCombo.length > 0 && ( - - )} - {buildingCombo.length >= 2 && ( - - )} -
-
- {BUILDER_MODES.map((m) => ( - - ))} -
- - )} + const cfg = buildEffectiveConfig(p2pConfig, pipeline[0]); + onSelectAllConfig?.(cfg, pipeline.length, key); + setConfigRoundsPicker(false); + setConfigPickerFocus('rounds'); + }} + />
); } diff --git a/web/src/components/P2pComboManager.tsx b/web/src/components/P2pComboManager.tsx new file mode 100644 index 000000000..010e8045a --- /dev/null +++ b/web/src/components/P2pComboManager.tsx @@ -0,0 +1,224 @@ +import { useMemo, useState } from 'preact/hooks'; +import { useTranslation } from 'react-i18next'; +import { COMBO_PRESETS, COMBO_SEPARATOR } from '@shared/p2p-modes.js'; +import { BUILDER_MODES, MAX_CUSTOM_COMBOS, comboModeColor, comboModeLabel } from './p2p-combos.js'; + +interface Props { + customCombos: string[]; + onCustomCombosChange: (combos: string[]) => void; + onSelectCombo?: (key: string) => void; + highlightedComboKey?: string | null; + onHoverCombo?: (key: string) => void; + compact?: boolean; +} + +const chipStyle: Record = { + padding: '3px 10px', + borderRadius: 6, + border: '1px solid #475569', + background: '#1e293b', + color: '#e2e8f0', + fontSize: 12, + cursor: 'pointer', +}; + +const compactChipStyle: Record = { + ...chipStyle, + fontSize: 10, + padding: '2px 6px', +}; + +const builderRowStyle: Record = { + display: 'flex', + alignItems: 'center', + gap: 4, + flexWrap: 'wrap', +}; + +const sectionStyle: Record = { + display: 'flex', + flexDirection: 'column', + gap: 8, +}; + +const sectionLabelStyle: Record = { + fontSize: 11, + fontWeight: 700, + color: '#94a3b8', + textTransform: 'uppercase', + letterSpacing: '0.05em', +}; + +const emptyStateStyle: Record = { + color: '#64748b', + fontSize: 12, +}; + +function comboChipStyle(color: string, compact: boolean, highlighted: boolean): Record { + const base = compact ? compactChipStyle : chipStyle; + if (!highlighted) return base; + return { + ...base, + borderColor: color, + color, + boxShadow: `0 0 0 1px ${color}55, 0 0 18px ${color}22`, + }; +} + +export function P2pComboManager({ + customCombos, + onCustomCombosChange, + onSelectCombo, + highlightedComboKey, + onHoverCombo, + compact = false, +}: Props) { + const { t } = useTranslation(); + const [buildingCombo, setBuildingCombo] = useState([]); + + const canAddMore = customCombos.length < MAX_CUSTOM_COMBOS; + const buildingKey = buildingCombo.join(COMBO_SEPARATOR); + const buildingColor = comboModeColor(buildingKey); + const canSaveBuilding = buildingCombo.length >= 2 + && canAddMore + && !customCombos.includes(buildingKey) + && !COMBO_PRESETS.some((preset) => preset.key === buildingKey); + + const allCustomComboKeys = useMemo(() => new Set(customCombos), [customCombos]); + + const saveBuildingCombo = () => { + if (!canSaveBuilding) return; + onCustomCombosChange([...customCombos, buildingKey]); + setBuildingCombo([]); + }; + + return ( +
+
+
{t('p2p.combo_presets')}
+
+ {COMBO_PRESETS.map((combo) => { + const color = comboModeColor(combo.key); + return ( + + ); + })} +
+
+ +
+
{t('p2p.combo_custom')}
+ {customCombos.length === 0 ? ( +
{t('p2p.combo_custom_empty')}
+ ) : ( +
+ {customCombos.map((key) => { + const color = comboModeColor(key); + const label = comboModeLabel(key, t); + const base = compact ? compactChipStyle : chipStyle; + return ( + + + + + ); + })} +
+ )} +
+ + {(canAddMore || buildingCombo.length > 0) && ( +
+
{t('p2p.combo_builder')}
+
+ {buildingCombo.length > 0 && ( + + {buildingCombo.map((mode) => t(`p2p.mode_${mode}`)).join('→')} + + )} + {buildingCombo.length > 0 && ( + + )} + {buildingCombo.length >= 2 && ( + + )} +
+
+ {BUILDER_MODES.map((mode) => { + const previewKey = buildingCombo.length > 0 ? `${buildingKey}${COMBO_SEPARATOR}${mode}` : mode; + const alreadyExists = allCustomComboKeys.has(previewKey) || COMBO_PRESETS.some((preset) => preset.key === previewKey); + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/web/src/components/P2pConfigPanel.tsx b/web/src/components/P2pConfigPanel.tsx index 114259a1d..1df8addee 100644 --- a/web/src/components/P2pConfigPanel.tsx +++ b/web/src/components/P2pConfigPanel.tsx @@ -5,6 +5,8 @@ import { useState, useEffect } from 'preact/hooks'; import { useTranslation } from 'react-i18next'; import { getUserPref, saveUserPref } from '../api.js'; +import { P2pComboManager } from './P2pComboManager.js'; +import { useP2pCustomCombos } from './p2p-combos.js'; import type { P2pSavedConfig, P2pSessionConfig } from '@shared/p2p-modes.js'; interface SessionRow { @@ -34,30 +36,6 @@ const EXCLUDED_TYPES = new Set(['shell', 'script']); const SESSION_MODES = ['audit', 'review', 'plan', 'brainstorm', 'discuss', 'skip'] as const; const ROUND_OPTIONS = [1, 2, 3, 5] as const; -const overlayStyle: Record = { - position: 'fixed', - inset: 0, - background: 'rgba(0,0,0,0.6)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: 9999, - padding: 16, -}; - -const panelStyle: Record = { - background: '#1e293b', - border: '1px solid #334155', - borderRadius: 10, - width: '100%', - maxWidth: 520, - maxHeight: '90vh', - display: 'flex', - flexDirection: 'column', - boxShadow: '0 8px 32px rgba(0,0,0,0.5)', - overflow: 'hidden', -}; - const headerStyle: Record = { display: 'flex', alignItems: 'center', @@ -89,15 +67,34 @@ const bodyStyle: Record = { padding: '12px 20px', }; +const tabsStyle: Record = { + display: 'flex', + gap: 8, + padding: '0 20px 12px', + borderBottom: '1px solid #334155', +}; + +const tabStyle = (active: boolean): Record => ({ + padding: '6px 12px', + borderRadius: 999, + border: `1px solid ${active ? '#3b82f6' : '#475569'}`, + background: active ? '#1d4ed840' : '#0f172a', + color: active ? '#bfdbfe' : '#94a3b8', + fontSize: 12, + fontWeight: active ? 600 : 500, + cursor: 'pointer', +}); + const rowStyle: Record = { - display: 'inline-flex', + display: 'grid', + gridTemplateColumns: '18px minmax(0, 1fr) minmax(110px, auto)', alignItems: 'center', - gap: 4, - padding: '4px 8px', + gap: 8, + padding: '10px 12px', borderRadius: 6, background: '#0f172a', border: '1px solid #334155', - whiteSpace: 'nowrap', + minWidth: 0, }; const checkboxStyle: Record = { @@ -139,6 +136,19 @@ const sectionLabelStyle: Record = { marginBottom: 6, }; +const agentGridStyle = (mobile: boolean): Record => ({ + display: 'grid', + gridTemplateColumns: mobile ? '1fr' : 'repeat(2, minmax(0, 1fr))', + gap: 10, +}); + +const sectionCardStyle: Record = { + background: '#111827', + border: '1px solid #334155', + borderRadius: 10, + padding: 14, +}; + const roundsBtnStyle = (active: boolean): Record => ({ padding: '4px 12px', borderRadius: 6, @@ -182,6 +192,9 @@ const btnPrimaryStyle: Record = { export function P2pConfigPanel({ sessions, subSessions, activeSession, onClose, onSave }: Props) { const { t } = useTranslation(); const [crossSession, setCrossSession] = useState(false); + const [activeTab, setActiveTab] = useState<'participants' | 'combos'>('participants'); + const { customCombos, saveCustomCombos } = useP2pCustomCombos(); + const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768); // Build combined eligible session list (exclude shell/script). // If activeSession is a sub-session, resolve its parent for scope filtering. @@ -256,6 +269,14 @@ export function P2pConfigPanel({ sessions, subSessions, activeSession, onClose, }); }, [configKey]); + useEffect(() => { + if (typeof window === 'undefined') return; + const handleResize = () => setIsMobile(window.innerWidth < 768); + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + const toggleEnabled = (key: string) => { setSessionCfg((prev) => { const cur = prev[key] ?? { enabled: false, mode: 'audit' }; @@ -288,6 +309,29 @@ export function P2pConfigPanel({ sessions, subSessions, activeSession, onClose, }; const getEntry = (key: string) => sessionCfg[key] ?? { enabled: false, mode: 'audit' }; + const overlayStyle: Record = { + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.6)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + padding: isMobile ? 0 : 16, + }; + const panelStyle: Record = { + background: '#1e293b', + border: '1px solid #334155', + borderRadius: isMobile ? 0 : 10, + width: isMobile ? '100vw' : 'min(780px, calc(100vw - 32px))', + maxWidth: isMobile ? '100vw' : 780, + height: isMobile ? '100vh' : 'auto', + maxHeight: isMobile ? '100vh' : '90vh', + display: 'flex', + flexDirection: 'column', + boxShadow: isMobile ? 'none' : '0 8px 32px rgba(0,0,0,0.5)', + overflow: 'hidden', + }; return (
{ if (e.target === e.currentTarget) onClose(); }}> @@ -297,130 +341,157 @@ export function P2pConfigPanel({ sessions, subSessions, activeSession, onClose,

{t('p2p.settings_title')}

+
+ + +
{/* Body */}
{loading ? (
) : ( - <> - {/* Cross-session toggle */} - - - {/* Session rows — horizontal wrap */} -
{t('p2p.picker.agents')}
- {eligible.length === 0 && ( -
- {t('p2p.picker.no_agents_available')} + activeTab === 'participants' ? ( + <> + + +
+
{t('p2p.picker.agents')}
+ {eligible.length === 0 && ( +
+ {t('p2p.picker.no_agents_available')} +
+ )} +
+ {eligible.map((e) => { + const entry = getEntry(e.key); + return ( +
+ toggleEnabled(e.key)} + /> +
+ {e.shortName} + {e.agentType} +
+ +
+ ); + })} +
- )} -
- {eligible.map((e) => { - const entry = getEntry(e.key); - return ( -
- toggleEnabled(e.key)} - /> - {e.shortName} - {e.agentType} - +
+
+ {t('p2p.settings_rounds_hint')} +
+
+ +
+
{t('p2p.settings_hop_timeout', 'Hop Timeout')}
+
+ { + const v = parseInt((e.target as HTMLInputElement).value, 10); + if (v >= 1 && v <= 10) setHopTimeoutMinutes(v); + }} + style={{ + width: 72, + background: '#0f172a', + border: '1px solid #334155', + borderRadius: 5, + color: '#e2e8f0', + fontSize: 13, + padding: '6px 8px', + textAlign: 'center', + outline: 'none', + }} + /> + {t('p2p.settings_hop_timeout_unit', 'minutes per hop')} +
+
+ {t('p2p.settings_hop_timeout_hint', 'How long to wait for each agent to respond. Increase for complex tasks.')} +
- ); - })} -
- - {/* Rounds */} -
{t('p2p.settings_rounds')}
-
- {ROUND_OPTIONS.map((r) => ( - - ))} -
-
- {t('p2p.settings_rounds_hint')} -
- - {/* Hop timeout */} -
{t('p2p.settings_hop_timeout', 'Hop Timeout')}
-
- { - const v = parseInt((e.target as HTMLInputElement).value, 10); - if (v >= 1 && v <= 10) setHopTimeoutMinutes(v); - }} - style={{ - width: 64, - background: '#0f172a', - border: '1px solid #334155', - borderRadius: 5, - color: '#e2e8f0', - fontSize: 13, - padding: '4px 8px', - textAlign: 'center', - outline: 'none', - }} - /> - {t('p2p.settings_hop_timeout_unit', 'minutes per hop')} -
-
- {t('p2p.settings_hop_timeout_hint', 'How long to wait for each agent to respond. Increase for complex tasks.')} -
- - {/* Extra prompt */} -
{t('p2p.settings_extra_prompt')}
-