From d3d80a80e5c93ab86f2607d54735dd99e3b39c4c Mon Sep 17 00:00:00 2001 From: cachewraith Date: Tue, 27 Oct 2026 00:10:42 +0700 Subject: [PATCH] fix: smaller bugs from the 2026-10-26 audit (#140) - Changes panel: GitActions and History keyed by pane; a diff past git's buffer shows its start marked cut; status overflow still reads as a repo; diff paths with spaces in binary/mode-only entries - chat: claude control_cancel_request and codex serverRequest/resolved close the approval bar, and a turn ending settles any left; codex stopAsked resets when turn/start fails; agy keeps --effort on first start; a chat view load racing forget no longer doubles its items - host: replay resets the terminal before its first piece; the Node host holds live output during an attach snapshot for attached clients too; `wait` dropped when its client leaves (Node and Rust hosts); host spawn errors no longer throw in main; `attach` stdin decoded as one UTF-8 stream - preview output read line by line; Settings path rows save a pending edit on unmount; Usage days by the calendar (DST); EdgeResizer cleans up when unmounted mid-drag Closes #140 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + docs/TODO.md | 2 +- host-rs/src/server.rs | 4 + src/host/server.ts | 14 +- src/main/app.ts | 3 +- src/main/chat/agy.ts | 6 +- src/main/chat/claude.ts | 7 + src/main/chat/codex.ts | 7 +- src/main/cli-entry.ts | 8 +- src/main/git.ts | 39 +++-- src/main/host-start.ts | 8 +- src/main/preview.ts | 28 +++- src/renderer/app/store.ts | 8 +- src/renderer/components/DiffPanel.tsx | 4 + src/renderer/components/EdgeResizer.tsx | 21 ++- src/renderer/components/SettingsView.tsx | 43 ++++-- src/renderer/lib/chat-bus.ts | 6 +- src/renderer/lib/chat-thread.ts | 13 ++ src/renderer/lib/diff.ts | 14 +- src/renderer/lib/pty-bus.ts | 15 +- src/shared/usage.ts | 17 ++- tests/unit/audit-small.test.ts | 175 +++++++++++++++++++++++ 22 files changed, 401 insertions(+), 45 deletions(-) create mode 100644 tests/unit/audit-small.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b036fe6..c617db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - [chat] A ↑ recall or a `/` pick no longer moves the caret a frame later (cause of the intermittent `chat-history.spec.ts` CI failures, e.g. `second promptline one`): the frame could land between a select-all and the typing that replaces it, so the text was added instead; the caret is now placed in a layout effect right after the text is in the box (files: src/renderer/components/chat/Composer.tsx) - [chat] A sideways swipe over the chat no longer pauses follow (the cause of the intermittent `chat.spec.ts:299` CI failure): Chromium sometimes scrolls the chat by the swipe's few px of vertical noise, and that upward move read as the reader going back; for 150 ms after a mostly sideways wheel the chat's own upward scroll is ignored (files: src/renderer/components/chat/ChatView.tsx) +- [git] Changes panel: commit message, push/pull confirm and History "Load more" belong to their pane (keyed by pane); a diff past git's 8 MB buffer shows its start marked cut, not the unstaged-only diff; too many changes to list still reads as a repo; paths with spaces in binary/mode-only entries are right (#140) (files: src/renderer/components/DiffPanel.tsx, src/renderer/lib/diff.ts, src/main/git.ts, src/main/app.ts) +- [chat] No stale approval bar after Stop (#140): claude's `control_cancel_request` and codex's `serverRequest/resolved` close it, and a turn ending settles any still open; a codex Stop before a failed `turn/start` no longer cuts the next turn short; agy's first start keeps `--effort` (the window leaves it out when the saved model list says the model has none); a chat view load racing a close no longer shows saved items twice (files: src/main/chat/{claude,codex,agy}.ts, src/renderer/lib/{chat-thread,chat-bus}.ts, src/renderer/app/store.ts) +- [host] A reattached screen resets the terminal before its first piece, and the Node host holds live output during an attach snapshot even for an already attached client (renderer reload drew it twice); `wraithgrid wait` is dropped when its client leaves (both hosts); a host spawn that fails later no longer throws in main; `wraithgrid attach` keeps UTF-8 whole across stdin chunks (#140) (files: src/renderer/lib/pty-bus.ts, src/host/server.ts, host-rs/src/server.rs, src/main/{host-start,cli-entry}.ts) +- [misc] Preview reads server output line by line (a URL cut across chunks got the wrong port); Settings path fields save a pending edit when the page closes; Usage counts days by the calendar (a clock change skipped or repeated one); a panel edge unmounted mid-drag drops its listeners and the drag flag (#140) (files: src/main/preview.ts, src/renderer/components/{SettingsView,EdgeResizer}.tsx, src/shared/usage.ts, tests/unit/audit-small.test.ts) ## 2026-10-26 diff --git a/docs/TODO.md b/docs/TODO.md index 1621a0e..eec4a03 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -3,7 +3,7 @@ ## Now - #76 Rust host: phase 3 (macOS + Windows) and 4 (default on, drop node-pty) left; phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed - CI flakes fixed 2026-10-27 (PR #152): chat.spec.ts:299 (sideways-wheel noise paused follow), chat-history.spec.ts (caret placed a frame late collapsed a select-all). Still seen once locally: responsive.spec.ts:131 (Settings nav heading not hidden in a small window), passed 3/3 on rerun -- Audit (2026-10-26) bugs: #140 smaller bugs (checklist). #139 fixed on fix/stale-diff-20261026. #135–#138 fixed (PR #148; #138 needs a real cancelled pkexec prompt to see live) +- Audit (2026-10-26) bugs: all fixed (#135–#138 PR #148, #139 PR #150, #140 PR #151). Not seen live: #138 cancelled pkexec prompt, #140 renderer-crash reattach, codex cancel/stop paths, agy first-start effort - Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm) - #41 (2026-10-22): agy in the chat UI (`src/main/chat/agy.ts`, `chat: true`); agy 1.4.4 has `-p --input-format/--output-format stream-json`, `--model/--effort/--mode`, `--continue/--conversation`; also set its `resumeArgs` - #40 (2026-10-22): Codex CLI in the chat UI (`src/main/chat/codex.ts` adapter, `chat: true`; `codex exec --json` vs `app-server` to decide) + codex sign-in dialog (`codex login --device-auth`/`status`; login.ts per-CLI) diff --git a/host-rs/src/server.rs b/host-rs/src/server.rs index 813d4b3..506e010 100644 --- a/host-rs/src/server.rs +++ b/host-rs/src/server.rs @@ -90,6 +90,8 @@ struct Waiter { deadline: Instant, idle_since: Option, done: Option>, + /// Who waits: once it is gone the wait is dropped, not polled to its timeout (#140). + client: u64, } struct Relay { @@ -275,6 +277,7 @@ impl Host { } } } + s.waiters.retain(|_, w| w.client != id); let gone: Vec = s .relays .iter() @@ -578,6 +581,7 @@ impl Host { deadline: Instant::now() + Duration::from_millis(timeout_ms), idle_since: None, done: Some(tx), + client, }, ); wid diff --git a/src/host/server.ts b/src/host/server.ts index d1613d9..000c38b 100644 --- a/src/host/server.ts +++ b/src/host/server.ts @@ -63,6 +63,8 @@ interface Waiter { /** When the pane was first seen idle in a row (for `quietMs`). */ idleSince: number | null done: (reply: HostReply) => void + /** Who waits: gone, the wait is dropped rather than polled to its timeout (#140). */ + client: Client } /** Typed input with no answer yet: the CLI is about to work (a `wait --until idle` right after `send`). */ @@ -160,6 +162,7 @@ export class HostServer { stream.on('close', () => { clearTimeout(helloTimer) this.clients.delete(client) + for (const w of this.waiters) if (w.client === client) this.waiters.delete(w) // A terminal attach resized its panes; give them back the window's size. if (client.role === 'cli') { for (const paneId of client.attached) { @@ -348,7 +351,8 @@ export class HostServer { quietMs: f.quietMs, deadline: this.now() + f.timeoutMs, idleSince: null, - done + done, + client }) this.tickWaits() }) @@ -386,8 +390,11 @@ export class HostServer { const now = this.now() if (now - entry.lastInputAt > ECHO_WINDOW_MS) entry.lastOutputAt = now for (const c of this.clients) { - if (c.attached.has(paneId)) c.send({ t: 'data', paneId, data }) - else c.catchUp.get(paneId)?.push(data) + // Held while this client's snapshot is taken, even when it was attached already (the + // window reattaching after a reload): sent now, it could also be in the snapshot (#140). + const held = c.catchUp.get(paneId) + if (held) held.push(data) + else if (c.attached.has(paneId)) c.send({ t: 'data', paneId, data }) } } @@ -441,6 +448,7 @@ export class HostServer { private async checkWaits(): Promise { for (const w of [...this.waiters]) { + if (!this.waiters.has(w)) continue // Its client left during an earlier await. const entry = this.panes.get(w.paneId) if (!entry) { this.waiters.delete(w) diff --git a/src/main/app.ts b/src/main/app.ts index 9a9c1e8..9ad7f34 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -667,7 +667,8 @@ const runGit: GitRunner = (args, cwd, opts) => env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } }, (err, stdout, stderr) => { - if (err) reject(Object.assign(err, { stderr: String(stderr) })) + // Past maxBuffer, `stdout` is what came before: a big diff shows that much (#140). + if (err) reject(Object.assign(err, { stderr: String(stderr), stdout: String(stdout) })) else resolve(String(stdout)) } ) diff --git a/src/main/chat/agy.ts b/src/main/chat/agy.ts index 8be46e7..95a754e 100644 --- a/src/main/chat/agy.ts +++ b/src/main/chat/agy.ts @@ -255,7 +255,11 @@ export function createAgyAdapter(): ChatAdapter { 'stream-json', ...(sessionId ? ['--conversation', sessionId] : continueLast ? ['--continue'] : []), ...(model ? ['--model', model] : []), - ...(model && effort && effortModels.has(model) ? ['--effort', effort] : []), + // The first start comes before `agy models` answered: then the window's pick stands + // (it leaves effort out for a model its saved list says has none, #140). + ...(model && effort && (!effortModels.size || effortModels.has(model)) + ? ['--effort', effort] + : []), ...(MODE_ARGS[mode] ?? []) ] }, diff --git a/src/main/chat/claude.ts b/src/main/chat/claude.ts index 257b0af..25b7e92 100644 --- a/src/main/chat/claude.ts +++ b/src/main/chat/claude.ts @@ -394,6 +394,13 @@ export function createClaudeAdapter(): ChatAdapter { break } + case 'control_cancel_request': { + // claude no longer wants the answer (the turn was stopped): the bar goes (#140). + const id = str(msg.request_id) + if (pending.delete(id)) push({ t: 'approvalDone', reqId: id, decision: 'deny' }) + break + } + case 'control_request': { const id = str(msg.request_id) const r = isObj(msg.request) ? msg.request : {} diff --git a/src/main/chat/codex.ts b/src/main/chat/codex.ts index 7092594..d31ede5 100644 --- a/src/main/chat/codex.ts +++ b/src/main/chat/codex.ts @@ -313,6 +313,8 @@ export function createCodexAdapter(): ChatAdapter { }) reply(request('thread/start', threadParams())) } else if (what === 'turn/start') { + // A Stop asked for this turn ends with it: the next turn must not be cut short (#140). + stopAsked = false push({ t: 'notice', level: 'error', text }) push({ t: 'busy', busy: false }) } else if (!['account/rateLimits/read', 'model/list', 'skills/list'].includes(what)) @@ -478,7 +480,10 @@ export function createCodexAdapter(): ChatAdapter { case 'serverRequest/resolved': { // Answered elsewhere, or no longer wanted: the bar must go. for (const [reqId, q] of pending) - if (String(q.id) === String(p.requestId)) pending.delete(reqId) + if (String(q.id) === String(p.requestId)) { + pending.delete(reqId) + push({ t: 'approvalDone', reqId, decision: 'deny' }) + } break } case 'thread/tokenUsage/updated': { diff --git a/src/main/cli-entry.ts b/src/main/cli-entry.ts index 1662a13..9e37f51 100644 --- a/src/main/cli-entry.ts +++ b/src/main/cli-entry.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { StringDecoder } from 'node:string_decoder' import { parseConfig } from '@shared/schema' import { hostPaths } from '../host/paths' import { HOST_STDIO, runCli, type Tty } from './cli' @@ -91,7 +92,12 @@ const tty: Tty | undefined = else process.stdin.pause() }, onInput: (cb) => { - const listener = (chunk: Buffer): void => cb(chunk.toString('utf8')) + // One decoder for the stream: a character cut across two chunks stays whole (#140). + const utf8 = new StringDecoder('utf8') + const listener = (chunk: Buffer): void => { + const text = utf8.write(chunk) + if (text) cb(text) + } process.stdin.on('data', listener) return () => process.stdin.off('data', listener) }, diff --git a/src/main/git.ts b/src/main/git.ts index c9cfffb..0ef49d0 100644 --- a/src/main/git.ts +++ b/src/main/git.ts @@ -46,25 +46,48 @@ export function parseStatus(out: string): Omit { return { branch, ahead, behind, changed: lines.length } } +/** The output git gave before it outran the runner's buffer, or null for any other failure. */ +function overflowed(err: unknown): string | null { + const e = err as { code?: unknown; message?: unknown; stdout?: unknown } + const over = + e?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || /maxBuffer/.test(String(e?.message)) + return over && typeof e.stdout === 'string' ? e.stdout : null +} + export async function gitStatus(run: GitRunner, cwd: string): Promise { try { const out = await run(['status', '--porcelain=1', '--branch', '--untracked-files=normal'], cwd) return { repo: true, ...parseStatus(out) } - } catch { + } catch (err) { + // Too many changed files to list is still a repo: count what was listed (#140). + const part = overflowed(err) + if (part !== null) + return { repo: true, ...parseStatus(part.slice(0, part.lastIndexOf('\n') + 1)) } return { repo: false, branch: null, ahead: 0, behind: 0, changed: 0 } } } export async function gitDiff(run: GitRunner, cwd: string): Promise { let patch: string + let over = false try { patch = await run(['diff', 'HEAD', '--no-color', '--no-ext-diff'], cwd) - } catch { - // No commits yet: there is no HEAD to compare against. - try { - patch = await run(['diff', '--no-color', '--no-ext-diff'], cwd) - } catch (err) { - return { ok: false, error: firstLine(err) } + } catch (err) { + const part = overflowed(err) + if (part !== null) { + // Bigger than the buffer: what came first, marked cut (#140), not the unstaged-only diff. + patch = part + over = true + } else { + // No commits yet: there is no HEAD to compare against. + try { + patch = await run(['diff', '--no-color', '--no-ext-diff'], cwd) + } catch (err2) { + const part2 = overflowed(err2) + if (part2 === null) return { ok: false, error: firstLine(err2) } + patch = part2 + over = true + } } } let untracked: string[] = [] @@ -76,7 +99,7 @@ export async function gitDiff(run: GitRunner, cwd: string): Promise DIFF_MAX_BYTES + const truncated = over || patch.length > DIFF_MAX_BYTES return { ok: true, patch: truncated ? patch.slice(0, DIFF_MAX_BYTES) : patch, diff --git a/src/main/host-start.ts b/src/main/host-start.ts index c05d28e..06b4107 100644 --- a/src/main/host-start.ts +++ b/src/main/host-start.ts @@ -40,7 +40,7 @@ export async function startHostProcess(s: HostStart): Promise { const [file, args] = s.rustBin ? [s.rustBin, []] : [s.execPath, [path.join(s.outDir, 'host.js')]] - spawn(file, args, { + const child = spawn(file, args, { detached: true, stdio: posix ? stdioMasking(out, nullFd, maskUpTo) : ['ignore', out, out], windowsHide: true, @@ -54,7 +54,11 @@ export async function startHostProcess(s: HostStart): Promise { maskedFds: maskUpTo }) } - }).unref() + }) + // A spawn that fails later (ENOENT, EACCES on the Rust binary) must not throw in main: the + // connect that follows finds no host and says it did not start (#140). + child.once('error', (err) => console.warn(`[wraithgrid] host spawn failed: ${err.message}`)) + child.unref() } finally { closeSync(out) if (nullFd >= 0) closeSync(nullFd) diff --git a/src/main/preview.ts b/src/main/preview.ts index 7d7432e..b9dd730 100644 --- a/src/main/preview.ts +++ b/src/main/preview.ts @@ -20,6 +20,9 @@ export interface DevServersDeps { stopGraceMs?: number } +/** How much of an unfinished output line is held back waiting for its end. */ +const LINE_REST_MAX = 64 * 1024 + interface Run { child: ChatChild urlSeen: boolean @@ -42,8 +45,8 @@ export class DevServers { } const run: Run = { child, urlSeen: false, lines: 0 } this.runs.set(paneId, run) - const onData = (buf: string): void => { - for (const raw of buf.split('\n')) { + const onLines = (lines: string[]): void => { + for (const raw of lines) { const line = plainLine(raw) if (!line.trim()) continue // A server stuck printing forever still leaves the app responsive. @@ -56,10 +59,29 @@ export class DevServers { } } } + // Line by line, per stream: a URL cut across two chunks (`:51` | `73/`) is read whole (#140). + const ends: (() => void)[] = [] for (const s of [child.stdout, child.stderr]) { + let rest = '' s.setEncoding('utf8') - s.on('data', onData) + s.on('data', (chunk: string) => { + const lines = (rest + chunk).split('\n') + rest = lines.pop()! + // A line that never ends (a progress bar redrawn with \r) still shows, in pieces. + if (rest.length > LINE_REST_MAX) { + lines.push(rest) + rest = '' + } + onLines(lines) + }) + ends.push(() => { + if (rest) onLines([rest]) + rest = '' + }) } + child.once('exit', () => { + if (this.runs.get(paneId) === run) ends.forEach((f) => f()) + }) child.once('error', (err) => { if (this.runs.get(paneId) !== run) return this.runs.delete(paneId) diff --git a/src/renderer/app/store.ts b/src/renderer/app/store.ts index 6c02fe5..949230e 100644 --- a/src/renderer/app/store.ts +++ b/src/renderer/app/store.ts @@ -607,6 +607,12 @@ export function createAppStore({ /** Each project's last focused chat: where a preview's report goes (#74). */ const lastChat = new Map() + /** False only when the CLI's model list says the chosen model has no effort levels. */ + const takesEffort = (models: ChatModel[], model: string | null): boolean => { + const m = models.find((x) => x.value === (model ?? 'default')) + return !m || m.efforts.length > 0 + } + const store = createStore()((set, get) => { const patchRuntime = (paneId: string, patch: Partial): void => { const cur = get().runtime[paneId] @@ -650,7 +656,7 @@ export function createAppStore({ // A conversation begun in the terminal view carries on here. continueLast: !pane.chatSessionId && pane.hasConversation && pane.resume, model: pane.model, - effort: pane.effort, + effort: takesEffort(chat.thread(paneId).models, pane.model) ? pane.effort : null, mode: pane.chatMode }) if (!get().runtime[paneId]) { diff --git a/src/renderer/components/DiffPanel.tsx b/src/renderer/components/DiffPanel.tsx index c6123b9..8fd841b 100644 --- a/src/renderer/components/DiffPanel.tsx +++ b/src/renderer/components/DiffPanel.tsx @@ -521,7 +521,9 @@ export function DiffPanel() { {pane ?
{pane.cwd}
: null}
{tab === 'history' && pane && paneId && git?.repo !== false ? ( + // Keyed by pane: a list loading for one pane never lands in another's (#140). @@ -530,7 +532,9 @@ export function DiffPanel() { )}
{pane && paneId && git?.repo ? ( + // Keyed by pane: a commit message or a pending push confirm is for its own repo (#140). void) | null>(null) + useEffect(() => () => abort.current?.(), []) const onPointerDown = (e: ReactPointerEvent): void => { const el = target.current @@ -51,12 +61,16 @@ export function EdgeResizer({ width = clamp(startW + sign * (ev.clientX - startX)) el.style.setProperty(cssVar, `${width}px`) } - const end = (): void => { + const stop = (): void => { + abort.current = null window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', up) window.removeEventListener('pointercancel', end) - setDragging(false) setPanelDragging(false) + } + const end = (): void => { + stop() + setDragging(false) onCommit(width) } // The release point decides the saved width: a busy renderer can drop or coalesce the @@ -68,6 +82,7 @@ export function EdgeResizer({ window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) window.addEventListener('pointercancel', end) + abort.current = stop } return ( diff --git a/src/renderer/components/SettingsView.tsx b/src/renderer/components/SettingsView.tsx index 717866b..cc69036 100644 --- a/src/renderer/components/SettingsView.tsx +++ b/src/renderer/components/SettingsView.tsx @@ -1,6 +1,6 @@ import { CommunityThemes } from './CommunityThemes' import { ThemeEditor } from './ThemeEditor' -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { formatArgs, parseArgs } from '@shared/args' import type { ShellOption } from '@shared/ipc-contract' import { AGENT_CLIS, AGENT_CLI_LABEL, AGENT_CLI_SPECS, type AgentCli } from '@shared/agent-clis' @@ -789,6 +789,31 @@ function NotifyRow() { ) } +/** + * Runs the latest save 400 ms after typing stops; leaving the page runs a waiting one at once, + * so the last edit is kept (#140). + */ +function useDebouncedSave(): (fn: () => void) => void { + const timer = useRef | null>(null) + const pending = useRef<(() => void) | null>(null) + const run = useCallback(() => { + if (timer.current) clearTimeout(timer.current) + const fn = pending.current + timer.current = null + pending.current = null + fn?.() + }, []) + useEffect(() => run, [run]) + return useCallback( + (fn: () => void) => { + if (timer.current) clearTimeout(timer.current) + pending.current = fn + timer.current = setTimeout(run, 400) + }, + [run] + ) +} + function ClaudeBinaryRow() { const { api } = useServices() const actions = useActions() @@ -796,17 +821,15 @@ function ClaudeBinaryRow() { const claude = useApp((s) => s.claude) const home = useApp((s) => s.info.homeDir) const [draft, setDraft] = useState(claudePath) - const timer = useRef | null>(null) + const save = useDebouncedSave() const commit = (value: string): void => { setDraft(value) - if (timer.current) clearTimeout(timer.current) - timer.current = setTimeout(() => { + save(() => { actions.setClaudePath(value.trim()) void actions.detectClaude() - }, 400) + }) } - useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []) const browse = async (): Promise => { const picked = await api.dialog.pickFile(draft || '~') @@ -882,21 +905,19 @@ function CliPathsRow() { const { updateConfig } = useActions() const saved = useApp((s) => s.config.settings.cliPaths) const [draft, setDraft] = useState>>(saved) - const timer = useRef | null>(null) - useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []) + const save = useDebouncedSave() const commit = (cli: AgentCli, value: string): void => { const next = { ...draft, [cli]: value } setDraft(next) - if (timer.current) clearTimeout(timer.current) - timer.current = setTimeout(() => { + save(() => { const cliPaths = Object.fromEntries( Object.entries(next) .map(([k, v]) => [k, (v ?? '').trim()]) .filter(([, v]) => v) ) as Partial> updateConfig((c) => ({ ...c, settings: { ...c.settings, cliPaths } })) - }, 400) + }) } return ( diff --git a/src/renderer/lib/chat-bus.ts b/src/renderer/lib/chat-bus.ts index c88ca71..6ba5a97 100644 --- a/src/renderer/lib/chat-bus.ts +++ b/src/renderer/lib/chat-bus.ts @@ -134,7 +134,9 @@ export class ChatBus { this.forgotten.delete(paneId) let p = this.loaded.get(paneId) if (!p) { - p = this.api.load(paneId).then((raw) => { + const mine = (p = this.api.load(paneId).then((raw) => { + // Forgotten (and maybe opened again) while reading: a newer load owns the view (#140). + if (this.loaded.get(paneId) !== mine) return const saved = parseThread(raw) const live = this.thread(paneId) this.set(paneId, { @@ -146,7 +148,7 @@ export class ChatBus { restored: saved.items.length, models: live.models.length ? live.models : saved.models }) - }) + })) this.loaded.set(paneId, p) } return p diff --git a/src/renderer/lib/chat-thread.ts b/src/renderer/lib/chat-thread.ts index 75005e2..8140f2f 100644 --- a/src/renderer/lib/chat-thread.ts +++ b/src/renderer/lib/chat-thread.ts @@ -135,6 +135,17 @@ function updateItem( return null } +/** Marks undecided approvals denied; the same array when there were none. */ +function settleApprovals(items: ChatItem[]): ChatItem[] { + if (!items.some((it) => it.kind === 'tool' && it.approval && it.approval.decided === null)) + return items + return items.map((it) => + it.kind === 'tool' && it.approval && it.approval.decided === null + ? { ...it, approval: { ...it.approval, decided: 'deny' } } + : it + ) +} + const nextId = (t: ChatThread, prefix: string): [string, number] => [ `${prefix}-${t.seq + 1}`, t.seq + 1 @@ -198,6 +209,8 @@ export function applyChatEvent(t: ChatThread, e: ChatEvent, now: number): ChatTh case 'busy': return { ...t, + // Nothing waits on a question once its turn is over (Stop, #140): not asked again. + items: e.busy ? t.items : settleApprovals(t.items), busy: e.busy, turnStartedAt: e.busy ? (t.turnStartedAt ?? now) : null } diff --git a/src/renderer/lib/diff.ts b/src/renderer/lib/diff.ts index bbeded0..5d1b644 100644 --- a/src/renderer/lib/diff.ts +++ b/src/renderer/lib/diff.ts @@ -27,6 +27,18 @@ function stripPrefix(name: string): string | null { return n.replace(/^[ab]\//, '') } +/** + * `a/my file.png b/my file.png`: unquoted paths with spaces can't be split on a space, but + * both sides name the same file unless renamed (then `rename to` sets it), so cut in the middle. + */ +function bothSides(rest: string): string { + const half = (rest.length - 1) / 2 + const b = rest.slice(half + 1) + return Number.isInteger(half) && rest[half] === ' ' && rest.startsWith('a/') && b.startsWith('b/') + ? b + : rest +} + export function parsePatch(patch: string): DiffFile[] { const files: DiffFile[] = [] let cur: DiffFile | null = null @@ -35,7 +47,7 @@ export function parsePatch(patch: string): DiffFile[] { for (const line of patch.split('\n')) { if (line.startsWith('diff --git ')) { const m = /^diff --git (\S+|"[^"]*") (\S+|"[^"]*")$/.exec(line) - const path = stripPrefix(m?.[2] ?? line.slice(11)) ?? '' + const path = stripPrefix(m?.[2] ?? bothSides(line.slice(11))) ?? '' cur = { path, oldPath: null, diff --git a/src/renderer/lib/pty-bus.ts b/src/renderer/lib/pty-bus.ts index 77b37eb..409cefa 100644 --- a/src/renderer/lib/pty-bus.ts +++ b/src/renderer/lib/pty-bus.ts @@ -10,6 +10,8 @@ interface PaneTrack extends PaneActivity { const TAIL_MAX = 4000 const PENDING_MAX = 1_000_000 +/** A full terminal reset (RIS): screen, scrollback and modes, as a fresh terminal has them. */ +const RESET = '\x1bc' /** * Routes PTY output to the right xterm instance without going through React state, @@ -18,6 +20,8 @@ const PENDING_MAX = 1_000_000 export class PtyBus { private readonly sinks = new Map() private readonly pending = new Map() + /** Panes whose replay is coming in: its pieces arrive in a row, live output ends it. */ + private readonly replaying = new Set() private readonly tracks = new Map() private readonly sizes = new Map() /** Each agent pane's "waiting for approval" output (its CLI's); shell panes have none. */ @@ -103,6 +107,7 @@ export class PtyBus { forget(paneId: string): void { this.sinks.delete(paneId) this.pending.delete(paneId) + this.replaying.delete(paneId) this.tracks.delete(paneId) this.sizes.delete(paneId) this.approval.delete(paneId) @@ -128,10 +133,16 @@ export class PtyBus { } private handleData(paneId: string, data: string, replay = false): void { + // A reattached screen is the whole screen: drawn over what the terminal already shows, it + // would show twice (a reload after a renderer crash, #140). Reset before its first piece, + // as `wraithgrid attach` clears; anything queued before it is in it too. + const first = replay && !this.replaying.has(paneId) + if (replay) this.replaying.add(paneId) + else this.replaying.delete(paneId) const sink = this.sinks.get(paneId) - if (sink) sink(data) + if (sink) sink(first ? `${RESET}${data}` : data) else { - const queued = (this.pending.get(paneId) ?? '') + data + const queued = (first ? '' : (this.pending.get(paneId) ?? '')) + data this.pending.set(paneId, queued.slice(-PENDING_MAX)) } // A reattached screen is old output: it must not read as running, approval or login. diff --git a/src/shared/usage.ts b/src/shared/usage.ts index d7635c5..5375d16 100644 --- a/src/shared/usage.ts +++ b/src/shared/usage.ts @@ -95,6 +95,17 @@ export function dayKey(ms: number): string { return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` } +/** + * The local day `n` calendar days before `now`'s. By the calendar, not n × 24 h: across a + * clock change that skips or repeats a day (#140). From noon, which every day has. + */ +export function dayKeyBefore(now: number, n: number): string { + const d = new Date(now) + d.setHours(12, 0, 0, 0) + d.setDate(d.getDate() - n) + return dayKey(d.getTime()) +} + /** Adds one turn to its day/account/project/chat bucket. */ export function addTurn( data: UsageData, @@ -151,7 +162,7 @@ export function addTurn( /** Drops days older than USAGE_DAYS. */ export function pruneUsage(data: UsageData, now: number): UsageData { - const oldest = dayKey(now - (USAGE_DAYS - 1) * 86_400_000) + const oldest = dayKeyBefore(now, USAGE_DAYS - 1) return { ...data, buckets: data.buckets.filter((b) => b.day >= oldest) } } @@ -194,9 +205,7 @@ const tokensOf = (b: UsageBucket): number => b.input + b.output + b.cacheRead + export function summarizeUsage(data: UsageData, range: UsageRange, now: number): UsageSummary { const days = RANGE_DAYS[range] const seriesDays = Math.max(days, 7) - const series = Array.from({ length: seriesDays }, (_, i) => - dayKey(now - (seriesDays - 1 - i) * 86_400_000) - ) + const series = Array.from({ length: seriesDays }, (_, i) => dayKeyBefore(now, seriesDays - 1 - i)) const from = series[seriesDays - days]! const dayIndex = new Map(series.map((day, i) => [day, i])) const total: UsageTotals = { costUsd: 0, tokens: 0, turns: 0, unpriced: 0 } diff --git a/tests/unit/audit-small.test.ts b/tests/unit/audit-small.test.ts new file mode 100644 index 0000000..ce03c33 --- /dev/null +++ b/tests/unit/audit-small.test.ts @@ -0,0 +1,175 @@ +// The smaller bugs of the 2026-10-26 audit (#140), one test each where a unit can show it. +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ChatEvent } from '@shared/chat' +import type { WraithApi } from '@shared/ipc-contract' +import type { PreviewEvent } from '@shared/preview' +import { dayKey, dayKeyBefore } from '@shared/usage' +import { createClaudeAdapter } from '../../src/main/chat/claude' +import type { ChatChild } from '../../src/main/chat/sessions' +import { gitDiff, gitStatus, type GitRunner } from '../../src/main/git' +import { DevServers } from '../../src/main/preview' +import { ChatBus } from '../../src/renderer/lib/chat-bus' +import { EMPTY_THREAD, applyChatEvent, pendingApproval } from '../../src/renderer/lib/chat-thread' +import { parsePatch } from '../../src/renderer/lib/diff' +import { PtyBus } from '../../src/renderer/lib/pty-bus' + +const overflow = (stdout: string): Error => + Object.assign(new Error('stdout maxBuffer length exceeded'), { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', + stdout + }) + +describe('git output past the buffer', () => { + it('a big diff shows what came first, marked cut, not the unstaged-only diff', async () => { + const run = vi.fn(async (args) => { + if (args[0] === 'diff') throw overflow('diff --git a/x b/x\n+one\n') + return '' + }) + const r = await gitDiff(run, '/repo') + expect(r).toMatchObject({ ok: true, truncated: true, patch: 'diff --git a/x b/x\n+one\n' }) + expect(run.mock.calls.filter((c) => c[0][0] === 'diff')).toHaveLength(1) + }) + + it('too many changes to list is still a repo, counted from the whole lines', async () => { + const run: GitRunner = async () => { + throw overflow('## main\n M a.ts\n?? b.ts\n?? c') + } + expect(await gitStatus(run, '/repo')).toMatchObject({ repo: true, branch: 'main', changed: 2 }) + }) +}) + +describe('diff paths with spaces', () => { + it('a binary file named with spaces keeps its own name', () => { + const files = parsePatch( + 'diff --git a/my file.png b/my file.png\nindex 1..2 100644\nBinary files a/my file.png and b/my file.png differ\n' + ) + expect(files[0]).toMatchObject({ path: 'my file.png', binary: true }) + }) +}) + +describe('preview output line by line', () => { + it('a URL cut across two chunks is read whole', async () => { + const ev = new EventEmitter() + const child = { + pid: 42, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: () => true, + once: (e: string, cb: (...a: unknown[]) => void) => ev.once(e, cb) + } + const events: PreviewEvent[] = [] + const ds = new DevServers({ + spawn: () => child as unknown as ChatChild, + emit: (_id, e) => events.push(e) + }) + ds.start('p1', { file: 'sh', args: [], cwd: '/', env: {} }) + child.stdout.write(' Local: http://localhost:51') + await new Promise((r) => setImmediate(r)) + child.stdout.write('73/\nlast line, no newline') + await new Promise((r) => setImmediate(r)) + ev.emit('exit', 0, null) + expect(events).toEqual([ + { kind: 'log', line: ' Local: http://localhost:5173/' }, + { kind: 'url', url: 'http://localhost:5173/' }, + { kind: 'log', line: 'last line, no newline' }, + { kind: 'exit', code: 0 } + ]) + }) +}) + +describe('approvals after Stop', () => { + it('a question claude takes back closes, and none outlives its turn', () => { + const a = createClaudeAdapter() + const ask = a.decode({ + type: 'control_request', + request_id: 'r1', + request: { subtype: 'can_use_tool', tool_name: 'Bash', input: { command: 'ls' } } + }).events + const cancel = a.decode({ type: 'control_cancel_request', request_id: 'r1' }).events + expect(cancel).toEqual([{ t: 'approvalDone', reqId: 'r1', decision: 'deny' }]) + + // Without a cancel (codex, an older claude): the turn ending settles it. + const busy: ChatEvent = { t: 'busy', busy: true } + let t = [busy, ...ask].reduce((x, e) => applyChatEvent(x, e, 1), EMPTY_THREAD) + expect(pendingApproval(t)).not.toBeNull() + t = applyChatEvent(t, { t: 'busy', busy: false }, 2) + t = applyChatEvent(t, busy, 3) + expect(pendingApproval(t)).toBeNull() + }) +}) + +describe('usage days by the calendar', () => { + const tz = process.env.TZ + afterEach(() => { + process.env.TZ = tz + }) + + it('counts back over a clock change without skipping or repeating a day', () => { + process.env.TZ = 'Europe/Berlin' + // 2026-03-29 has 23 hours there: 24 h before 00:30 on the 30th is the 28th. + const now = new Date(2026, 2, 30, 0, 30).getTime() + expect(dayKey(now - 86_400_000)).toBe('2026-03-28') + const week = Array.from({ length: 7 }, (_, i) => dayKeyBefore(now, 6 - i)) + expect(week).toEqual([ + '2026-03-24', + '2026-03-25', + '2026-03-26', + '2026-03-27', + '2026-03-28', + '2026-03-29', + '2026-03-30' + ]) + expect(dayKeyBefore(now, 0)).toBe(dayKey(now)) + }) +}) + +describe('chat view load and forget', () => { + it('a load still reading when the pane was forgotten and reopened adds its items once', async () => { + type ChatApi = WraithApi['chat'] + const reads: ((raw: string) => void)[] = [] + const bus = new ChatBus({ + onEvent: () => () => {}, + load: () => new Promise((r) => reads.push(r)), + save: vi.fn() + } as unknown as ChatApi) + const saved = JSON.stringify({ + v: 1, + sessionId: 's', + model: null, + seq: 1, + items: [{ kind: 'notice', id: 'n1', level: 'info', text: 'old' }] + }) + const first = bus.load('p') + bus.forget('p') + const second = bus.load('p') + reads[0]!(saved) + reads[1]!(saved) + await Promise.all([first, second]) + expect(bus.thread('p').items.map((it) => it.id)).toEqual(['n1']) + }) +}) + +describe('terminal replay', () => { + it('a reattached screen resets the terminal first, once per replay', () => { + let onData: (e: { paneId: string; data: string; replay?: boolean }) => void = () => {} + const pty = { + onData: (cb: typeof onData) => { + onData = cb + return () => {} + }, + onExit: () => () => {} + } as unknown as WraithApi['pty'] + const bus = new PtyBus(pty) + bus.start() + const out: string[] = [] + bus.attach('p', (d) => out.push(d)) + onData({ paneId: 'p', data: 'old screen' }) + onData({ paneId: 'p', data: 'piece 1', replay: true }) + onData({ paneId: 'p', data: 'piece 2', replay: true }) + onData({ paneId: 'p', data: 'live' }) + expect(out).toEqual(['old screen', '\x1bcpiece 1', 'piece 2', 'live']) + }) +})