Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions host-rs/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ struct Waiter {
deadline: Instant,
idle_since: Option<Instant>,
done: Option<oneshot::Sender<Value>>,
/// Who waits: once it is gone the wait is dropped, not polled to its timeout (#140).
client: u64,
}

struct Relay {
Expand Down Expand Up @@ -275,6 +277,7 @@ impl Host {
}
}
}
s.waiters.retain(|_, w| w.client != id);
let gone: Vec<u64> = s
.relays
.iter()
Expand Down Expand Up @@ -578,6 +581,7 @@ impl Host {
deadline: Instant::now() + Duration::from_millis(timeout_ms),
idle_since: None,
done: Some(tx),
client,
},
);
wid
Expand Down
14 changes: 11 additions & 3 deletions src/host/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -348,7 +351,8 @@ export class HostServer {
quietMs: f.quietMs,
deadline: this.now() + f.timeoutMs,
idleSince: null,
done
done,
client
})
this.tickWaits()
})
Expand Down Expand Up @@ -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 })
}
}

Expand Down Expand Up @@ -441,6 +448,7 @@ export class HostServer {

private async checkWaits(): Promise<void> {
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)
Expand Down
3 changes: 2 additions & 1 deletion src/main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
)
Expand Down
6 changes: 5 additions & 1 deletion src/main/chat/agy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? [])
]
},
Expand Down
7 changes: 7 additions & 0 deletions src/main/chat/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : {}
Expand Down
7 changes: 6 additions & 1 deletion src/main/chat/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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': {
Expand Down
8 changes: 7 additions & 1 deletion src/main/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
},
Expand Down
39 changes: 31 additions & 8 deletions src/main/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,48 @@ export function parseStatus(out: string): Omit<GitStatus, 'repo'> {
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<GitStatus> {
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<GitDiffResult> {
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[] = []
Expand All @@ -76,7 +99,7 @@ export async function gitDiff(run: GitRunner, cwd: string): Promise<GitDiffResul
} catch {
// Listing untracked files is a nicety; the diff still stands.
}
const truncated = patch.length > DIFF_MAX_BYTES
const truncated = over || patch.length > DIFF_MAX_BYTES
return {
ok: true,
patch: truncated ? patch.slice(0, DIFF_MAX_BYTES) : patch,
Expand Down
8 changes: 6 additions & 2 deletions src/main/host-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function startHostProcess(s: HostStart): Promise<void> {
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,
Expand All @@ -54,7 +54,11 @@ export async function startHostProcess(s: HostStart): Promise<void> {
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)
Expand Down
28 changes: 25 additions & 3 deletions src/main/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,12 @@ export function createAppStore({
/** Each project's last focused chat: where a preview's report goes (#74). */
const lastChat = new Map<string, string>()

/** 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<AppState>()((set, get) => {
const patchRuntime = (paneId: string, patch: Partial<PaneRuntime>): void => {
const cur = get().runtime[paneId]
Expand Down Expand Up @@ -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]) {
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,9 @@ export function DiffPanel() {
{pane ? <div className="dp-sub mono">{pane.cwd}</div> : null}
<div className="dp-bd" role="tabpanel">
{tab === 'history' && pane && paneId && git?.repo !== false ? (
// Keyed by pane: a list loading for one pane never lands in another's (#140).
<History
key={paneId}
paneId={paneId}
reload={`${nonce}:${git?.branch}:${git?.ahead}:${git?.behind}`}
/>
Expand All @@ -530,7 +532,9 @@ export function DiffPanel() {
)}
</div>
{pane && paneId && git?.repo ? (
// Keyed by pane: a commit message or a pending push confirm is for its own repo (#140).
<GitActions
key={paneId}
paneId={paneId}
branch={git.branch}
changed={git.changed}
Expand Down
Loading
Loading