Merge latest main into PR review fixes

# Conflicts:
#	src/renderer/src/components/terminal-pane/pty-transport.ts
This commit is contained in:
Brennan Benson
2026-07-13 23:57:23 -07:00
83 changed files with 5153 additions and 3579 deletions
+4 -4
View File
@@ -100,9 +100,9 @@ docs/reference/react-performance-audit.md
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Local scratch notes and PR evidence screenshots (not part of the product).
/notes/
/pr-evidence/
# Playwright
test-results/
@@ -123,5 +123,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json
src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
# Bench result JSONs are working artifacts
tools/benchmarks/results/terminal-pipeline-*.json
+108 -96
View File
@@ -24,6 +24,7 @@ import Animated, {
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
const DISMISS_THRESHOLD = 80
@@ -107,6 +108,7 @@ function MountedBottomDrawer({
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
const insideModalHost = useInsideBottomDrawerModalHost()
useEffect(() => {
if (visible) {
@@ -265,104 +267,114 @@ function MountedBottomDrawer({
return { opacity: progress.value * dragFade }
})
// Why: rendering through a full-screen Modal lifts the sheet into its own
// native window so it always covers the viewport — even when the drawer is
// mounted deep inside a ScrollView, where a plain absolute overlay anchors to
// the scrolled content and clips the sheet. The Modal stays mounted (visible)
// for the whole life of MountedBottomDrawer so the reanimated exit animation
// runs before the parent unmounts us; show/hide is driven by `progress`, so
// animationType stays "none". onRequestClose handles the Android back button.
// Why: the sheet renders through a full-screen native window (its own Modal
// below, or the shared BottomDrawerModalHost) so it always covers the viewport
// — even when mounted deep inside a ScrollView, where a plain absolute overlay
// anchors to the scrolled content and clips the sheet. Show/hide is driven by
// `progress` (animationType "none") so the reanimated exit animation runs before
// the parent unmounts us.
const overlay = (
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
)
// Why: inside a BottomDrawerModalHost the host owns the single native Modal;
// rendering our own would stack modals and reintroduce the iOS present/dismiss
// race the host exists to avoid. The host handles the Android back button.
if (insideModalHost) {
return overlay
}
return (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
{overlay}
</Modal>
)
}
+20 -3
View File
@@ -15,6 +15,7 @@ import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
@@ -814,7 +815,23 @@ function NewWorktreeModalContent({
}
return (
<>
// Why: hosting the form and every picker in one persistent native Modal makes
// form → repo/agent transitions in-window view swaps, avoiding the iOS
// dismiss-then-present race that left the dropdowns unresponsive. Native back
// closes the flow from the form, routes the trust prompt through its in-flight
// guard, and otherwise returns to the form from a picker.
<BottomDrawerModalHost
visible={visible}
onRequestClose={() => {
if (drawerView === 'form') {
onClose()
} else if (drawerView === 'trust') {
closeSetupTrust()
} else {
transitionDrawer('form')
}
}}
>
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
@@ -1036,7 +1053,7 @@ function NewWorktreeModalContent({
</BottomDrawer>
{/* Why: list drawers stay outside the form's ScrollView, and the transition
state prevents overlapping native modals from swallowing iOS taps. */}
state lets each hosted overlay finish hiding before the next appears. */}
<SmartWorkspaceSourceDrawer
visible={visible && drawerView === 'source'}
client={client}
@@ -1088,7 +1105,7 @@ function NewWorktreeModalContent({
onDontRun={skipSetupTrust}
onClose={closeSetupTrust}
/>
</>
</BottomDrawerModalHost>
)
}
@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from 'react'
import { Modal } from 'react-native'
const BottomDrawerModalHostContext = createContext(false)
/** True when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and
* must therefore skip its own native Modal (the host owns the single Modal). */
export function useInsideBottomDrawerModalHost(): boolean {
return useContext(BottomDrawerModalHostContext)
}
type Props = {
visible: boolean
onRequestClose: () => void
children: ReactNode
}
// Why: iOS cannot reliably dismiss one native modal and present another in the same
// beat. Flows that swap between sibling drawer modals (e.g. the Create Workspace form
// → its repository/agent pickers) dropped the incoming modal, leaving the sheet dead
// to taps. Hosting every drawer in ONE persistent native Modal makes those swaps
// in-window view changes instead, so no present/dismiss race can eat the transition.
export function BottomDrawerModalHost({ visible, onRequestClose, children }: Props) {
if (!visible) {
return null
}
return (
<Modal
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onRequestClose}
>
<BottomDrawerModalHostContext.Provider value={true}>
{children}
</BottomDrawerModalHostContext.Provider>
</Modal>
)
}
@@ -66,6 +66,8 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
return `A ${copy.reviewLabel} already exists for this branch.`
case 'fork_head_unsupported':
return `Creating a ${copy.reviewLabel} from this fork is not supported.`
case 'base_not_on_remote':
return `Push the base branch before creating a ${copy.reviewLabel}.`
case 'needs_push':
case null:
case undefined:
-296
View File
@@ -1,296 +0,0 @@
# Garble differential fuzz — divergence log
Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz
(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is
a case where restoring a hidden terminal from its main-side snapshot
(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal)
produces a screen that differs from an always-visible renderer terminal fed the
same bytes. Any such diff is a user-visible garble on reveal.
## Method
- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane
sizes, PTY-style random chunk splitting.
- Differential: production `HeadlessEmulator` snapshot replayed into a fresh
renderer-parity terminal, compared cell-by-cell (text, per-cell style,
cursor, modes, scrollback) against an always-visible renderer-parity twin.
- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's
buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`,
`vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified
against `buildDefaultTerminalOptions` in
`src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and
`pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`,
`drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored
cell attributes, so their omission is not a source of false diffs.
`windowsMode` is unset in both (matches renderer). Addon versions:
`@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all
`*-beta.287` (headless `6.1.0-beta.287`).
- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug
(predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is
listed below.
## Inventory
| bug | found by | seeds | classification |
| --- | --- | --- | --- |
| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN |
| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) |
| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) |
| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) |
| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) |
Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and
their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D
carries position only (saved SGR/charset are not re-established — the synthetic
ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate
snapshot field written LAST by restorers because any later ESC (e.g. the
post-replay reset) would abort the dangling sequence; its bytes are already
counted by the snapshot seq, so tail-slice arithmetic is unchanged.
All five bug classes are reproduced by dedicated minimal `test.skip` repros so
they cannot silently regress, AND each is tolerated + counted by its suite's
corpus loop so deep mode surfaces only genuinely NEW divergences:
- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via
`snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized
snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a
full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`.
- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the
Bug-C cursor cascade are kept out of the corpus by an append-only racing tail
(no DECSC/cursor motion) and pinned only as standalone repros. Green at the
default 200 and at `FUZZ_ITERATIONS=1000`.
Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts
tripping on most seeds fails the suite instead of silently swallowing it.
Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored
text mid-line") does not diverge on the current harness. It is a `savedCursor
Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the
emulator and the serializer here. It was most likely an earlier observation
folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a
transient during harness construction. No live divergence at 113.
---
## Bug A — SerializeAddon drops null cells at a soft-wrap boundary
**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found
and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz
suite (V1 seed 31, V2 seed 157). Full mechanism documented in
`bufferHasSerializeHostileWrappedRow` and the suite's headline comment.
- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell
passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps
at the right margin, overwriting the previous row's last cell and shifting the
tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`.
- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null
takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so
the ECH erase lands one cell right and the first filler `-` survives.
**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake,
mobile subscribe replay) paints lost/shifted characters or stray `-` fillers
when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite;
unskip the repros when upstream fixes or a local serialize post-processor lands.
---
## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops).
**Minimal repro (isolated, no fuzz corpus needed), cols=20:**
```
live bytes: "\x1b[2mA\x1b[22m\x1b[1mB"
SerializeAddon → : "\x1b[2mA\x1b[1;22mB"
live cell B: bold=1 dim=0 (style flags 100000)
restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST
```
**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen
from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in
xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the
emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is
neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`.
(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is
specific to a dim-cell → bold-only-cell attribute transition.)
**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a
bold status/spinner line (Claude Code, Codex). On the live screen the status
line is bold; after a hide→reveal snapshot restore it renders normal-weight.
The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored.
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`.
Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`.
---
## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK).
**Minimal repro (isolated, pure serializer replay), cols=10:**
```
live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5)
SerializeAddon → : "0123456789\x1b[2B\x1b[6D"
live cursor: { x: 4, y: 2 }
restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT
```
Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"`
and round-trips the cursor exactly, isolating the trigger to a full-width final
content row.
**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in
the *wrap-pending* state (cursor visually on the last column, logically "one
past"). The serializer computes its final cursor-restore as relative
`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed
one column short, so the restored cursor lands at `x-1`. Reproduced with pure
`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble
or normalization involved**, confirming it is upstream, not Orca's snapshot
path.
**Why it garbles a real pane:** the cursor is where the next keystroke echoes
and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line
reached the right edge (wide status lines, long prompts), the cursor sits one
cell left of where the live pane had it — visible as a mispositioned prompt
caret or spinner, and subsequent input can overwrite the wrong cell.
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
`it.skip('restores the cursor exactly when the last content row fills the right margin')`.
Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`.
---
## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary
**Classification: (a) real bug — a structural snapshot limitation.** New
finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity
fuzz.
**Minimal repro (cols=20):**
```
hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2,
move to r3c9, write CD)
tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X)
live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 }
reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 }
^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2
snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all)
```
**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few
rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` /
`CSI u`) is runtime state that never appears in the serialized buffer, so it
cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before
the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or
`\x1b[u`), the restore targets the fresh terminal's default saved position
(home) instead of where the TUI saved it — the next writes land at the wrong
cell and overwrite live content.
**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and
status-line redraws (save cursor, jump to a corner to paint a clock/token
counter, restore). If the save happens while the pane is hidden and the restore
fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2
seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and
whose `\x1b8` fell in the racing tail after chunk-splitting).
**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor
motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is
tested in isolation from this and the other terminal-state-loss garbles. Bug D
is instead pinned as a standalone repro,
`hidden-reveal-reconciliation.fuzz.test.ts` →
`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`.
**Fix (applied):** the snapshot epilogue re-establishes the register with
`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`,
reading the active buffer's core register via `readSavedCursorRegister`
(alt screen yields its own register). Position-only: saved SGR/charset are not
carried.
---
## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence
**Classification: (a) real bug — a structural snapshot limitation.** New
finding, surfaced by the reveal-reconciliation fuzz (suite 2).
**Minimal repro (cols=20):**
```
hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet)
tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD)
live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic)
reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character
snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone)
```
**Mechanism:** a PTY read (one delivery record) can split an escape sequence.
If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the
serialized SCREEN cannot carry the partial sequence (it lives in the parser
state machine, not the buffer). The racing tail supplies the sequence's
remaining bytes, but with the prefix gone the terminal parses them as literal
text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`.
**Why it garbles a real pane:** any TUI whose output is heavy with escape
sequences (all of them) can have a read boundary fall mid-escape; if a reveal
lands in that window the continuation renders as stray literal bytes (a rogue
`m`, `H`, digits) injected into the visible text.
**Reachability:** requires the reveal/snapshot to fire in the gap between the two
halves of a split escape. `main` writes each PTY read to the emulator and
records it as one delivery unit (`session.ts emitSubprocessOutput`), and the
snapshot is taken synchronously at a drain — so the window is a single delivered
record that ended mid-escape. Narrow but real.
**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends
mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the
serialize wrap bug — it fired on ~24% of the corpus, confirming the class is
common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` →
`it.skip('completes an escape sequence split across the hide/reveal boundary')`.
**Fix (applied):** the emulator tracks the unparsed trailing partial escape at
ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse
mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers
write it LAST, after their post-replay resets, so the racing tail's
continuation completes it exactly as live. Snapshot seq already counted those
ingested bytes, so reconcile slicing is unchanged.
---
## Known-legitimate normalization (NOT bugs)
- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link
cells underlined; SerializeAddon never re-emits OSC 8. Production restores the
link ranges out-of-band via `snapshot.oscLinks`
(`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the
underline by design. Pinned by the passing
`it('drops OSC 8 underline from byte replay but preserves the range …')`.
- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette
indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored
cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the
same 16 theme slots — no visual difference. Canonicalized by
`canonicalColorMode` in the parity fixture.
---
## Corpus vs deep mode
- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default
`FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A,
B, and C are each tolerated + counted by a predicate, so the corpus fails only
on a genuinely new divergence.
- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default
`FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail
is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`).
- Combined default runtime is ~19s (well under the 60s gate).
- `FUZZ_SEED=<n>`: re-run exactly one seed for a repro (both suites).
-532
View File
@@ -1,532 +0,0 @@
# orca-performance Branch Guide
Agent-facing map of every optimization on this branch: what it does, why it exists,
where it lives, and the invariants you must not break when adding to it. The
chronological evidence trail (benchmarks, retractions, A/B protocols) is in
`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view.
**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load
(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s).
As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput
11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms
(10× Terminal.app).
## The pipeline
```
shell → pty → daemon (persistence, headless model) → unix socket
→ main (ipc/pty.ts: batching, delivery gate, flow control, snapshots)
→ IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm)
```
Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer
MessagePort). The daemon owns sessions so they survive app restarts; it also runs
a headless xterm emulator per pty — the _model_ — which is the source of truth
for screen contents. The renderer terminal is a _view_ that can be discarded and
rebuilt from model snapshots.
## Optimization inventory
### 1. Renderer parse-path fixes (the original 16× on agent TUIs)
- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain
cadence follows xterm's actual parse completion instead of fixed timers, so the
queue never outruns the parser.
- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only
re-process a bounded window instead of the full retained tail. Guarded by
differential fuzz `retained-tail-redraw-window.equivalence.test.ts`.
- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent
wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at
50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's
per-chunk cost.
### 2. term-speed-2 chain (model/view contract — the architecture)
Revived from ~38 never-merged branches; kill-switched, default ON. Docs:
`docs/reference/terminal-model-view-contract.md`.
- **Hidden view parking**: hidden tabs tear down their xterm view entirely
(memory: parked panes cost ~0).
- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are
dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a
model snapshot + live chunks after the snapshot's seq.
- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd)
from the model so parked panes stay live in the UI without a view.
- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC
colors) deterministically from the model for hidden panes.
- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal
reconciliation drops duplicates already covered by the snapshot baseline.
### 3. Batching & scheduling cadence
- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts`
(`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`).
At 9% utilization there is no queue — latency was literally the sum of fixed
batch windows. This one change took dev DSR-load 19→8ms.
- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`):
Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks
without the clamp, preserving cooperative yield (input/paint still serviced).
Vitest keeps the timer path (fake timers can't advance channel posts).
- **Input write coalescing** (from main, #7205): renderer input writes coalesce
instead of queuing macrotask-per-keystroke.
### 4. Backpressure (the correctness spine — read before touching delivery)
Three cooperating layers, innermost first:
- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler
`ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the
chunk is legitimately discarded), not when IPC delivered it.
**INVARIANT: every delivered chunk credits exactly once — parsed or
discarded.** Every scheduler/pty-connection discard path (backlog replacement,
disposed terminal, reconcile drop, split remainders) must fire the credit.
- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`,
`applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals
(TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a
fully-gated pty triggers a resync probe instead of a timeout reset. The only
timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight
gate + 2MB pendingData cap sit on top.
- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`,
`pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed
wedge where every main→renderer PUSH channel dies while invoke stays alive
(v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden
resync probe can never reach. The 15s heartbeat costs one Map upsert per
received chunk and does no IPC while output flows; mutation stays
verified-state-only (the timer decides when to REPORT; the write-off derives
entirely from the renderer's cumulative received totals, never wall-clock,
and a received-but-unparsed window is never written off). Heal = re-attach
push listeners + pull restore markers through the modelRestoreNeeded router.
E2e blackhole harness: `__terminalDeliveryWatchdog`,
`terminal-push-delivery-loss-recovery.spec.ts`.
- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts`
and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts):
recovers the field-confirmed wedge where macOS occlusion tracking pins
`document.visibilityState` at `'hidden'` after display sleep and never fires
another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate
dropped across 2 pane-level-visible ptys, transport healthy). Real user
input (keydown/pointerdown/window focus) while the document claims hidden is
a physical contradiction — it latches an override, runs each pane's existing
visibilitychange resync (gate unhide + hidden-output restore), and a genuine
visibilitychange hands authority back. No timers; recovery is purely
event-proven, and the failure bias is safe (a wrong override only restores
pre-gate delivery cost, never drops bytes). Hot-path cost: zero when
visible (same single comparison); one property read per user-interaction
event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the
freeze repro and the keystroke recovery, plus the
`hiddenDeliveryGatedVisiblePtyCount` field discriminator).
- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed
`await window.__orcaTerminalFreezeReport()`): a single DevTools command that
returns renderer state (visibilityState + stale override, pty:data listener
count, watchdog totals), main's snapshot with a per-pty delivery table
(sent/acked/pending, hidden vs visible-set membership, last send/ACK ages,
window focus flags, power suspend/resume ages, app version), and bounded
breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared
ring: 100 entries, same-kind coalescing) recording gate marks, visibility
trust changes, watchdog stalls/heals, restore markers, heal write-offs,
renderer lifecycle resets. Pty ids are redacted to their `@@` suffix —
daemon session ids embed worktree paths. Recording happens only on rare
transitions; the table/report is built only when read. This exists so a
field freeze report never needs a follow-up ask.
- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit
guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that
keep a split pane's death near the hidden/park boundary from stranding state
(field incident: a closed setup-split leaf persisted in `root` with no
binding and remounted as a permanently blank pane, unreachable by
dead-session reconcile — it skips ptyId-null panes by design).
(1) The "keep a fresh split whose newborn PTY died" branch is **gated on
`isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output"
proves nothing there; a hidden newborn death must `closePane`, or the kept
pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked**
reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a
mounted TerminalPane), so the sidecar itself collapses the dead leaf out of
the stored layout via `detachTerminalLayoutLeaf` — a stale binding left
behind reattaches on reveal and the daemon re-creates the exited session id
as a fresh shell (silent pane resurrection). E2e:
`terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every
lifecycle phase and asserts leaves(root) == bindings == live panes.
- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause /
32KB resume watermarks, keyed off **pendingData only** — never renderer
counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's
buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous
(drops impossible); for daemon ptys the pause notify has ~20-30ms socket
latency, so wire-speed bursts can still cross the 2MB cap (known follow-up:
daemon-side self-pacing watermark).
- **Shallow stream-socket write gate + per-session fairness**
(`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices /
4KB small-session bypass / 32MB write-through valve; kill switch
`ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for
every session — bytes already written can never be overtaken, so a deep
user-space buffer buries a visible pane's echo behind other panes' bulk
(measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at
the gate and hold in the batcher, where the interactive flushSession path
and the deterministic small-session bypass still jump them; socket `drain`
refills. This layer alone bounds echo latency by the shallow depth.
- **Background keep-tail stream thinning + daemon fact authority**
(`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail,
`daemon-background-transient-facts.ts`; kill switch
`ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from
pendingData flow control (main drops their bytes after ingestion), which
let N background agents run unbounded ahead of main. Main mirrors the
hidden-delivery gate to the daemon via the wire-tolerated
`setSessionBackground` notification (introduced in v19; authoritative
thinning requires v20 snapshots, so preserved v19 sessions are explicitly
unthinned; older daemons swallow it) — but a live remote view subscriber
(mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`).
Backgrounded sessions' queued output is keep-tail dropped (oldest bytes
replaced by an in-order `dataGap` event; reply-eliciting query bytes are
salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused,
so reveal stays instant with zero catch-up. Un-background neither discards
nor force-flushes the queued tail — restore paths read MAIN's model
(hidden-output recovery buffer), so a discarded tail loses a finished
program's last output forever (caught by the ACK-backpressure e2e), and a
16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own
bytes; the ordered drain loop delivers it within the budget below. Two
aggregate bounds make that budget real: (1) a GLOBAL background keep
budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more
backgrounded sessions hold queued data, and tighten retroactively when the
count grows — without this, N sub-cap sessions queue N×cap and a worktree
switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden
restore vs the 1.5s budget, probe-verified drain overlap); (2) a
kernel-flush refill sentinel: a held flush pass arms one ~90B empty data
event whose write callback re-flushes when the kernel accepts the
in-flight bytes — without it, held bulk advances one gate-depth per
'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy
daemon). NOTE: an empty `socket.write('')`'s callback fires immediately
even with megabytes buffered (verified) — the sentinel must be a real
protocol no-op line. Notifications are
structurally lossless: while backgrounded, the DAEMON runs the same shared
scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133
command-finished / pr-link / DECSET 2031) over every byte BEFORE drop
decisions and relays facts as in-order `transientFact` events; ordered
`sessionBackgroundMarker` events hand scan authority back and forth
(main suppresses just those four scanners in between), and the emulator's
`partialEscapeTailAnsi` seeds each side's fresh scanner carry so a
sequence split across the handoff neither phantom-fires nor goes missing.
Titles/agent-status stay main-side (they converge from the kept tail and
fuse with synthetic spinner frames). On `dataGap`, main resets its
cross-chunk parse carries, drops the mobile headless mirror (rebuilds from
tail/seeds), and sends the model-restore-needed marker so any
renderer-side buffer heals from the snapshot. Visible ptys are never
touched by this layer. Backlog observability:
`ORCA_DAEMON_STREAM_BACKLOG_FILE=<path>` JSONL
(`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`,
`setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`).
Causation A/B (`bench:multi-workspace-typing`): realistic steady rates
(8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine
do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50
29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms.
### 5. Flood resilience (why bulk output can't wedge or lie anymore)
- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop
abandons immediately when a foreground pane's live-chunk queue overflows
(3-iteration hard cap), and a 2s flood-suppression window stops main's own
backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming
restore — bytes write through, ONE deferred repaint heals after the flood.
This killed a positive feedback loop (restore starves ACKs → main drops →
drop re-arms restore) that caused multi-second renderer stalls.
- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal
queries are extracted (`terminal-reply-query-extraction.ts`) and answered by
_synthesizing replies on the input path_ (CPR from live buffer, DA1 canned,
OSC via direct responder) — probes and TUIs never hang on a dropped reply.
- Drops are downstream of the model: the daemon ingests every byte, so the
post-flood repaint restores complete, correct content.
### 6. Wake/sleep recovery
- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes
WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed).
- Cumulative ACKs make the historical "lost ACKs across suspend pin the global
window forever" wedge (BMW user bug) structurally impossible.
### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned)
Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs
found by differential fuzzing; four fixed, one tolerated:
- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted
`1;22` (22 clears the bold 1 just set). Patched via pnpm patch
(`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the
bold/dim group (+2 sibling bare-22 defects).
- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely:
`serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`)
appends absolute CUP from the source terminal's authoritative cursor
(skipped when wrap-pending, where CUP would corrupt).
- **D: DECSC saved-cursor register not serialized** — snapshot appends
`CUP(saved) + ESC 7 + CUP(actual)` when a register exists.
- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) —
`terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the
unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written
LAST on restore so continuation bytes complete the sequence. Seq accounting
unchanged (the tail is a suffix of bytes ≤ snapshot seq).
- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by
`bufferHasSerializeHostileWrappedRow`, the only remaining tolerance.
## Correctness infrastructure (run these before merging delivery/restore changes)
- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs
reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep,
`FUZZ_SEED=n` to replay.
- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random
hide/reveal boundaries × snapshot seq × racing chunks must equal an
always-visible reference.
- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped
= regression alarms).
- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test —
byte-identical vs control), `terminal-parked-memory`,
`terminal-sleep-wake-restore`.
- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests
(pane-manager / terminal-pane suites).
## Benchmarking protocol (hard-won rules)
- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under
1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the
terminal under test.
- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12
--rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label <build>`
— real keystrokes (CDP) into a visible pane while N hidden-worktree panes
replay paced agent-TUI streams through real daemon ptys; decomposes each key
into input-half (keydown→pty, sidecar timestamps) and echo-half
(pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s:
p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer
timer drift staying ~15ms while echo-half grows means the backlog is
upstream of the renderer (daemon socket / main ingest).
- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev
benches used 3MB and never tripped the cap.
- Load-controlled A/B only: alternate builds within one session; dev carries ~2×
day-to-day variance. Never conclude from runs while agents/builds hammer the
machine (two false convictions came from this).
- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang).
- Packaged builds are truth; dev has ~2× overhead.
## Release mechanics
- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance
version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above
its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter
(`release-rc-history.mjs`), telemetry identity classifier, and build guard are
all suffix-aware — a suffixed rc classifies as `rc`.
- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release
(PR #7278; merged here) — perf-line users self-update after one manual install.
## Syncing with main: MERGE, never rebase
`orca-performance` is a long-lived, shared, continuously-pushed integration
branch — RCs are cut from it and agents branch off it. **Always
`git merge origin/main`; never rebase** (rebasing rewrites pushed history and
strands every RC tag, fix branch, and worktree based on the old commits).
Conflict pattern, established over ~6 syncs:
1. **Our structure wins; main's semantics graft in.** This branch deliberately
restructures terminal code (shared scanners, single-policy handlers,
model/view split). When main adds a feature inside code we've restructured,
keep our shape and port their new behavior into it. Example: main inlined an
OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared
`createOsc133CommandFinishedScanner` (main's side-effect tracker must parse
byte-identically) and added 133;C support to the shared scanner instead.
2. Preserve the invariants in **Guardrails** below through every resolution —
especially chunk-credit, `pendingData`-keyed flow control, and the
single `handleCommandFinished` policy (byte path AND sideEffect-fact path
route through it).
3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon
suites, and both fuzz suites. Commit the merge with a message stating what
was kept from each side; push. If the push races a moved remote, merge the
remote tip — never `pull --rebase` a merge.
4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench
before the next RC cut.
## Known limits / next levers (in rough priority order)
1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at
wire speed before `pausePty` bites. Fix: daemon enforces its own watermark
locally (VS Code does this server-side for remotes).
2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining
drain cadence dominate the 13.3ms prod p50.
3. utilityProcess router endgame: take main off the per-byte hot path
(VS Code's ptyHost→renderer MessagePort shape).
4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up).
5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control →
term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK)
is open against main separately.
## Guardrails for future agents
- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path
that receives, defers, drops, or splits pty data in the renderer, prove it
credits exactly once. The credit-invariant unit tests are the gate.
- Flow control keys off `pendingData` only. Do not couple it to renderer
counters; the two layers compose because they are independent.
- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes
≤ its seq (Bug E made this true; don't regress it). Chunks after restore are
reconciled by seq — off-by-N re-triggers duplicate-drop garble.
- Hidden panes must receive nothing (delivery gate) but side-effects and query
replies must stay live via the model. If you add a new query type, wire it
through model authority AND the drop-path synthesis.
- Never add timeout-based recovery that mutates counters (user requirement —
design decision from #7260). Deterministic resync or nothing; hygiene timers
may only log.
- Any change on the delivery/restore path: run both fuzz suites, the roundtrip
tests, the chain e2e trio, AND a 10MB bench before calling it done.
## Audit 1
Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main →
renderer terminal path, with particular attention to hidden delivery, parking,
stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach,
mobile/remote composition, SSH routing, and teardown. The audit treated model
state and user scrollback as correctness requirements, not expendable memory.
### Findings and fixes
1. **Hidden-gate handoff owners could undo one another.** A parked watcher and
an unmounting/remounting pane shared one boolean hidden mark; likewise a
retiring pane could report `visible=false` after its replacement had already
reported `visible=true`. Hidden claims are now reference-counted and
visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager
pre-mount buffers no longer hold raw-byte delivery interest: they are not a
side-effect consumer, and model-backed hidden output can restore from a
snapshot. This removes the ownership and eager-interest races formerly
listed in Known limits item 6 without weakening parked side effects. The
remaining transient-visibility concern was checked separately: bind and
reconnect reports read `TerminalPane`'s synchronously refreshed
`isVisible && isWorktreeActive` ref, the global effect uses the same
expression, and owner counting prevents a retiring pane from overriding its
replacement. No hidden-worktree `visible=true` report site remains.
2. **A natural/synthetic daemon exit could overtake final output.** When a
shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event
directly. The final bytes could therefore arrive after `exit`. Exit is now an
ordered control event in `DaemonStreamDataBatcher`; both natural and
synthetic exits flush through the same FIFO. A deep-socket regression test
pins final-data-before-exit ordering.
3. **Keep-tail thinning could permanently reduce scrollback to the retained
tail.** On `dataGap`, main discarded its headless model, then rebuilt it from
later tail bytes even though the daemon still owned the complete model. Live
daemon snapshots now carry `outputSequence`; the provider exposes an
authoritative `getBufferSnapshot`, accepts the requested scrollback depth,
and main requires that provider snapshot after a gap. Reconciliation starts
in the pre-snapshot absolute sequence domain and runtime sequence accounting
advances across dropped bytes. The daemon remains the source of complete
scrollback instead of making a transport optimization destructive. If that
authoritative RPC is temporarily unavailable, main now returns no snapshot
and lets the renderer retry; it never paints main's known-incomplete tail as
a full recovery.
4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA
bytes salvaged from a dropped region are copies of bytes already counted by
the daemon, not new output. Stream events now distinguish delivered text
from `sequenceChars`; salvaged query data advances by zero while the gap
advances by the original characters. Main can still parse/deliver the query
copy without shifting every later snapshot baseline.
5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK
credit fired when bytes entered `terminal.write`, before xterm's callback.
Split scheduler chunks also attached `onParsed` to the first slice. ACK
credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the
final xterm parse callback, and is released exactly once on throw, discard,
or terminal disposal. Submitted-but-unparsed credit is retained until parse
or disposal, so main's flow-control window measures parser work rather than
renderer submission.
6. **Hidden restore ignored configured scrollback.** The renderer always asked
for 5,000 rows, so users configured for 10k–50k silently lost older history
on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback
option and clamps it through the shared 0–50,000 policy
(`terminal-hidden-restore-scrollback.ts`).
7. **Active alternate-screen snapshots discarded the normal shell buffer.**
SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old
normalization sliced away everything before the last `?1049h`. A restored
TUI looked correct until it exited alternate mode, then returned to empty
history. Snapshots now carry the normal buffer separately in
`scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose
both buffers; an already-alt renderer exits alt, clears/rebuilds the normal
buffer, then re-enters and rebuilds alt. History replay also composes both
buffers, including legacy empty-field compatibility.
Deep fuzz then found a second two-buffer issue: normal-buffer serialization
can leave its SGR pen active while the separately serialized alternate body
assumes default SGR. The rehydrate boundary now emits `SGR 0` before
`?1049h`, preventing a shell color from tinting restored TUI cells. The
regression proves the TUI is visible immediately and `?1049l` returns to the
original shell history.
8. **Daemon provider wrappers forwarded only part of the recovery contract.**
A preserved current/legacy daemon could emit `dataGap` through a provider
wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while
the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap
events, snapshots, and explicit `sequenceChars`. Both wrappers now route the
complete contract to the provider that owns the session, including requested
50,000-row recovery and zero-advance query-salvage events.
9. **Sequence-safe recovery was added without advancing the daemon protocol.**
An already-running v19 daemon could accept background-thinning hints but
could not return the new `outputSequence`, making any resulting gap
impossible to reconcile safely. The authoritative snapshot contract is now
protocol v20. Preserved v19 sessions remain live but are explicitly marked
unthinned; their stale background hint is cleared on the ordered control
socket before `createOrAttach`, while fresh v20 sessions retain keep-tail
performance and full-model recovery.
### Static audit conclusions
- Model query authority still captures ownership synchronously at ingestion;
seed/hydration/snapshot writes remain reply-silent, remote view subscribers
retain view authority, and replies use the provider input path (including
daemon shell-ready queuing and SSH routing).
- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared
by the centralized PTY teardown path. Parked watcher timers, byte sidecars,
fact consumers, exit subscriptions, hidden claims, and runtime-title slots
dispose on reveal/exit/worktree shutdown.
- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden
panes still have a main-owned headless model, so mounted hidden-gate restore
is valid; live remote viewers veto daemon background thinning. The new
two-buffer payload is recomposed before mobile/remote snapshot frames.
- Wake recovery keeps its focus/visibility/system-resume listener symmetry and
cancels its settled animation frame on cleanup. No timeout was added that
mutates ACK or delivery counters.
- The one documented upstream SerializeAddon null-cell/wrapped-row defect
remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row
predicate as fidelity fuzz, rather than misclassifying that known serializer
defect as sequence-reconciliation loss.
### Validation evidence
- Focused ownership/connection/dispatcher tests: 422 passed.
- Daemon server/batcher/order tests passed, including deep queued-socket exit.
- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale
mocks were updated to assert the new explicit `sequenceChars` argument; the
production behavior was already correct.
- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed.
- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed.
- Post-protocol completion sweep: 1,106 affected main/daemon tests passed;
484 renderer/restore tests passed with 2 expected skips.
- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`.
- Required hidden-view parking, parked-memory, and sleep/wake E2E trio:
7 passed on a fresh v20 Electron build, including byte-identical output
across 25 park/reveal cycles.
- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s).
- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It
reproducibly found the SGR boundary bug at seed 16 and the known upstream
wrapped-null-cell case at seed 1221 before the final green run.
- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and
remote incomplete-escape regression suites passed.
- Fullscreen real-app headful flow: a real shell wrote normal history, entered
a TUI while its worktree was hidden, restored on reveal, then exited with
`?1049l` back to the original history. The BrowserWindow was fullscreen;
no click/focus occurred before evidence; the restored frame settled for
1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63;
`fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap
11px (the scrollbar/remainder, with grid and proposed dimensions equal).
Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and
`.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`.
- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s
(986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms,
DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39,
reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result:
`tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`.
- `pnpm typecheck`, oxlint on every touched TypeScript file,
`pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production
build all passed. No max-lines bypass was added.
-152
View File
@@ -1,152 +0,0 @@
# Orca Serve Terminal Persistence
## Problem
`orca serve` exposes terminal tabs to paired web clients through the runtime
`session.tabs` API, but the host-side terminal tab registry was process-local.
When the host published an empty session-tabs snapshot for a worktree, the web
client bootstrapped a new terminal, giving the user a fresh shell instead of
the previously running host session.
The browser must remain stateless for terminal identity. Browser storage is
intentionally sanitized because remote handles become stale after a new pairing
or host restart.
## Goals
- Keep the host runtime as the source of truth for paired web terminal tabs.
- Persist `orca serve` terminal tab, leaf, and PTY/session bindings in the
existing workspace session model.
- Hydrate headless `session.tabs` snapshots from host persistence before a web
client decides a worktree has no terminals.
- Mirror the SSH persistence model where it applies, while keeping SSH relay
leases and local serve persistence behind their own provider checks.
- Preserve split-pane identity by routing all activation and attachment through
parent tab id plus leaf id.
## Non-Goals
- Do not persist remote handles in browser local storage.
- Do not make browser panes supported in headless `orca serve`.
- Do not redesign the terminal daemon or SSH relay.
- Do not treat a persisted PTY id alone as proof that a live process belongs to
a pane.
## Design
### 1. Persist Runtime-Owned Serve Spawns
The runtime PTY spawn path accepts a main-only `persistHostSessionBinding` flag.
Headless serve sets it when creating session-tab terminals. The PTY handler
then calls `Store.persistPtyBinding` only after validating `worktreeId`,
`tabId`, and stable `leafId`.
This keeps unrelated renderer-local PTY spawns from writing workspace-session
terminal bindings.
### 2. Use Stable Session IDs
Serve-created terminals pass `tabId`, `leafId`, optional `sessionId`, and
`persistHostSessionBinding` into `ptyController.spawn`.
If a pending hydrated terminal has a persisted PTY/session id, activation
passes that id back to the provider. New serve-owned local sessions use a
nonnumeric `serve-${uuid}` id so they cannot collide with older numeric PTY ids
after restart.
### 3. Hydrate Headless Snapshots
Before `list`, `listAll`, subscribe initial emission, activation, close, or
move returns an empty headless state, the runtime hydrates
`mobileSessionTabsByWorktree` from `workspaceSession.tabsByWorktree` and
`terminalLayoutsByTabId`.
Hydration preserves:
- parent terminal tab id
- stable leaf id
- title fields
- active tab and active leaf
- split layout and `ptyIdsByLeafId`
- persisted PTY/session id
Legacy terminal tabs without layout entries are still hydrated using a
deterministic stable leaf id derived from the parent tab id.
### 4. Materialize Pending Tabs On Activation
Hydrated terminal surfaces with no live trusted handle are exposed as
`pending-handle`. Activating one in headless serve materializes the exact
parent tab and leaf on the server, then returns a ready terminal surface.
Explicit leaf activation is exact. If `leafId` is provided and the requested
leaf is missing, the runtime returns `tab_not_found` instead of falling back to
a sibling.
### 5. Require Trusted PTY Identity
For headless-hydrated persisted tabs, a live PTY is safe to expose only when
the runtime record already matches the same worktree, parent tab id, and pane
key. A process-list entry with the same PTY id but no pane identity stays
pending, preventing stale or numeric id collisions from attaching the wrong
terminal.
Renderer-published authoritative session snapshots retain their existing
worktree-only daemon PTY adoption path.
### 6. Close And Move Without A Renderer
Headless close and move have no-renderer mutation paths:
- close hydrates and refreshes first, removes the persisted terminal tab and
layout, updates active pointers, emits a new snapshot, and kills every live
trusted leaf under the closed parent tab
- move updates in-memory and persisted tab order without changing PTY bindings
This prevents closed or reordered tabs from reverting on reconnect.
### 7. Renderer Guards
The web client still drops remote terminal identity from browser storage. It
mirrors the host snapshot and activates pending host mirrors through
`session.tabs.activate`.
Bootstrap of a default web terminal is allowed only for a fresh empty snapshot
for the active worktree when no local terminal state already exists. Stale empty
snapshots and fresh empty snapshots racing with staged local terminals do not
create duplicates.
### 8. SSH Parity
Runtime-owned SSH spawns record remote PTY leases with target-local relay PTY
ids while workspace-session PTY bindings keep app-facing ids. Lease writes are
deferred until after binding persistence succeeds for persisted runtime-owned
spawns, so a failed binding save cannot leave durable SSH lease metadata for a
tab/leaf that was not saved.
## Edge Cases Covered
- Browser reload or WebSocket reconnect mirrors host-owned terminal tabs.
- Pending headless terminal activation reuses persisted tab and leaf identity.
- Split panes attach and activate the requested leaf only.
- Removed split leaves fail fast even when siblings remain.
- Legacy tabs without layouts hydrate instead of appearing empty.
- Stale empty snapshots do not bootstrap duplicate terminals.
- Fresh empty snapshots do not bootstrap when local terminal state already
exists.
- Numeric PTY id collisions remain pending until a safe reattach/spawn occurs.
- SSH reattach failure after binding persistence failure leaves no stale lease.
## Verification Plan
- Unit-test runtime hydration, activation, close, move, split-leaf exactness,
stale PTY id handling, and legacy no-layout tabs.
- Unit-test PTY persistence gates, local session-id reattach behavior, SSH
lease parity, and persistence failure cleanup.
- Unit-test renderer snapshot bootstrap guards and remote runtime PTY transport
pending-mirror behavior.
- Run adjacent session-tab RPC and web-runtime session tests.
- Run typecheck, lint, and `git diff --check`.
- Launch the Electron dev app from this worktree with an isolated profile and
verify CDP attachment, app identity, store availability, visible boot, and
zero console errors.
-583
View File
@@ -1,583 +0,0 @@
# Terminal Performance Initiative
Working plan for the `orca-performance` branch. Goal: make Orca's terminal as
performant as the architecture allows, with every claim backed by a number.
Started 2026-07-02.
## Why (user-reported, from the team meeting)
1. Typing in the terminal is sometimes laggy — occasionally seconds of delay.
2. Users say the terminal is slower than iTerm (unclear if typing or scrolling).
3. Scrolling in Claude Code / OpenCode is slow.
4. Idle memory is high (1–2 GB).
5. Battery usage is high.
Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling
latency down significantly; lower memory with 0–1 agents.
## Ground truth (verified against source, 2026-07-02)
Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified
file:line claims) — see the archived digest and the "xterm.js vs Ghostty"
deep-dive. The Orca-specific findings below were re-verified against this
repo's code:
- **Electron main sits on every terminal byte's path** (daemon → main →
renderer). VS Code ships the same xterm.js but bypasses main entirely: its
ptyHost is a UtilityProcess with a direct MessagePort to each renderer.
- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in
both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery
is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can
grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k
unacked chars (kernel backpressure blocks the shell).
- Renderer terminals share one thread with the entire React app; xterm.js
parses in 12 ms slices at a documented 5–35 MB/s ceiling.
- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`),
5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines)
reflow on column resize.
- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms
(same-library reference), native class 5–10 ms. Realistic target: beat
VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99
dominates perception).
## Current state
Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`):
1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig
(see Benchmark protocol below).
2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards,
probe-certified replay release) + #7139 (cooperative drain: paced backlog
draining keeps typing responsive under floods). Post-merge on this base:
`pnpm typecheck` clean, 626 targeted tests green (scheduler, guards,
pty/pty-connection/pty-transport suites). #7153 itself is a disposable
testing PR; #7139 and #7150 land separately on main.
## Workstreams
### 1. Baseline benchmarks (now; human-in-terminal required)
Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty,
Terminal.app, VS Code (T3Code if available):
```
node tools/benchmarks/terminal-pipeline-bench.mjs --label <machine>-<date>
node tools/benchmarks/terminal-pipeline-bench.mjs report
```
These numbers answer "are we actually slower than iTerm, and where," and are
the before/after for everything below.
### 2. Validate #7153 on orca-performance (this week, extended testing)
Watch for: typing responsiveness under agent floods, bounded memory,
skip-notice + snapshot repaint on overflow, no permanent input loss. When
validated, land #7139 and #7150 as separate PRs on main.
### 3. Revive term-speed-2 (the headline structural work)
History: nwparker's ~38-branch chain (+20k lines) implementing the terminal
model/view contract — hidden view parking, hidden delivery gate, side-effect
authority in main, model query authority, skip-grammar deletion — all
kill-switched, documented in
`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`.
It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was
never rejected and never reached main. Directly targets complaints 3–5
(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases).
Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34
conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16),
`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5).
`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the
chain touch different layers; runtime interaction (drain pacing × hidden
gate) still needs deliberate testing.
Execution: dedicated focused session; resolve on `revive/term-speed-2` off
orca-performance; keep both sides' kill switches; validate with typecheck +
the contract tests listed in the model-view-contract doc + #7153's suites;
merge back to orca-performance for extended testing. Estimated ~1 day of
careful resolution + validation.
### 4. Remaining stall-bug fixes (parallel, independently shippable)
The "seconds of delay" class = discrete thread-blocking events, not
steady-state latency:
- PR #7105 (open): skip synchronous cold-restore replay for live daemon
sessions in doSpawn.
- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback
rows (#5096 follow-up, never done). Call sites include the mobile snapshot
path (`pty-connection.ts:2861`) and sleep/hibernate serialization.
- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped
buffers (repro harness exists).
- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s
tick; small, part of the same program).
### 5. Producer-side PTY flow control
Ack-driven pause/resume of the actual PTY through the daemon protocol
(node-pty supports it), watermarks per the xterm.js flow-control guide
(≤500 KB). Converts flood-induced buffered lag into shell blocking — the
correct physics. Sequence after #7139 lands (interacts with its drain pacing).
Design (2026-07-03, implement after the term-speed-2 revival merges —
same files):
- Signal source: main already tracks per-pty pending + in-flight
(`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a
pty's pending exceeds HIGH (256 KB), main asks the producer to pause;
below LOW (32 KB), resume.
- Producer side: two new protocol notifications (`pausePty`/`resumePty`,
protocol vNext, version-gated like `supportsIncrementalCheckpoints`);
daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the
pty fd, kernel buffer fills, the shell blocks on write: true kernel
backpressure, identical physics to VS Code's 100k/5k design.
`LocalPtyProvider` calls pause/resume directly.
- Safety invariants: (1) failsafe auto-resume after 5 s regardless of
watermark, so a lost resume can never wedge a shell; (2) resume on
detach/exit/kill/daemon-reconnect; (3) pause must not suppress the
interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the
HIGH watermark is only reachable during genuine floods where echo is
already queued; (4) PTY reads never stop for model/tail ingestion
(term-speed-2 invariant #1) — pause gates the fd read, so daemon-side
emulator state pauses with it, which is correct (state = what was read).
- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit
cleanup, plus an e2e pressure scenario asserting bounded main memory and
a blocked producer (`yes` exits promptly on SIGINT while paused).
### 6. Extend the measurement rig
- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops
at the parser reply, before paint).
- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling
at 0/1/5 agents (goal-3 metric).
- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in
main/daemon/renderer behind a debug flag for pipeline attribution.
### 7. utilityProcess terminal router (structural endgame; gated on data)
An Electron UtilityProcess owns the daemon socket and hands each renderer a
MessagePort — VS Code's topology while keeping Orca's detached daemon (warm
reattach). Takes main off the terminal data path entirely; daemon-side
history persistence falls out naturally. Prototype only after baselines show
how much tail latency lives in the main hop.
### 8. Production P90 telemetry
Sampled keypress→echo latency + long-task/stall counts from real users;
defines the success criterion and becomes the permanent regression gate.
Design after the local rig stabilizes so the metrics match.
## Benchmark protocol
`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any
terminal:
- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only
after the parser reaches the query, so it proxies the input pipeline
without keystroke injection.
- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`,
`agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints —
and labeled-pathological `styles-stress`), each run ended by a DSR fence so
xterm.js-class ingest queues can't flatter the result.
- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI
stream: "typing while the agent works," quantified.
Rules: same machine, AC power, comparable window size, no tmux/screen, hands
off the keyboard during runs. Never compare numbers across machines.
## Sequencing
```
now: [1] baselines [2] #7153 testing (parallel)
next: [3] term-speed-2 revival (dedicated session)
parallel: [4] stall fixes, [6] rig extensions
after 2/3: [5] flow control
gated: [7] utility router [8] telemetry
```
BMW-group crash work remains the team's priority gate above all of this
(#7150's wedge guards overlap it); this plan runs measurement and revival
prep in parallel without displacing it.
## Findings log
### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/)
Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty
1.3.1; iTerm2 not installed, VS Code pending):
| metric | Orca prod | Terminal.app | Ghostty |
|---|---|---|---|
| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 |
| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 |
| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 |
| ascii-log fenced throughput | 13 MB/s | 39 | 93 |
Decomposition of the 51× agent-tui gap — both pipeline ends are fast:
- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s**
(`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem.
- Daemon `Session` ingest (emulator + pending-output recording + fanout):
agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`,
`ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem.
Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms,
so the utilityProcess router is deprioritized by data; (2) the crisis is
queueing between daemon egress and renderer parse completion — main
per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after
renderer write callbacks, so renderer slowness throttles delivery
multiplicatively), and renderer per-chunk layers above xterm; (3) the
agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain
text inside Orca while being equal-cost everywhere else — profile it in the
renderer first (task #9).
### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only)
Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode
overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** —
the cooperative-drain branch does not move the under-load class. In
hindsight this is structural: DSR replies are ordered within the output
stream, so the metric measures output-queue depth; #7139 paces draining to
protect input-send responsiveness but cannot reorder the queue. Implications:
(1) the 134 ms-class number is fixed only by shrinking the queue (producer
flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's
own wins (freeze class, bounded memory, input-loss guards) must be validated
with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs
are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom.
### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9)
The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained
at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background)
regardless of parse speed. Isolation bench (fake timers, instant-parse
terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`,
`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching
prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only
when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the
sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's
`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks
to the queue, so floods always ride the drip.
Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** —
a pacer re-arms a 0ms drain when xterm's write callback confirms the batch
parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling:
27 → **117.6 MB/s** (parse-limited). Background cadence deliberately
unchanged (protects the focused pane; hidden panes are term-speed-2's job).
`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of
#7139 preserved; its budget-yield test still passes). 621 tests green.
Open follow-ups from this attribution: (a) end-to-end dev verification (in
progress); (b) whether main's `background:true` delivery marking demotes
visible-pane floods to the background drip — check
`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log
gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect
scanning; profile after (a).
### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk
Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered
probes (renderer scheduler counters → main whole-method timer → per-section
timers → targeted micro-benches) attributed it fully:
- Renderer receives only ~350–770 KB/s — it is **starved**, not slow.
- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the
flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk).
- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless
track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the
pty-record tail block.
- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame
containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32×
the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer`
materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and
`finalizeRetainedTerminalRows` allocates them all again plus runs a
trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes
per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code
frame (cursor-up + erase-below) takes this path; plain logs don't — which
is exactly the measured agent-tui vs ascii asymmetry.
Chain: TUI flood → O(tail) work per chunk in main → main event loop
saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s →
deep queue → 134 ms DSR-under-load.
Fix (in progress): run the existing algorithm on a lazy suffix window (the
cursor's maximum upward reach, computed from the chunk) with the untouched
prefix shared by reference; differential fuzz test proves output equality
against the original implementation. Worst case (pathological full-height
cursor-up) falls back to today's cost.
### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued
Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same
protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under-
load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the
first time, but the pipeline is still far from the renderer's 27–117 MB/s
capacity — another main-side consumer remains hot.
Next cycle (exact recipe): re-apply the whole-method main probe
(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed
build. If onPtyData still dominates, the remaining O(tail)/per-chunk
suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full
tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in
dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured);
(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If
onPtyData no longer dominates, probe the main→renderer delivery batching
next. The probe/bench cycle is mechanical: relaunch dev
(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command
"<bench> --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log.
### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale
Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still
~90% of main's event loop (~930 ms/s). Bucket split per second:
**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed
fix confirmed), normalize ≈ 7, preview ≈ 0.
Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its
callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText`
joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both
(another ~512KB of string allocation per chunk) and runs multi-pattern
blocked/ready scans (`findTerminalWaitBlockedSignal`,
`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text)
— all to timestamp `waitBlockedAt` for `terminal wait`.
Fix design (next session): blocked/ready prompts are end-anchored — an
actionable prompt is at the END of output. (1) Run the check on a bounded
suffix of the wait text (last ~64 lines / 16KB) instead of the full tail;
(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small
carry for split keywords) can contain a blocked keyword; (3) build the two
wait texts only when the check runs. Verification mirrors the windowed-tail
pattern: keep the full-text check as reference + differential fuzz over
randomized tails/prompts (split-across-chunks cases included — the
`appendCandidateSignal` ordering semantics at :23146 must be preserved),
plus the terminal-wait contract tests. Expected effect: removes ~85% of
remaining onPtyData cost; combined with the two landed fixes should
finally unlock the pipeline toward the renderer's measured 27–117 MB/s.
### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency
Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243,
windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label
dev-blockedfix, same protocol/config as prior dev rows:
| metric | pre-fix dev | +tail fix | +blocked fix |
|---|---|---|---|
| agent-tui MB/s | 0.7 | 1.0 | **11.5** |
| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** |
| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** |
| ascii-log MB/s | 6.4 | 4.7 | **9.6** |
The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The
throttled blocked-check delivered the predicted ~85% cut. Dev mode carries
~2× overhead vs prod, so the prod build should land near ~10ms DSR-under-
load — from the 134ms baseline (~13×) — pending a packaged-build rerun.
Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain
ticks + xterm 12ms slices), which flow control (#6) does not target;
re-evaluate the "within 10× of Terminal.app" goal line after a prod
measurement. Next: term-speed-2 revival (#4), then flow control (#6).
### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate)
`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files,
typecheck clean, ~2,776 targeted tests green, all three of our fixes
verified present, chain features present and kill-switched (subagent's
six review risks recorded in its report). Bench verdict on the revived
build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35%
unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round
proved the kill switches are NOT the cost) and idle p50 doubled.
Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s
≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon
CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×)
on the revive branch** (`session-ingest-throughput.bench.test.ts`,
ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator
restructure (scanner classes / query-reply forwarding / view-attribute
responder) added per-byte cost to the daemon hot path. Chunks reaching
main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger).
NEXT (fast inner loop — pure unit bench, no app restarts): on
revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs
7839fb9db, find the per-chunk scanner cost, restore our bounded-parser
fast paths (the daemon emulator must never pay per-byte JS scanning for
bytes that contain no ESC — same pre-filter pattern as the blocked-check
keyword bypass), verify with the ingest bench back at ~100 MB/s, then
full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to
orca-performance. A residual renderer-side share is possible once the
daemon is fixed — re-attribute after.
Merge gate: revive branch merges only at ≥ blockedfix numbers.
**RETRACTION (2026-07-03, later):** the daemon conviction above was a
confounded measurement — the 39–48 MB/s ingest runs executed while a dev
app was still running. On a quiet machine the revive branch ingests at
**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s
vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end
revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs
were load-controlled, and unit benches show up to 2.6× machine-load
variance. Scanner pre-filters landed anyway on revive (71c89da9b;
strictly positive, 641 daemon tests green).
**New measurement protocol (mandatory from here):** quiet machine (no dev
apps or benches concurrent), paired A/B runs back-to-back alternating
branches, n≥2 per side, report spread not just p50. The merge-gate
comparison (blockedfix vs revive) must be redone under this protocol
before any verdict. Next: run the controlled A/B; if the delta
disappears, merge revive into orca-performance and proceed to flow
control (#6); if it persists, resume attribution renderer-side (probe
pty-connection dataCallback additions per chunk).
### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance
Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui +
DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive
6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50
tied, p99 better on revive, throughput within overlapping noise (revive2's
3.6 followed two runtime-busy create failures). The earlier "35%
regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs
11.5 yesterday — dev benches carry ~2x day-to-day machine variance;
absolute dev numbers are only comparable within one A/B session.
Merged revive/term-speed-2 → orca-performance; typecheck clean, 288
post-merge spot tests green. orca-performance now = main-ish base + #7153
+ three perf fixes + full term-speed-2 chain (kill-switched, default ON)
+ scanner pre-filters. Extended user testing now covers everything.
Remaining from the revival agent's risk list: gate×drain e2e specs
(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them.
Next: producer flow control (#6) per design §5; prod packaged-build bench
for the real headline numbers.
### 2026-07-03 — flow control merged; goal-state accounting
Producer flow control merged to orca-performance (348aeb325): protocol
v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData,
node-pty kernel backpressure, 5s daemon-side lost-resume failsafe +
main-side pause re-assert, resume on every teardown path, version-gated
(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED`
(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests
green.
**Definition-of-done accounting:**
- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s
and DSR-load p50 161→18.8 dev, results committed).
- term-speed-2: REVIVED AND MERGED (A/B gate passed).
- Flow control: IMPLEMENTED AND MERGED.
- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged
RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod
projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The
remaining gap is structural cadence (daemon 8ms batch, renderer drain
ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the
waste class this initiative eliminated. Prod verification path:
electron-vite preview CANNOT host the bench (CLI-created panes are not
adopted by the preview window's renderer → no ACKs → pending-cap drop;
two attempts, documented) — measure on the next packaged RC cut from
orca-performance using the committed rig + protocol instead.
**Deferred, ordered:** (1) sync orca-performance with main — conflicts
incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era
`Metadata=12`; renumber chain side, audit mobile/web stream consumers);
(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) —
gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig
extensions + P90 telemetry (tasks #3/#8).
### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers)
Same rig, same protocol, same machine as the 1.4.91 baseline:
| metric | 1.4.91 baseline | v1.4.121-rc.0 | change |
|---|---|---|---|
| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) |
| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x |
| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x |
| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x |
| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x |
| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x |
| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x |
Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at
a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse
CPU (prod ≈ dev for both latency and throughput; the pipeline is
cadence-bound, so faster prod code changes nothing). That uniform cap
also explains plain-text dipping slightly below baseline: ascii/cjk used
to run unpaced ahead of the old scheduler; now everything flows through
the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app
under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at
parity. The remaining 4x is the named cadence stack (daemon 8 ms batch,
scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) —
next lever, tunable, tracked as follow-up. p99 tail (the freeze class)
is 29.7 ms — users cannot perceive it.
Caveat: measured on the user's live app (this session active in it);
idle p99 118 ms reflects that activity, not the terminal path.
### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig)
| metric | Orca v1.4.121-rc.0 | VS Code | verdict |
|---|---|---|---|
| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster |
| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster |
| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail |
| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% |
| cjk-emoji | 12.2 | 11.3 | tie |
| agent-tui | 11.2 | 11.7 | tie |
| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x |
Orca now beats or ties the best-known xterm.js terminal on 5 of 6
metrics — including 16x at idle (what users feel all day) and 5x on
SGR-heavy output — and holds a better p99 tail under load. Throughput
sits at the shared engine ceiling (~9-12 MB/s), confirming the class
limit.
The one loss (load p50) has a clean mechanism: VS Code's producer flow
control caps unacked output at ~100KB, so its standing queue is
~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue
(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window
(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested
setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel
drain scheduling (sub-ms re-arm; also raises the throughput ceiling);
(2) tighter effective in-flight window on the renderer delivery path.
Target: VS Code's ~7ms class or below without giving back throughput.
### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms
Lever results (dev, 3MB protocol, same session):
- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the
~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util)
there is no standing queue. Kept (correct, removes a real clamp).
- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon
STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS):
**p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1).
Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239
batcher+pty tests green after timing updates.
Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should
land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining
fixed-latency terms are renderer/xterm-internal (12ms parse slices).
Note: main's interactive bypass (input-gated) means real keystroke echo
skips batching entirely — the DSR metric understates real typing
responsiveness; VS Code measured on the same freight path, comparison
fair.
Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect
~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in
reach).
### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green
terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/
title side effects live while parked), terminal-sleep-wake-restore
(output restored + input accepted after wake), terminal-parked-memory
(renderer memory released on park; views retained when kill-switched
off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay —
the revival's top flagged risk — now has e2e coverage on the exact
branch the RC ships from. Remaining garble-hardening: differential
hide/reveal fuzz harness (next build), reveal-time seq diagnostics.
## Success criteria (baseline-relative; finalize after task 1)
- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same
box; zero DSR timeouts (today's freeze class).
- Fenced agent-tui throughput ≥ VS Code on the same box.
- Idle RSS with 0–1 agents materially down (target set after the memory
harness lands; hidden-pane parking is the main lever).
- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood.
- Production P90 typing latency down and monitored continuously.
-306
View File
@@ -1,306 +0,0 @@
# Windows Performance Investigation — Progress Log
Goal: (1) significantly improve Windows startup time (~1 min cold start reported),
(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance.
All changes must be proven with before/after benchmark numbers.
## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency
Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes.
Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase
timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture).
Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines
(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn).
Findings (baseline, this machine):
- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it —
~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts
admitted this). Premise (16-context budget) stale since #7064 raised budget to 128.
- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon
provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new
pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first
ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal.
- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs
scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear +
refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts
recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers
risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) —
left as follow-up.
- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system
ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping
were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the
previous dispose-on-hide behavior.
- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
first user terminal doesn't pay the ~2.7s first-ConPTY cost.
Follow-ups (documented, not in this branch):
- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual
tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback
when column count changed while hidden.
- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons
+ synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF,
pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes.
- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is
Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty
→ rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at
that renderer connect layer, not in reconnectPersistedTerminals.
- First terminal opened immediately after launch also waits on the one-time daemon-init
barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured
preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays
it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain
existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it
does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize
on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing
behind the in-flight warm-up, not unwarmed cost.
- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn
storms serializing the daemon loop.
Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim
PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts).
## Status
- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`)
- [x] Startup bottleneck FIXED + verified: **19.31s → 1.80s median** (fixture);
real-world profile was 62s of blocked main thread → now 0 icacls spawns steady-state
- [x] OpenCode freeze ROOT CAUSE found + fixed: MessagePart hook flood (see F5/D2).
Benchmark: 22.9 MB / 540 ms / 400 main-process fanouts per turn → 469 KB / 79 ms / 120
(legacy vs throttled plugin behavior through the real hook HTTP pipeline)
- [x] General Windows sync-work audit (results below); audit item #2 (readHooksJson per
status IPC) investigated and found NOT hot — renderer barely calls those handlers.
Fixed pre-existing Windows-only test failures (hydrate-shell-path delimiter).
- [x] Windows ConPTY e2e perf validation (F7 below)
## Key facts / environment
- Branch: `Jinwoo-H/windows-launch-time`
- Electron app, entry: `src/main/index.ts` (~1557 lines)
- Existing startup diagnostics: `ORCA_STARTUP_DIAGNOSTICS=1` writes `[startup] <event>` lines to stderr
(`src/main/startup/startup-diagnostics.ts`)
- Prior art: PR #4618 "perf: speed up desktop startup", #5011 "stop main-thread PowerShell ACL storm
on env-store reads", #4526 "Avoid OpenCode config cleanup freezes on Windows", b240d5eee
"Measure startup hydration phases"
## Follow-ups / known issues (out of scope for this branch)
- Pre-existing Windows-only unit test failures: `daemon-pty-adapter.test.ts` (61) and
`history-manager.test.ts` (3, chmod-based fs-error simulation is a no-op on Windows).
Identical with/without this branch's changes. CI never sees them (ubuntu-only).
- Consider a Windows CI lane for the terminal-perf e2e suite (F6/F7) and these unit suites.
- Typing-latency load-sensitivity (F7): possible deeper work on daemon checkpoint
scheduling/priority if user reports persist after D3.
- Audit leftovers (F4): non-recursive `grantDirAcl` execFileSync on hook install
(installer-utils.ts:210) could be async; readHooksJson caching unnecessary (not hot).
### D3 — Async checkpoint writes (implemented)
`HistoryManager.checkpoint` (every ~5s per dirty session, Electron main process) switched
from writeFileSync+renameSync (~1MB snapshot JSON, inflated by Defender on Windows) to
fs.promises with the same tmp+rename atomicity; ordering preserved by the adapter's
checkpointInFlight guard.
## Suspects (startup)
1. **`grantDirAcl(userData, { recursive: true })`** — `src/main/index.ts:517-523`, win32 only,
runs **synchronously on the main process inside `openMainWindow()` before window creation**.
Spawns `icacls <userData> /grant:r <user>:(OI)(CI)(F) /T /C` with a **60s timeout**.
The comment itself admits large userData dirs (tens of thousands of Chromium cache files)
can take >10s. This blocks first paint for the whole walk. Matches "1 minute launch" and
"Windows only".
2. Windows Defender real-time scan of exe/asar/native modules on cold start (environmental,
can't fix in code, but reducing file count / sync IO helps).
3. TBD: store sync load, daemon init, i18n init, sherpa-onnx native module load.
### F3 — Baseline benchmark (2026-06-10)
Harness: `node tools/benchmarks/startup-time-bench.mjs --label baseline --iterations 3 --files 28000`
(28k-file synthetic Chromium-cache-shaped userData fixture in %TEMP%, headless launch of
the electron-vite build with `ORCA_STARTUP_DIAGNOSTICS=1`, milestones parsed from stderr).
| phase (median of 3) | baseline |
|---|---|
| spawnToAppReady | 857ms |
| appReadyToServices | 178ms |
| servicesToI18n | 2ms |
| i18nToOpenWindow | 7ms |
| **aclGrantMs** | **15.65s** |
| windowCreatedToLoaded | 1.06s |
| **totalToDidFinishLoad** | **19.31s** |
ACL walk = 81% of total. (Fixture is kinder than the real profile: same file count but
freshly-written small files → real %APPDATA%\Orca measured 62s for the same command.)
JSON: tools/benchmarks/results/startup-baseline-2026-06-10T19-36-01-305Z.json
### F4 — Sync main-thread audit (subagent, 2026-06-10)
Ranked offenders beyond the ACL grant (#1):
2. `readHooksJson` + JSON.parse re-read per agent-status IPC call across ~10 hook services
(`src/main/*/hook-service.ts` via `agent-hooks/installer-utils.ts:50`) — 10-100ms per
status snapshot, all platforms. Remediation: in-memory cache.
3. `whoami.exe` SID resolution (win32-utils.ts:92) — already cached, OK.
4. macOS-only `defaults read` per browser probe — not Windows.
5. `installer-utils.ts:210` non-recursive grantDirAcl on hook install (execFileSync,
500ms-2s) — infrequent write path, low priority.
6. `secure-file.ts` sync PowerShell on credential write path — by design (#5011), leave.
## Suspects (OpenCode freeze)
- User report: UI freezes ~5s after sending prompt; OpenCode session itself continues fine
(visible from external terminal). So the agent process is healthy — the freeze is in Orca's
main process or renderer. Spinner in left panel still animates (= renderer compositor alive?
or just that one timer). Need to find sync main-process work triggered by OpenCode activity.
- Prior fix #4526 "Avoid OpenCode config cleanup freezes on Windows" — re-check that path.
### Research results (subagent, 2026-06-10) — ranked candidates
1. **ConPTY output flood vs PTY batching/backpressure** (HIGH): Windows ConPTY re-renders
full TUI frames → 10-100x output volume vs macOS. Batching in `src/main/ipc/pty.ts`
(16KB chunks / 8ms flush, 512KB renderer in-flight window). If renderer xterm.write is
slow, ACKs stall → in-flight fills → main stalls. Tests: terminal-foreground-redraw-freeze,
artificial-opencode-terminal-load e2e.
2. **Sync `runtime.onPtyData` per data event before batching** (MED-HIGH):
`src/main/ipc/pty.ts:1376-1430` → `orca-runtime.ts:3256-3420`: normalizeTerminalChunk +
tail-buffer append + agent-status OSC parsing run synchronously per chunk on main.
Daemon PTY path. High event rate × per-event cost can saturate the main loop.
3. **`mirrorUserConfig` recursive fs work in `buildPtyEnv` on PTY spawn** (MED):
`src/main/opencode/hook-service.ts:359-524` + `pty/overlay-mirror.ts:63-110` —
readdir/safeRemoveTree/symlinks on main thread at spawn; #4526 fixed only clearPty side.
Timing mismatch with "5s after prompt" though.
4. Agent-status event fan-out per OSC title (LOW-MED). 5. Tail-buffer O(n²) (LOW).
Gap in coverage: no test exercises rapid continuous ConPTY-scale data + sync onPtyData
accumulation on Windows.
### F5 — ROOT CAUSE (2026-06-10): OpenCode MessagePart hook flood
Eliminated candidates first: ran `terminal-foreground-redraw-freeze.spec.ts` on THIS Windows
machine (real ConPTY + daemon provider) — passes; renderer output scheduler protections hold.
The raw TUI-output-flood theory doesn't explain an OpenCode-specific permanent freeze.
The actual mechanism (src/main/opencode/hook-service.ts plugin source):
- OpenCode publishes `message.part.updated` with the FULL accumulated text of the part on
every streamed append (architecture: parts are republished, not deltas).
- Orca's plugin POSTed that full text to the agent-hook server on EVERY event →
**O(n²) bytes per streaming turn**. A 120KB reply in 400 updates = ~23 MB through
loopback HTTP + main-process JSON.parse; real turns are worse (per-token updates).
- Main process spends its whole loop on HTTP receive + parse + normalize + fanout. UI symptom
matches the user report exactly: everything dead (window close needs main + renderer
round-trip), EXCEPT the sidebar agent indicator — which is the one thing fed by the very
agentStatus:set flood that's starving everything else.
- Why Windows-biased: same flood exists on macOS but combines on Windows with ConPTY
full-frame redraw volume and generally slower process IO; also Windows daemon-PTY path
adds main-process onPtyData work.
- Why "5 seconds after sending the prompt": that's when the accumulated text gets big.
- Why OpenCode keeps working: plugin POST failures are swallowed; the session is healthy.
- Downstream payloads were already bounded (prompt 200 chars, lastAssistantMessage 8000
chars via agent-status-types normalization) — the renderer wasn't the bottleneck; the
main-process ingest was.
### F7 — Windows ConPTY e2e perf validation (2026-06-10)
Ran the terminal-perf budget specs on this Windows machine (real ConPTY + daemon PTY
provider — a path CI never exercises):
- `terminal-output-scheduler.spec.ts`: PASS (all tests)
- `terminal-foreground-redraw-freeze.spec.ts`: PASS
- `terminal-typing-latency.spec.ts`: PASSES in isolation, repeatedly — median 13.6-23.1ms,
worst 34-42ms (budgets: 250ms median / 1000ms worst). Two earlier runs that exceeded the
worst-key budget (1054.9ms, 2016.1ms outlier on a single key) occurred while other heavy
tooling (vitest/tsgo/builds) ran concurrently on the machine → load-sensitivity, not a
deterministic product defect. Note the product implication: under heavy host load
(exactly what coding agents generate), a keystroke can stall >1s on Windows. Plausible
contributors for follow-up: daemon checkpoint ticks (5s interval; snapshot serialize in
daemon + sync writeFileSync of checkpoint JSON on main — daemon-pty-adapter.ts:592,
history-manager.ts:109), Defender scanning fresh build artifacts.
### F6 — Windows e2e perf coverage gap
All terminal-perf e2e specs run on ubuntu-latest in CI. Verified they DO run on a Windows
dev machine (`npx playwright test ... --project electron-headless` works locally). Consider
a Windows CI lane for the terminal-perf suite.
## OpenCode fix (D2)
1. **Plugin throttle + cap (source fix)** — `src/main/opencode/hook-service.ts`:
assistant MessagePart posts are trailing-edge coalesced to ≥250ms apart and text is
capped at 4000 chars (leading edge posts immediately so previews stay snappy; pending
snapshot flushed before SessionIdle so the done-row preview is the final message; user
prompts bypass the throttle slot). Plugin file is rewritten on every Orca-launched
OpenCode spawn, so the fix deploys to new sessions immediately.
2. **Listener-side cap (stale-plugin defense)** — `src/shared/agent-hook-listener.ts`:
OpenCode MessagePart text capped at 8000 chars at ingest (OPENCODE_HOOK_TEXT_MAX_CHARS)
so pre-fix plugins in long-running OpenCode processes can't blow up state maps.
3. **Benchmark/regression test** — `src/main/agent-hooks/opencode-message-part-flood-bench.test.ts`
drives the real hook HTTP pipeline with both behaviors. Measured on this machine:
| metric/turn | legacy plugin | throttled plugin |
|---|---|---|
| posts | 400 | 120 |
| bytes through main | 22.9 MB | 469 KB (49x less) |
| wall time | 540 ms | 79 ms |
| listener fanouts | 400 | 120 |
4. Behavioral plugin tests — `src/main/opencode/hook-plugin-message-part-throttle.test.ts`
executes the generated plugin with fake timers + stubbed fetch.
## Findings
### F1 — Recursive icacls walk is the ~1 min startup (CONFIRMED, 2026-06-10)
- This machine's real packaged-Orca userData: `%APPDATA%\Orca` = **28,650 files / 2.06 GB**
(mostly Chromium caches: Cache, Code Cache, GPUCache, blob_storage…).
- Measured the exact command Orca runs in `openMainWindow()` (src/main/index.ts:517-523):
- `icacls <userData> /grant:r <user>:(OI)(CI)(F) /T /C` → **62.0 s**
- App runs it with `execFileSync` (main thread, BLOCKING, before BrowserWindow creation)
with a **60s timeout** → every cold launch freezes ~60s, then the grant *times out and
silently fails* (execFileSync throws, caught). Users pay the full minute and get nothing.
- Non-recursive root-only grant: **4.8 s** (NTFS propagates inheritable ACE internally).
- `icacls <userData>\* /grant:r …` (immediate children, 48 entries): **4.7 s**.
- Why it exists (PR #1152): Chromium's BrowserWindow ctor resets userData DACL with
Inherit-Only ACEs → EPERM on writes in existing subdirs (codex-runtime-home, agent-hooks…).
Explicit child ACEs survive propagation. Per-write EPERM retries exist as backstop in
`codex-accounts/fs-utils.ts` + `agent-hooks/installer-utils.ts`.
- Windows ACL inheritance recalculates from the immediate parent during propagation, so
explicit ACEs on userData + immediate children are sufficient; per-file ACEs on 28k
Chromium cache files are useless work.
### F2 — Instrumentation prior art
- `ORCA_STARTUP_DIAGNOSTICS=1` → `[startup] <event>` lines on stderr (startup-diagnostics.ts).
Only 2 events exist today (single-instance lock). Commit b240d5eee (branch
perf/startup-first-window, NOT merged here) has a full StartupPhaseTimer framework —
too large to cherry-pick; adding minimal milestone logs instead.
- Hermetic benchmark launch path: `ORCA_E2E_USER_DATA_DIR=<dir>` redirects userData
(works packaged + dev), `ORCA_E2E_HEADLESS=1` keeps window hidden. Dev/preview mode
skips single-instance lock → safe alongside installed Orca.
## Decisions / fixes
### D0 — RESULTS: ACL fix benchmark (2026-06-10)
| phase (median) | baseline (3 it.) | after fix (4 it.) | steady state (3 it.) |
|---|---|---|---|
| aclGrantMs | **15.65s sync/blocking** | async (off critical path) | **0ms (marker hit)** |
| totalToWindowCreated | 18.25s | 930ms | 814ms |
| totalToDidFinishLoad | **19.31s** | **2.04s** | **1.80s** |
- First launch after fix: total 2.06s while the background grant ran 6.81s concurrently.
- Marker verified written by real icacls run; subsequent launches log `acl-grant-done
mode=marker-hit` with zero spawns.
- JSON evidence: tools/benchmarks/results/startup-{baseline,acl-fix,acl-fix-steady}-*.json
- Files: src/main/startup/windows-user-data-acl.ts (+tests), src/main/index.ts (wire-up +
startup milestones), src/main/win32-utils.ts (export identity resolver),
tools/benchmarks/startup-time-bench.mjs (harness).
### D1 — ACL grant fix (implemented as planned)
Replace the synchronous recursive walk with:
1. A persisted marker (`windows-acl-grant.json` in userData, keyed on identity + scheme
version): when present → skip everything (steady-state launches: 0 icacls spawns, 0 ms).
2. When marker missing (first launch after install/profile import): grant root +
immediate children via **async spawn** (never blocks window creation); write marker
on success. Per-write EPERM retries remain the backstop during the async window —
that's exactly what they're for (#1152 comment says so).
3. Drop the /T full-tree walk entirely; it grants nothing the immediate-children
ACEs + inheritance propagation don't already cover.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.139-rc.1",
"version": "1.4.139",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -153,7 +153,7 @@
"@tiptap/starter-kit": "^3.22.5",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
@@ -188,10 +188,10 @@
"oxlint-plugin-react-doctor": "0.2.10",
"oxlint-tsgolint": "0.23.0",
"pdfjs-dist": "^5.7.284",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"radix-ui": "^1.6.2",
"react": "^19.2.7",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.5",
"react-dom": "^19.2.7",
"react-grab": "^0.1.33",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
+1239 -926
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

+3 -1
View File
@@ -64,7 +64,9 @@ describe('orca claude-teams CLI handler', () => {
spawnMock.mockImplementation(() => mockClaudeChild())
callMock.mockReset()
callMock.mockResolvedValue({
result: { launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } }
result: {
launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } }
}
})
previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE
previousPaneKey = process.env.ORCA_PANE_KEY
@@ -53,6 +53,11 @@ const { fakeElectron } = vi.hoisted(() => {
webContents: FakeWebContents
setBounds = vi.fn()
constructor(options: { webContents?: FakeWebContents; webPreferences?: unknown }) {
// Why: Electron rejects explicit undefined instead of treating it as
// omitted, which the popup fallback path depends on.
if (Object.hasOwn(options, 'webContents') && options.webContents === undefined) {
throw new TypeError('options.webContents must be a WebContents')
}
this.options = options
this.webContents = options.webContents ?? createFakeWebContents()
FakeWebContentsView.instances.push(this)
@@ -186,6 +191,7 @@ describe('openPopupWithOriginBar', () => {
it('loads the target itself only when no pre-created contents were provided', () => {
const popup = openPopupWithOriginBar({}, 'https://example.com/login')
expect(lastViews().content.options).not.toHaveProperty('webContents')
expect(popup.contentWebContents.loadURL).toHaveBeenCalledWith('https://example.com/login')
})
+3 -1
View File
@@ -120,7 +120,9 @@ export function openPopupWithOriginBar(
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
})
const contentView = new WebContentsView({
webContents: options.webContents,
// Why: Electron rejects an explicitly undefined webContents; omitting it
// lets WebContentsView create contents for Cmd/Ctrl-click popups.
...(options.webContents === undefined ? {} : { webContents: options.webContents }),
webPreferences: options.webPreferences
})
window.contentView.addChildView(contentView)
+48 -8
View File
@@ -189,7 +189,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--assignee',
'@me'
'@me',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@@ -205,7 +207,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--assignee',
'@me'
'@me',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@@ -406,7 +410,9 @@ describe('listWorkItems', () => {
'acme/widgets',
'--state',
'open',
'--draft'
'--draft',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@@ -464,7 +470,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--state',
'merged'
'merged',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@@ -528,7 +536,7 @@ describe('listWorkItems', () => {
const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged']),
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']),
{ cwd: '/repo-root' }
)
expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }])
@@ -597,7 +605,7 @@ describe('listWorkItems', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'review-requested:@me']),
expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
@@ -606,6 +614,34 @@ describe('listWorkItems', () => {
)
})
it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo-root', 10, 'is:issue is:open')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'sort:updated-desc']),
{ cwd: '/repo-root' }
)
})
it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z')
// Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's
// exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows.
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']),
{ cwd: '/repo-root' }
)
})
it('returns open issues and PRs for the all-open preset query', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
@@ -652,7 +688,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--state',
'open'
'open',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@@ -667,7 +705,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--state',
'open'
'open',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
+20 -7
View File
@@ -455,6 +455,12 @@ type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees'
// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch
// must return rows newest-updated-first for the cursor to advance correctly.
// This is the search-qualifier spelling of the same contract the REST list
// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649).
const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc'
const WORK_ITEM_PR_LIST_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
@@ -996,11 +1002,13 @@ function buildWorkItemListArgs(args: {
if (excludeMergedFromClosed) {
searchParts.push('-is:merged')
}
// Why: cursor-based pagination. GitHub search supports updated:<DATE to
// fetch items older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor.
// Why: cursor-based pagination. GitHub search supports updated:<=DATE to
// fetch items at or older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor. The bound is inclusive (`<=`) so items
// sharing the boundary row's exact updatedAt aren't skipped between pages; the
// renderer dedupes the re-fetched boundary rows by id (#8649).
if (before) {
searchParts.push(`updated:<${before}`)
searchParts.push(`updated:<=${before}`)
}
if (kind === 'pr' && query.reviewRequested) {
searchParts.push(`review-requested:${query.reviewRequested}`)
@@ -1011,9 +1019,14 @@ function buildWorkItemListArgs(args: {
if (query.freeText) {
searchParts.push(query.freeText)
}
if (searchParts.length > 0) {
out.push('--search', searchParts.join(' '))
}
// Why: pagination cursors slice on updatedAt, but `gh issue list` defaults
// to created-desc and `--search` defaults to best-match. `gh issue/pr list`
// has no --sort flag, so the only lever is the search query — which forces us
// to always emit --search. Without pinning the sort, recently-updated old
// items never appear on any page, so the pager advertises pages the fetch
// chain can never reach (#8649).
searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER)
out.push('--search', searchParts.join(' '))
return out
}
@@ -126,16 +126,18 @@ describe('Windows firewall remote-address scope', () => {
// A /32 subnet is just the desktop itself, so an explicit range cannot prove
// the phone (a different host) is allowed — only the keywords can.
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true)
expect(hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32)).toBe(
true
)
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32)
).toBe(true)
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32)
).toBe(false)
})
it('fails address-family keywords closed when the interface family is unknown', () => {
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe(true)
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe(
true
)
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe(
false
)
@@ -0,0 +1,529 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
isAzureDevOpsReviewCreationAuthenticatedMock,
isGiteaReviewCreationAuthenticatedMock,
getRepoSlugMock,
getProjectSlugMock,
getBitbucketRepoSlugMock,
getAzureDevOpsRepoSlugMock,
getGiteaRepoSlugMock,
getHostedReviewForBranchMock,
ghExecFileAsyncMock,
glabExecFileAsyncMock,
gitExecFileAsyncMock,
getUpstreamStatusMock,
getSshGitProviderMock,
getEnterpriseGitHubRepoSlugMock
} = vi.hoisted(() => ({
createGitHubPullRequestMock: vi.fn(),
createGitLabMergeRequestMock: vi.fn(),
createAzureDevOpsPullRequestMock: vi.fn(),
createGiteaPullRequestMock: vi.fn(),
isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(),
isGiteaReviewCreationAuthenticatedMock: vi.fn(),
getRepoSlugMock: vi.fn(),
getProjectSlugMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
getAzureDevOpsRepoSlugMock: vi.fn(),
getGiteaRepoSlugMock: vi.fn(),
getHostedReviewForBranchMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
glabExecFileAsyncMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
getUpstreamStatusMock: vi.fn(),
getSshGitProviderMock: vi.fn(),
getEnterpriseGitHubRepoSlugMock: vi.fn()
}))
vi.mock('../github/client', () => ({
createGitHubPullRequest: createGitHubPullRequestMock,
getRepoSlug: getRepoSlugMock,
getPRForBranch: vi.fn()
}))
vi.mock('../github/github-enterprise-repository', () => ({
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock
}))
vi.mock('../gitlab/client', () => ({
getProjectSlug: getProjectSlugMock,
getMergeRequestForBranch: vi.fn(),
getMergeRequest: vi.fn()
}))
vi.mock('../gitlab/merge-request-creation', () => ({
createGitLabMergeRequest: createGitLabMergeRequestMock
}))
vi.mock('../bitbucket/client', () => ({
getBitbucketRepoSlug: getBitbucketRepoSlugMock,
getBitbucketPullRequestForBranch: vi.fn(),
getBitbucketPullRequest: vi.fn()
}))
vi.mock('../azure-devops/client', () => ({
getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock,
getAzureDevOpsPullRequestForBranch: vi.fn(),
getAzureDevOpsPullRequest: vi.fn()
}))
vi.mock('../azure-devops/pull-request-creation', () => ({
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock,
isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock
}))
vi.mock('../gitea/client', () => ({
getGiteaRepoSlug: getGiteaRepoSlugMock,
getGiteaPullRequestForBranch: vi.fn(),
getGiteaPullRequest: vi.fn()
}))
vi.mock('../gitea/pull-request-creation', () => ({
createGiteaPullRequest: createGiteaPullRequestMock,
isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock
}))
vi.mock('../github/gh-utils', () => ({
acquire: vi.fn(),
release: vi.fn(),
ghExecFileAsync: ghExecFileAsyncMock,
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../gitlab/gl-utils', () => ({
acquire: vi.fn(),
release: vi.fn(),
glabExecFileAsync: glabExecFileAsyncMock,
glabRepoExecOptions: (repoPath: string, connectionId?: string | null) =>
connectionId ? {} : { cwd: repoPath }
}))
vi.mock('../git/upstream', () => ({
getUpstreamStatus: getUpstreamStatusMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('./hosted-review', () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
}))
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
function resetMocks(): void {
for (const mock of [
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
isAzureDevOpsReviewCreationAuthenticatedMock,
isGiteaReviewCreationAuthenticatedMock,
getRepoSlugMock,
getProjectSlugMock,
getBitbucketRepoSlugMock,
getAzureDevOpsRepoSlugMock,
getGiteaRepoSlugMock,
getHostedReviewForBranchMock,
ghExecFileAsyncMock,
glabExecFileAsyncMock,
gitExecFileAsyncMock,
getUpstreamStatusMock,
getSshGitProviderMock,
getEnterpriseGitHubRepoSlugMock
]) {
mock.mockReset()
}
}
function mockGitHubProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' })
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
getGiteaRepoSlugMock.mockResolvedValue(null)
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
}
// GHES: github.com-only slug parsing misses the custom host, so the enterprise
// resolver claims the repo and reports the host for the gh auth probe (#8312).
function mockGitHubEnterpriseProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
getGiteaRepoSlugMock.mockResolvedValue(null)
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
owner: 'acme',
repo: 'orca',
host: 'github.acme-corp.com'
})
}
function mockGitLabProvider(): void {
getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' })
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
getGiteaRepoSlugMock.mockResolvedValue(null)
}
function mockAzureDevOpsProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue({
host: 'dev.azure.com',
project: 'Project',
repository: 'orca',
apiBaseUrl: 'https://dev.azure.com/acme/Project',
webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca'
})
getGiteaRepoSlugMock.mockResolvedValue(null)
}
function mockGiteaProvider(): void {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue(null)
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
getGiteaRepoSlugMock.mockResolvedValue({
host: 'git.example.com',
owner: 'acme',
repo: 'orca',
apiBaseUrl: 'https://git.example.com/api/v1',
webBaseUrl: 'https://git.example.com'
})
}
describe('getHostedReviewCreationEligibility', () => {
beforeEach(() => {
resetMocks()
mockGitHubProvider()
getHostedReviewForBranchMock.mockResolvedValue(null)
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' })
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
})
it('treats short remote base refs as the default branch name', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'main',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
canCreate: false,
blockedReason: 'default_branch',
defaultBaseRef: 'origin/main'
})
})
// Stacked-worktree base resolution (Change 1/2). `stackedArgs` defaults to a
// bare local-only parent; `mockRefs` controls the remote-tracking snapshot.
const stackedArgs = (
overrides: Partial<Parameters<typeof getHostedReviewCreationEligibility>[0]> = {}
): Parameters<typeof getHostedReviewCreationEligibility>[0] => ({
repoPath: '/repo',
branch: 'feature/stacked',
base: 'stacked-parent',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0,
...overrides
})
const mockRefs = (opts: {
symbolicRef?: string
forEachRef?: string
forEachThrows?: boolean
revParseThrows?: boolean
}): void => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: opts.symbolicRef ?? '', stderr: '' }
}
if (args[0] === 'for-each-ref') {
if (opts.forEachThrows) {
throw new Error('ssh: connect: connection refused')
}
return { stdout: opts.forEachRef ?? '', stderr: '' }
}
if (args[0] === 'rev-parse' && opts.revParseThrows) {
throw new Error('unknown revision')
}
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
})
}
it('falls back to the repo default when a stacked parent base is local-only', async () => {
mockRefs({ symbolicRef: 'refs/remotes/origin/main\n' })
await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({
canCreate: true,
blockedReason: null,
defaultBaseRef: 'origin/main'
})
})
it('preserves a stacked parent base that exists on the remote', async () => {
mockRefs({ forEachRef: 'refs/remotes/origin/parent-pushed\n' })
await expect(
getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' }))
).resolves.toMatchObject({
canCreate: true,
blockedReason: null,
defaultBaseRef: 'parent-pushed'
})
})
it('keeps the candidate base when no repo default can be resolved', async () => {
mockRefs({ revParseThrows: true })
await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({
canCreate: true,
blockedReason: null,
defaultBaseRef: 'stacked-parent'
})
})
it('preserves the candidate base when the remote probe cannot reach the host', async () => {
// Transport failure must not be read as "absent" — that would demote a
// legitimately-pushed parent to the repo default on a transient SSH blip.
mockRefs({ forEachThrows: true })
await expect(
getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' }))
).resolves.toMatchObject({ canCreate: true, defaultBaseRef: 'parent-pushed' })
})
it('blocks dirty tracked GitHub branches before PR creation', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'main',
hasUncommittedChanges: true,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
head: 'feature/create-pr'
})
})
it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => {
getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed'))
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'main',
hasUncommittedChanges: true,
hasUpstream: false,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
head: 'feature/create-pr'
})
})
it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'refs/heads/feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: true,
blockedReason: null,
nextAction: null,
defaultBaseRef: 'origin/main',
head: 'feature/create-pr'
})
})
it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => {
mockGitHubEnterpriseProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: true,
blockedReason: null,
nextAction: null
})
// Enterprise auth was already confirmed during detection; the gate must not
// fire a redundant gh probe.
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => {
const remoteGit = {
exec: vi.fn(async () => ({ stdout: '', stderr: '' }))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
await expect(
getHostedReviewCreationEligibility({
repoPath: '/remote/repo',
connectionId: 'ssh-1',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: true,
head: 'feature/create-pr'
})
expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' })
)
// Why: the base-on-remote probe must run on the SSH host that will execute
// the provider create, so it flows through the relay exec, not local git.
expect(remoteGit.exec).toHaveBeenCalledWith(
['for-each-ref', '--count=1', '--format=%(refname)', 'refs/remotes/*/main'],
'/remote/repo'
)
})
it('offers push as the next action for authenticated branches with local-only commits', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 2,
behind: 0
})
).resolves.toMatchObject({
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
})
})
it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => {
mockGitLabProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/gitlab',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'gitlab',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/gitlab'
})
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['auth', 'status', '--hostname', 'gitlab.com'],
{ cwd: '/repo' }
)
})
it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => {
mockAzureDevOpsProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/azure',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'azure-devops',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/azure'
})
expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => {
mockGiteaProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/gitea',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'gitea',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/gitea'
})
expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
})
@@ -115,7 +115,7 @@ vi.mock('./hosted-review', () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
}))
import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation'
import { createHostedReview } from './hosted-review-creation'
function resetMocks(): void {
for (const mock of [
@@ -223,6 +223,11 @@ describe('createHostedReview', () => {
if (args[0] === 'status') {
return { stdout: '', stderr: '' }
}
// Why: base-on-remote probe (Change 2 enforcement) — the default base
// resolves to a remote-tracking branch so create-time validation passes.
if (args[0] === 'for-each-ref') {
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
@@ -301,6 +306,32 @@ describe('createHostedReview', () => {
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
})
it('blocks creation with actionable copy when the submitted base is local-only', async () => {
// for-each-ref falls through to '' → the submitted stacked parent is not on
// the remote, so create-time enforcement blocks with actionable copy.
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse') {
return { stdout: 'feature\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await expect(
createHostedReview('/repo', {
provider: 'github',
base: 'stacked-parent',
head: 'feature',
title: 'Feature'
})
).resolves.toEqual({
ok: false,
code: 'validation',
error:
'Create PR failed: the base branch "stacked-parent" hasn\'t been pushed to the remote. Choose a pushed base or push it first.'
})
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
})
it('creates the pull request after fresh main-process validation passes', async () => {
await expect(
createHostedReview('/repo', {
@@ -501,6 +532,10 @@ describe('createHostedReview', () => {
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
return { stdout: 'feature\n', stderr: '' }
}
if (args[0] === 'for-each-ref') {
// Base-on-remote probe (Change 2) runs on the SSH host; base is pushed.
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
}
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
@@ -588,248 +623,3 @@ describe('createHostedReview', () => {
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
})
})
describe('getHostedReviewCreationEligibility', () => {
beforeEach(() => {
resetMocks()
mockGitHubProvider()
getHostedReviewForBranchMock.mockResolvedValue(null)
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' })
isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true)
isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true)
})
it('treats short remote base refs as the default branch name', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'main',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
canCreate: false,
blockedReason: 'default_branch',
defaultBaseRef: 'origin/main'
})
})
it('blocks dirty tracked GitHub branches before PR creation', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'main',
hasUncommittedChanges: true,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
head: 'feature/create-pr'
})
})
it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => {
getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed'))
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'main',
hasUncommittedChanges: true,
hasUpstream: false,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
head: 'feature/create-pr'
})
})
it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'refs/heads/feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: true,
blockedReason: null,
nextAction: null,
defaultBaseRef: 'origin/main',
head: 'feature/create-pr'
})
})
it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => {
mockGitHubEnterpriseProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: true,
blockedReason: null,
nextAction: null
})
// Enterprise auth was already confirmed during detection; the gate must not
// fire a redundant gh probe.
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => {
const remoteGit = {
exec: vi.fn(async () => ({ stdout: '', stderr: '' }))
}
getSshGitProviderMock.mockReturnValue(remoteGit)
await expect(
getHostedReviewCreationEligibility({
repoPath: '/remote/repo',
connectionId: 'ssh-1',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: true,
head: 'feature/create-pr'
})
expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' })
)
expect(remoteGit.exec).not.toHaveBeenCalled()
})
it('offers push as the next action for authenticated branches with local-only commits', async () => {
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 2,
behind: 0
})
).resolves.toMatchObject({
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
})
})
it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => {
mockGitLabProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/gitlab',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'gitlab',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/gitlab'
})
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['auth', 'status', '--hostname', 'gitlab.com'],
{ cwd: '/repo' }
)
})
it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => {
mockAzureDevOpsProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/azure',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'azure-devops',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/azure'
})
expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => {
mockGiteaProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/gitea',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'gitea',
canCreate: true,
blockedReason: null,
nextAction: null,
head: 'feature/gitea'
})
expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
})
@@ -42,6 +42,10 @@ import {
type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & {
connectionId?: string | null
// Why: only the create-time preflight enforces base-on-remote as a hard block;
// the renderer's eligibility probe passes the base as a candidate and relies on
// Change 1 to correct a local-only parent, so it must never set this.
enforceBaseOnRemote?: boolean
} & HostedReviewExecutionOptions
function stripRefPrefix(ref: string): string {
@@ -133,6 +137,52 @@ async function getDefaultBaseRef(
)
}
/**
* Whether the candidate base resolves to a remote-tracking branch on the
* executing host.
*
* Why: a stacked worktree's `worktree.baseRef` is typically a bare parent
* branch name with no remote qualifier, so the probe must match the branch
* under *any* configured remote rather than assume `origin` — otherwise fork
* workflows (`upstream/main`) would be missed. Runs through
* `runGitForHostedReview` so it evaluates on the same host that will run the
* provider create (native/WSL/SSH/relay). This reads the local remote-tracking
* snapshot, not the live remote; see the design doc's Open Questions for the
* ls-remote/staleness tradeoff left as a follow-up.
*/
async function baseRefExistsOnRemote(
candidate: string,
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
const base = normalizeHostedReviewBaseRef(candidate).trim()
if (!base) {
return false
}
const run = (argv: string[]): Promise<{ stdout: string }> =>
runGitForHostedReview(repoPath, argv, connectionId, options)
const patterns = [`refs/remotes/*/${base}`]
// Non-origin remote-qualified candidate (e.g. `fork/main`): the wildcard glob
// above only matches a branch literally named that because `*` does not cross `/`.
// Include the exact tracking ref directly.
if (base.includes('/')) {
patterns.push(`refs/remotes/${base}`)
}
try {
// for-each-ref exits 0 whether or not the pattern matches, so a clean empty
// result is an authoritative "absent" while a thrown error is a transport
// failure. Never conflate the two: on an unreachable host, preserve the
// candidate rather than silently demoting a legitimately-pushed parent base.
const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns])
return stdout.trim().length > 0
} catch {
return true
}
}
async function getCurrentBranch(
repoPath: string,
connectionId?: string | null,
@@ -255,9 +305,11 @@ async function isProviderAuthenticated(
function blockedCreateResultForReason(
reason: NonNullable<HostedReviewCreationBlockedReason>,
provider: HostedReviewProvider
provider: HostedReviewProvider,
submittedBase?: string | null
): CreateHostedReviewResult | null {
const copy = reviewCopy(provider)
const baseLabel = submittedBase?.trim() ? `"${submittedBase.trim()}" ` : ''
const blockedCreateResultByReason = {
auth_required: {
ok: false,
@@ -303,6 +355,11 @@ function blockedCreateResultForReason(
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: refresh source control status and try again.`
},
base_not_on_remote: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: the base branch ${baseLabel}hasn't been pushed to the remote. Choose a pushed base or push it first.`
}
} satisfies Partial<
Record<NonNullable<HostedReviewCreationBlockedReason>, CreateHostedReviewResult>
@@ -311,7 +368,8 @@ function blockedCreateResultForReason(
}
function blockedEligibilityToCreateResult(
eligibility: HostedReviewCreationEligibility
eligibility: HostedReviewCreationEligibility,
submittedBase?: string | null
): CreateHostedReviewResult | null {
if (eligibility.canCreate) {
return null
@@ -326,7 +384,11 @@ function blockedEligibilityToCreateResult(
}
}
if (eligibility.blockedReason) {
return blockedCreateResultForReason(eligibility.blockedReason, eligibility.provider)
return blockedCreateResultForReason(
eligibility.blockedReason,
eligibility.provider,
submittedBase
)
}
const copy = reviewCopy(eligibility.provider)
return {
@@ -358,20 +420,24 @@ async function validateCurrentBranchCanCreateReview(
hasUncommittedChanges(repoPath, connectionId, options),
getHostedReviewUpstreamStatus(repoPath, connectionId, options)
])
const submittedBase = normalizeHostedReviewBaseRef(input.base)
const eligibility = await getHostedReviewCreationEligibility({
repoPath,
branch: requestedHead || currentBranch,
base: normalizeHostedReviewBaseRef(input.base),
base: submittedBase,
hasUncommittedChanges: dirty,
hasUpstream: upstreamStatus.hasUpstream,
ahead: upstreamStatus.ahead,
behind: upstreamStatus.behind,
connectionId,
// Why: this is the last gate before the provider create, which targets the
// submitted base verbatim — enforce that the base exists on the remote here.
enforceBaseOnRemote: true,
...options
})
// Why: renderer eligibility can be stale by submit time; the main process
// is the last chance to avoid creating a PR from an out-of-date remote head.
return blockedEligibilityToCreateResult(eligibility)
return blockedEligibilityToCreateResult(eligibility, submittedBase)
} catch (error) {
console.warn('Hosted review creation preflight failed:', error)
return {
@@ -391,8 +457,22 @@ export async function getHostedReviewCreationEligibility(
connectionId: args.connectionId,
...hostedReviewExecutionContext(args)
})
const defaultBaseRef =
args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId, args))
// Why: an incoming base is only a *candidate* for the default merge target. A
// stacked worktree's parent base resolves on the remote only when it was
// actually pushed; a local-only parent must fall back to the repo default so
// the PR targets a ref the remote can resolve. Never regress to "no base" —
// keep the candidate if the repo default itself is unavailable.
const candidateBase = args.base?.trim() || null
const candidateBaseOnRemote =
candidateBase != null &&
(await baseRefExistsOnRemote(candidateBase, args.repoPath, args.connectionId, args))
let defaultBaseRef: string | null
if (candidateBase && candidateBaseOnRemote) {
defaultBaseRef = candidateBase
} else {
const repoDefaultBaseRef = await getDefaultBaseRef(args.repoPath, args.connectionId, args)
defaultBaseRef = repoDefaultBaseRef ?? candidateBase
}
const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null
let review: Awaited<ReturnType<typeof getHostedReviewForBranch>> = null
try {
@@ -481,6 +561,20 @@ export async function getHostedReviewCreationEligibility(
if ((args.ahead ?? 0) > 0) {
return { ...baseResult, canCreate: false, blockedReason: 'needs_push', nextAction: 'push' }
}
// Why: at create-time, `gh pr create` (and the other providers) target the
// submitted base verbatim — Change 1 only corrects the *default*, not a stale
// renderer's submitted value. Block a local-only submitted base here so it
// fails with actionable copy instead of the provider's opaque error. Only the
// create-time preflight enforces this; the renderer's eligibility probe leaves
// enforceBaseOnRemote unset so a local-only parent is silently auto-corrected.
if (args.enforceBaseOnRemote && candidateBase && !candidateBaseOnRemote) {
return {
...baseResult,
canCreate: false,
blockedReason: 'base_not_on_remote',
nextAction: null
}
}
return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null }
}
+4
View File
@@ -204,6 +204,8 @@
--git-graph-lane-4: #40b0a6;
--git-graph-lane-5: #b66dff;
--annotation-highlight: #f59e0b;
--markdown-search-match: #facc15;
--markdown-search-match-active: #fb923c;
/* Why: tab-group splits use a dedicated divider — `--border` is too faint on
card/editor surfaces when panes are side-by-side or stacked. Default must
meet >=3:1 against `--card`, not only the hover/drag strong state. */
@@ -292,6 +294,8 @@
--git-graph-lane-4: #40b0a6;
--git-graph-lane-5: #b66dff;
--annotation-highlight: #fbbf24;
--markdown-search-match: #facc15;
--markdown-search-match-active: #fb923c;
/* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */
--tab-group-split-divider: #71717a;
--tab-group-split-divider-strong: #a1a1aa;
+8 -6
View File
@@ -237,15 +237,17 @@
}
}
.markdown-preview-search-match {
padding: 0;
border-radius: 2px;
background: color-mix(in srgb, #facc15 50%, transparent);
/* Why: search hits are painted with the CSS Custom Highlight API (Ranges, no
DOM mutation) instead of injected <mark> elements — see markdown-preview-search.ts.
::highlight() supports color/background only, not border-radius/padding. */
::highlight(markdown-preview-search-match) {
background-color: color-mix(in srgb, var(--markdown-search-match) 50%, transparent);
color: inherit;
}
.markdown-preview-search-match[data-active] {
background: color-mix(in srgb, #fb923c 60%, transparent);
::highlight(markdown-preview-search-active-match) {
background-color: color-mix(in srgb, var(--markdown-search-match-active) 60%, transparent);
color: inherit;
}
.markdown-review-toolbar {
+33 -25
View File
@@ -205,6 +205,7 @@ import {
type TaskPageRepoSourceState
} from '@/components/task-page-cache-selectors'
import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility'
import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination'
import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns'
import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team'
import {
@@ -6280,32 +6281,32 @@ export default function TaskPage(): React.JSX.Element {
setPaginationLoading(true)
setLoadingTargetPage(target)
try {
let cursor = oldestItem.updatedAt
let loadedPages = pages.length
const newPages: GitHubWorkItem[][] = []
while (loadedPages <= target) {
const { items } = await fetchWorkItemsNextPage(
repoArgs,
PER_REPO_FETCH_LIMIT,
CROSS_REPO_DISPLAY_LIMIT,
q,
cursor
)
if (paginationGenerationRef.current !== requestGeneration) {
return
const result = await accumulateWorkItemPages({
existingPages: pages,
initialCursor: oldestItem.updatedAt,
targetPage: target,
// Why: uniform page size keeps deduped pages from shrinking below the
// size `totalPages` (count ÷ effectivePageSize) assumes — otherwise
// the per-page overlap loss would strand the tail items.
pageSize: effectivePageSize,
isCancelled: () => paginationGenerationRef.current !== requestGeneration,
fetchPage: async (cursor) => {
const { items } = await fetchWorkItemsNextPage(
repoArgs,
PER_REPO_FETCH_LIMIT,
CROSS_REPO_DISPLAY_LIMIT,
q,
cursor
)
return { items }
}
if (items.length === 0) {
break
}
newPages.push(items)
cursor = items.at(-1)!.updatedAt
loadedPages += 1
})
if (result.cancelled) {
return
}
if (newPages.length > 0) {
setPages((prev) => [...prev, ...newPages])
setCurrentPage(target < loadedPages ? target : loadedPages - 1)
if (result.newPages.length > 0) {
setPages((prev) => [...prev, ...result.newPages])
setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1)
}
} catch (err) {
console.error('Failed to load next page:', err)
@@ -6316,7 +6317,14 @@ export default function TaskPage(): React.JSX.Element {
}
}
},
[paginationLoading, selectedRepos, pages, appliedTaskSearch, fetchWorkItemsNextPage]
[
paginationLoading,
selectedRepos,
pages,
appliedTaskSearch,
fetchWorkItemsNextPage,
effectivePageSize
]
)
useEffect(() => {
@@ -484,12 +484,18 @@ export default function MarkdownPreview({
input.focus()
input.select()
}, [])
const matchesRef = useRef<HTMLElement[]>([])
const matchesRef = useRef<Range[]>([])
// Stable token identifying this preview in the document-global highlight
// registry, so split/floating previews don't clobber each other's Find paint.
const searchInstanceRef = useRef<object>({})
const lastAppliedInitialAnchorRef = useRef<string | null>(null)
const pendingEditorRevealFrameIdsRef = useRef<number[]>([])
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [query, setQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
// Bumps whenever the match ranges are recomputed, so the active-highlight
// effect re-runs even when a streamed rerender yields the same count/index.
const [searchRevision, setSearchRevision] = useState(0)
const [activeMatchIndex, setActiveMatchIndex] = useState(-1)
const isMac = navigator.userAgent.includes('Mac')
const openFile = useAppStore((s) => s.openFile)
@@ -835,29 +841,36 @@ export default function MarkdownPreview({
return
}
const instanceId = searchInstanceRef.current
if (!isSearchOpen) {
matchesRef.current = []
setMatchCount(0)
clearMarkdownPreviewSearchHighlights(body)
clearMarkdownPreviewSearchHighlights(instanceId)
return
}
// Search decorations are applied imperatively because the rendered preview is
// already owned by react-markdown. Rewriting the markdown AST for transient
// find state would make navigation and link rendering much harder to reason about.
const matches = applyMarkdownPreviewSearchHighlights(body, query)
// Search decorations are painted via the CSS Custom Highlight API (Ranges,
// no DOM mutation) because the rendered preview is owned by react-markdown;
// splitting its nodes to inject <mark> corrupted react's tree (crash 237acef1).
const matches = applyMarkdownPreviewSearchHighlights(instanceId, body, query)
matchesRef.current = matches
setMatchCount(matches.length)
setSearchRevision((v) => v + 1)
setActiveMatchIndex((cur) =>
matches.length === 0 ? -1 : cur >= 0 && cur < matches.length ? cur : 0
)
return () => clearMarkdownPreviewSearchHighlights(body)
return () => clearMarkdownPreviewSearchHighlights(instanceId)
}, [renderedContent, isSearchOpen, query])
useEffect(() => {
setActiveMarkdownPreviewSearchMatch(matchesRef.current, activeMatchIndex)
}, [activeMatchIndex, matchCount])
setActiveMarkdownPreviewSearchMatch(
searchInstanceRef.current,
matchesRef.current,
activeMatchIndex
)
}, [activeMatchIndex, matchCount, searchRevision])
useLayoutEffect(() => {
if (!initialAnchor || initialAnchor === lastAppliedInitialAnchorRef.current) {
@@ -1919,7 +1932,10 @@ export default function MarkdownPreview({
) : null}
</div>
) : null}
<div ref={bodyRef} className="markdown-body">
{/* Why: translate="no" keeps browser/OS page-translation from swapping
text nodes react owns, which otherwise triggers the same
insertBefore/removeChild reconciliation crash (237acef1). */}
<div ref={bodyRef} className="markdown-body" translate="no">
{/* Why: remarkFrontmatter strips front matter from normal markdown
output. When the user opts in from the preview actions menu, render the
raw metadata as a compact read-only block above the document body. */}
@@ -0,0 +1,193 @@
// @vitest-environment happy-dom
//
// Regression guards for crash 237acef1: search highlighting must not mutate the
// DOM react-markdown owns. Injecting <mark> by splitting react's text nodes (and
// normalize()-merging them on clear) left react with stale child pointers, so
// the next streamed-content commit threw
// NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before
// which the new node is to be inserted is not a child of this node.
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyMarkdownPreviewSearchHighlights,
clearMarkdownPreviewSearchHighlights,
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
const SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-match'
const ACTIVE_SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-active-match'
function MarkdownBody({ parts }: { parts: readonly string[] }): React.JSX.Element {
// Mirror react-markdown: a <p> whose children are text nodes react owns.
return (
<div className="markdown-body">
<p>{parts.map((part) => part)}</p>
</div>
)
}
describe('markdown preview search highlighting keeps react-owned DOM intact (crash 237acef1)', () => {
let container: HTMLDivElement
let root: Root
const instance = {}
beforeEach(() => {
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
clearMarkdownPreviewSearchHighlights(instance)
})
function render(parts: readonly string[]): HTMLElement {
act(() => root.render(<MarkdownBody parts={parts} />))
const body = container.querySelector<HTMLElement>('.markdown-body')
if (!body) {
throw new Error('missing markdown body')
}
return body
}
it('applies highlights without mutating the react-owned subtree', () => {
const body = render(['hello world'])
const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'lo')
expect(matches.length).toBe(1)
// No <mark> is injected; the text react owns is byte-for-byte intact.
expect(body.querySelector('mark')).toBeNull()
expect(body.textContent).toBe('hello world')
})
it('survives a streamed re-render while highlights are active', () => {
const body = render(['alpha ', 'beta'])
applyMarkdownPreviewSearchHighlights(instance, body, 'beta')
// Highlights stay registered while streamed content rewrites the paragraph.
// When they mutated react's text nodes this commit threw NotFoundError; with
// no DOM mutation it is clean. (afterEach clears the instance.)
expect(() => render(['alpha ', 'gamma', ' delta'])).not.toThrow()
})
})
// The paint path only runs where the CSS Custom Highlight API exists (prod
// Electron), which jsdom/happy-dom lack — so stub a faithful, Electron-shaped
// registry + Highlight to exercise it. The stub Highlight throws if handed any
// constructor argument, catching any accidental `new Highlight(...ranges)`
// spread (which overflows V8's arg stack on large docs).
class StubHighlight {
readonly ranges = new Set<Range>()
constructor(...args: Range[]) {
if (args.length > 0) {
throw new RangeError('Maximum call stack size exceeded')
}
}
add(range: Range): void {
this.ranges.add(range)
}
}
class StubHighlightRegistry {
readonly entries = new Map<string, StubHighlight>()
set(name: string, highlight: StubHighlight): void {
this.entries.set(name, highlight)
}
delete(name: string): void {
this.entries.delete(name)
}
}
describe('markdown preview search painting with the CSS Custom Highlight API', () => {
let registry: StubHighlightRegistry
beforeEach(() => {
registry = new StubHighlightRegistry()
vi.stubGlobal('Highlight', StubHighlight)
vi.stubGlobal('CSS', { highlights: registry })
})
afterEach(() => {
vi.unstubAllGlobals()
})
function bodyWithText(text: string): HTMLElement {
const div = document.createElement('div')
div.className = 'markdown-body'
div.textContent = text
document.body.append(div)
return div
}
it('paints many matches without a constructor-arg spread', () => {
const instance = {}
const body = bodyWithText('a a a a a a a a')
// A spread paint (new Highlight(...ranges)) would throw via the stub.
expect(() => applyMarkdownPreviewSearchHighlights(instance, body, 'a')).not.toThrow()
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(8)
clearMarkdownPreviewSearchHighlights(instance)
body.remove()
})
it('unions highlights across previews so a second Find does not clobber the first', () => {
const a = {}
const b = {}
const bodyA = bodyWithText('alpha alpha')
const bodyB = bodyWithText('alpha')
const matchesA = applyMarkdownPreviewSearchHighlights(a, bodyA, 'alpha')
const matchesB = applyMarkdownPreviewSearchHighlights(b, bodyB, 'alpha')
expect(matchesA.length).toBe(2)
expect(matchesB.length).toBe(1)
// The registry paints the union (A's 2 + B's 1), not just the last writer's.
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(3)
// Closing B keeps A's highlights.
clearMarkdownPreviewSearchHighlights(b)
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(2)
clearMarkdownPreviewSearchHighlights(a)
expect(registry.entries.has(SEARCH_HIGHLIGHT_NAME)).toBe(false)
setActiveMarkdownPreviewSearchMatch(a, matchesA, -1)
bodyA.remove()
bodyB.remove()
})
it('navigation repaints only the active highlight, not the full match set', () => {
const instance = {}
const body = bodyWithText('a a a a')
const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'a')
const paintedAfterApply = registry.entries.get(SEARCH_HIGHLIGHT_NAME)
setActiveMarkdownPreviewSearchMatch(instance, matches, 0)
setActiveMarkdownPreviewSearchMatch(instance, matches, 1)
// The SEARCH highlight object is untouched by navigation (not rebuilt each Next/Prev)...
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)).toBe(paintedAfterApply)
// ...while the ACTIVE highlight tracks the current match.
expect(registry.entries.get(ACTIVE_SEARCH_HIGHLIGHT_NAME)?.ranges.has(matches[1])).toBe(true)
clearMarkdownPreviewSearchHighlights(instance)
body.remove()
})
it('re-apply drops the active highlight until the caller repaints it (same-count rerender)', () => {
const instance = {}
const body = bodyWithText('one two one two')
const first = applyMarkdownPreviewSearchHighlights(instance, body, 'one')
expect(first.length).toBe(2)
setActiveMarkdownPreviewSearchMatch(instance, first, 0)
expect(registry.entries.has(ACTIVE_SEARCH_HIGHLIGHT_NAME)).toBe(true)
// A streamed rerender / new query re-applies and clears the active range even
// when the match count is unchanged (regression for the vanishing active mark).
const second = applyMarkdownPreviewSearchHighlights(instance, body, 'two')
expect(second.length).toBe(first.length)
expect(registry.entries.has(ACTIVE_SEARCH_HIGHLIGHT_NAME)).toBe(false)
// MarkdownPreview repaints via its searchRevision effect; the module restores it.
setActiveMarkdownPreviewSearchMatch(instance, second, 0)
expect(registry.entries.get(ACTIVE_SEARCH_HIGHLIGHT_NAME)?.ranges.has(second[0])).toBe(true)
clearMarkdownPreviewSearchHighlights(instance)
body.remove()
})
})
@@ -166,92 +166,170 @@ function buildLocaleLowercaseIndex(text: string): {
return { text: normalized, originalStartByNormalizedOffset, originalEndByNormalizedOffset }
}
export function clearMarkdownPreviewSearchHighlights(root: HTMLElement): void {
const highlights = root.querySelectorAll<HTMLElement>('[data-markdown-preview-search-match]')
for (const highlight of highlights) {
const textNode = document.createTextNode(highlight.textContent ?? '')
highlight.replaceWith(textNode)
// Why: react-markdown owns the preview DOM. Injecting <mark> by splitting its
// text nodes (and normalize()-merging them on clear) left react holding stale
// child pointers, so the next streamed-content commit threw NotFoundError
// ("insertBefore ... not a child of this node"; crash 237acef1). Paint matches
// with the CSS Custom Highlight API instead — it highlights Ranges without
// mutating the DOM react manages. The static names below must match the
// ::highlight() selectors in markdown-preview.css.
const SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-match'
const ACTIVE_SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-active-match'
type HighlightLike = { add(range: Range): void }
type HighlightRegistryLike = {
set(name: string, highlight: HighlightLike): void
delete(name: string): void
}
// Accessed via globalThis so the code degrades to a no-op where the API is
// absent (older Chromium, jsdom/happy-dom in tests) — match counting and
// navigation still work off the returned Ranges; only the paint is skipped.
function getHighlightApi(): {
registry: HighlightRegistryLike
create: (ranges: readonly Range[]) => HighlightLike
} | null {
const scope = globalThis as {
CSS?: { highlights?: HighlightRegistryLike }
Highlight?: new () => HighlightLike
}
const registry = scope.CSS?.highlights
const HighlightCtor = scope.Highlight
if (!registry || typeof HighlightCtor !== 'function') {
return null
}
return {
registry,
// Why: build with .add() rather than new Highlight(...ranges). A big doc +
// short query yields 100k+ ranges, and spreading that many constructor
// args overflows V8's argument stack (RangeError) — the same large-content
// regime as the bug this file fixes.
create: (ranges) => {
const highlight = new HighlightCtor()
for (const range of ranges) {
highlight.add(range)
}
return highlight
}
}
}
// Why: CSS.highlights is a document-global registry keyed by a static name, but
// several MarkdownPreview instances can be open at once (split panes, floating
// window). Track each instance's ranges by its own token and paint the UNION,
// so a second preview's Find does not clobber the first's highlights. Ranges
// live in each instance's own subtree, so the union paints every pane correctly.
const searchRangesByInstance = new Map<object, readonly Range[]>()
const activeRangeByInstance = new Map<object, Range>()
// Avoid array spread when collecting union ranges — a large doc can produce
// 100k+ ranges and create()/registry writes must not build variadic arg lists.
function paintMatchHighlight(api: NonNullable<ReturnType<typeof getHighlightApi>>): void {
const matchRanges: Range[] = []
for (const ranges of searchRangesByInstance.values()) {
for (const range of ranges) {
matchRanges.push(range)
}
}
if (matchRanges.length > 0) {
api.registry.set(SEARCH_HIGHLIGHT_NAME, api.create(matchRanges))
} else {
api.registry.delete(SEARCH_HIGHLIGHT_NAME)
}
}
function paintActiveHighlight(api: NonNullable<ReturnType<typeof getHighlightApi>>): void {
const activeRanges: Range[] = []
for (const range of activeRangeByInstance.values()) {
activeRanges.push(range)
}
if (activeRanges.length > 0) {
api.registry.set(ACTIVE_SEARCH_HIGHLIGHT_NAME, api.create(activeRanges))
} else {
api.registry.delete(ACTIVE_SEARCH_HIGHLIGHT_NAME)
}
}
export function clearMarkdownPreviewSearchHighlights(instanceId: object): void {
searchRangesByInstance.delete(instanceId)
activeRangeByInstance.delete(instanceId)
const api = getHighlightApi()
if (api) {
paintMatchHighlight(api)
paintActiveHighlight(api)
}
root.normalize()
}
export function applyMarkdownPreviewSearchHighlights(
instanceId: object,
root: HTMLElement,
query: string
): HTMLElement[] {
clearMarkdownPreviewSearchHighlights(root)
): Range[] {
const ranges: Range[] = []
if (!query || isMarkdownPreviewSearchQueryTooLarge(query)) {
return []
if (query && !isMarkdownPreviewSearchQueryTooLarge(query)) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!(node.parentElement instanceof HTMLElement)) {
return NodeFilter.FILTER_REJECT
}
if (!node.textContent?.trim()) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
}
})
let currentNode = walker.nextNode()
while (currentNode) {
if (currentNode instanceof Text) {
const text = currentNode.textContent ?? ''
// findTextMatchRanges returns offsets into the original text, so they
// map straight onto this Text node without any DOM rewrite.
for (const { start, end } of findTextMatchRanges(text, query)) {
const range = document.createRange()
range.setStart(currentNode, start)
range.setEnd(currentNode, end)
ranges.push(range)
}
}
currentNode = walker.nextNode()
}
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!(node.parentElement instanceof HTMLElement)) {
return NodeFilter.FILTER_REJECT
}
if (node.parentElement.closest('[data-markdown-preview-search-match]')) {
return NodeFilter.FILTER_REJECT
}
if (!node.textContent?.trim()) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
}
})
const textNodes: Text[] = []
let currentNode = walker.nextNode()
while (currentNode) {
if (currentNode instanceof Text) {
textNodes.push(currentNode)
}
currentNode = walker.nextNode()
searchRangesByInstance.set(instanceId, ranges)
activeRangeByInstance.delete(instanceId)
const api = getHighlightApi()
if (api) {
paintMatchHighlight(api)
paintActiveHighlight(api)
}
const matches: HTMLElement[] = []
for (const textNode of textNodes) {
const text = textNode.textContent ?? ''
const ranges = findTextMatchRanges(text, query)
if (ranges.length === 0) {
continue
}
const fragment = document.createDocumentFragment()
let cursor = 0
for (const range of ranges) {
if (range.start > cursor) {
fragment.append(document.createTextNode(text.slice(cursor, range.start)))
}
const highlight = document.createElement('mark')
highlight.dataset.markdownPreviewSearchMatch = 'true'
highlight.className = 'markdown-preview-search-match'
highlight.textContent = text.slice(range.start, range.end)
fragment.append(highlight)
matches.push(highlight)
cursor = range.end
}
if (cursor < text.length) {
fragment.append(document.createTextNode(text.slice(cursor)))
}
textNode.replaceWith(fragment)
}
return matches
return ranges
}
export function setActiveMarkdownPreviewSearchMatch(
matches: readonly HTMLElement[],
instanceId: object,
matches: readonly Range[],
activeIndex: number
): void {
for (const [index, match] of matches.entries()) {
const isActive = index === activeIndex
match.toggleAttribute('data-active', isActive)
if (isActive) {
match.scrollIntoView({ block: 'center', inline: 'nearest' })
}
const active = activeIndex >= 0 ? matches[activeIndex] : undefined
if (active) {
activeRangeByInstance.set(instanceId, active)
} else {
activeRangeByInstance.delete(instanceId)
}
const api = getHighlightApi()
if (api) {
// Only the active range changed — don't rebuild the (potentially 100k-range)
// match highlight on every Next/Prev navigation.
paintActiveHighlight(api)
}
if (active) {
// The Range's start container is a Text node; scroll its element into view.
active.startContainer.parentElement?.scrollIntoView({ block: 'center', inline: 'nearest' })
}
}
@@ -19,35 +19,37 @@ export function NativeChatApprovalCard({
onChoose
}: NativeChatApprovalCardProps): React.JSX.Element {
return (
<div className="shrink-0 border-t border-border bg-muted/30">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 py-3 sm:px-4">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
{approval.detail ? (
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
{approval.detail}
</p>
) : null}
<div className="shrink-0 bg-background">
<div className="mx-auto w-full max-w-4xl px-3 pt-2 pb-1 sm:px-4">
<div className="flex w-full flex-col gap-2 rounded-lg border border-input bg-card px-4 py-3 shadow-xs">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
{approval.detail ? (
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
{approval.detail}
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{approval.options.map((opt, i) => (
<button
key={`${opt.label}-${i}`}
type="button"
onClick={() => onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === 0
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-background text-foreground hover:bg-accent'
)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{approval.options.map((opt, i) => (
<button
key={`${opt.label}-${i}`}
type="button"
onClick={() => onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === 0
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-background text-foreground hover:bg-accent'
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
@@ -85,8 +85,9 @@ export function NativeChatComposerField({
}: NativeChatComposerFieldProps): React.JSX.Element {
return (
<div className="shrink-0 bg-background">
<div className="px-3 py-2 sm:px-4">
<div className="relative mx-auto w-full max-w-3xl">
{/* Extra bottom padding keeps the input box off the window rim. */}
<div className="px-3 pt-2 pb-4 sm:px-4">
<div className="relative mx-auto w-full max-w-4xl">
{autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? (
<NativeChatSlashMenu
suggestions={autocomplete.suggestions}
@@ -113,8 +114,8 @@ export function NativeChatComposerField({
<div
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
className={cn(
'rounded-xl border border-input bg-card p-1.5 shadow-xs transition-colors',
'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30'
'rounded-lg border border-input bg-card p-1.5 shadow-xs transition-colors',
'focus-within:border-ring dark:bg-input/30'
)}
>
{imageAttachments.length > 0 ? (
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '../../store'
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
@@ -23,11 +23,19 @@ import type { NativeChatInteractiveSend } from './use-native-chat-interactive-se
export function NativeChatInteractiveCard({
paneKey,
send,
canSend
canSend,
onShowingQuestionChange,
answerInputRef
}: {
paneKey: string
send: NativeChatInteractiveSend
canSend: boolean
/** Reports whether a question card is on screen so the view can replace the
* composer with it (the card's free-text row is the answer input). */
onShowingQuestionChange?: (showing: boolean) => void
/** Forwarded to the question card's free-text row so pane-level Paste keeps
* a target while the composer is unmounted. */
answerInputRef?: React.RefObject<HTMLInputElement | null>
}): React.JSX.Element | null {
const interactivePrompt = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
@@ -43,15 +51,40 @@ export function NativeChatInteractiveCard({
)
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
// A question answer is a paced multi-step write (body→Enter per question); keep
// the card up until it settles instead of dismissing on the click, so it doesn't
// vanish mid-send. `submitting` also gates a second submit racing the first.
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const submittingRef = useRef(false)
const clearDismissTimer = (): void => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current)
dismissTimerRef.current = null
}
submittingRef.current = false
}
// Keyed on cardKey (and unmount): a new prompt can replace the current one
// before the settle timer fires, and a stale `submitting` gate would swallow
// the new card's first answer.
useEffect(() => clearDismissTimer, [cardKey])
// Forget the dismissal once the prompt clears so a fresh prompt can show.
const present = card != null
useEffect(() => {
if (!present) {
setDismissedKey(null)
clearDismissTimer()
}
}, [present])
// Tell the view when a question card is up so it can hide the composer (this
// card supplies its own input). Reset on unmount so the composer comes back.
const showingQuestion = card?.kind === 'question' && canSend && cardKey !== dismissedKey
useEffect(() => {
onShowingQuestionChange?.(showingQuestion)
return () => onShowingQuestionChange?.(false)
}, [showingQuestion, onShowingQuestionChange])
if (!card || !canSend || cardKey === dismissedKey) {
return null
}
@@ -60,11 +93,23 @@ export function NativeChatInteractiveCard({
<NativeChatQuestionCard
key={cardKey ?? 'question'}
prompt={card.prompt}
answerInputRef={answerInputRef}
onAnswer={(text) => {
setDismissedKey(cardKey)
sendAnswer(text)
if (submittingRef.current) {
return
}
submittingRef.current = true
const settleMs = sendAnswer(text)
// Hold the card until the paced write finishes, then mark it answered
// (which hides it and restores the composer).
dismissTimerRef.current = setTimeout(() => {
setDismissedKey(cardKey)
submittingRef.current = false
dismissTimerRef.current = null
}, settleMs)
}}
onCancel={() => {
clearDismissTimer()
setDismissedKey(cardKey)
cancel()
}}
@@ -167,7 +167,9 @@ function MessageRow({
// transcript caught up.)
return (
<div ref={rowRef} className="flex flex-col items-end gap-0.5">
<div className="max-w-[85%] rounded-xl rounded-tr-sm border border-border bg-card px-3 py-2 text-sm text-card-foreground">
{/* User turns get a distinct muted fill (not the card/canvas color) so
the prompt reads apart from the assistant's body copy. */}
<div className="max-w-[85%] rounded-lg rounded-tr-sm bg-muted px-3.5 py-2.5 text-sm text-foreground">
{markdown ? (
<>
<ImageAttachmentRefs blocks={prose} />
@@ -213,7 +215,7 @@ function MessageRow({
<AgentControls
markdown={markdown}
onScrollToTop={scrollToTop}
className="absolute -top-1 right-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
className="absolute -top-8 right-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
) : null}
<ImageAttachmentRefs blocks={prose} />
@@ -251,6 +253,7 @@ export function NativeChatMessageList({
failedDeliveryMessageIds?: ReadonlySet<string>
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const [stuckToBottom, setStuckToBottom] = useState(true)
const [showJump, setShowJump] = useState(false)
@@ -312,6 +315,11 @@ export function NativeChatMessageList({
if (!container) {
return
}
// Detach synchronously (not just via the pending onScroll) so an in-place
// streaming growth can't re-pin to the bottom mid-flight and fight this
// deliberate scroll. The ref is what the resize observer reads.
stuckToBottomRef.current = false
setStuckToBottom(false)
const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top
container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' })
}, [])
@@ -334,17 +342,31 @@ export function NativeChatMessageList({
}
}, [messages.length, isWorking, showTypingIndicator, scrollToBottom])
// Keep the affordances in sync if the container resizes (e.g. composer mounts,
// viewport reflow) without a scroll event.
// Content growing without a message-count change (a streaming assistant turn
// extends its own message in place) never re-fires the layout effect above.
// Observe the container so those in-place growths still re-pin: stay glued to
// the bottom while stuck, otherwise just refresh the jump affordance. This is
// what removes most "Jump to latest" clicks during a live response.
useEffect(() => {
const el = scrollRef.current
if (!el || typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(handleScroll)
const observer = new ResizeObserver(() => {
if (stuckToBottomRef.current) {
scrollToBottom()
} else {
handleScroll()
}
})
// Observe the growing content, not just the fixed-height viewport, so an
// in-place streaming growth is seen; also watch the viewport for reflows.
observer.observe(el)
if (contentRef.current) {
observer.observe(contentRef.current)
}
return () => observer.disconnect()
}, [handleScroll])
}, [handleScroll, scrollToBottom])
return (
<div className="relative min-h-0 flex-1">
@@ -354,7 +376,8 @@ export function NativeChatMessageList({
className="scrollbar-sleek h-full overflow-y-auto px-3 pt-10 pb-4 sm:px-4"
>
<div
className="mx-auto flex w-full max-w-3xl flex-col gap-3"
ref={contentRef}
className="mx-auto flex w-full max-w-4xl flex-col gap-5"
// Why: `zoom` scales the chat transcript's text and layout together,
// scoped to this container so the rest of the app is untouched. It's
// the desktop analog of the mobile pinch-zoom (Chromium/Electron only).
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import { useState, type RefObject } from 'react'
import { Check, Pencil, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { formatAskAnswer, type AskPrompt } from './native-chat-interactive-prompt'
@@ -10,40 +10,31 @@ export type NativeChatQuestionCardProps = {
onAnswer: (text: string) => void
/** Dismiss the prompt (sends Escape to the agent). */
onCancel: () => void
/** Exposes the free-text row so pane-level Paste can target it while the
* card replaces the composer. */
answerInputRef?: RefObject<HTMLInputElement | null>
}
// Synthetic option value for the "Other…" free-text row, kept out of the
// answer text and replaced by the typed value when selected.
const OTHER = '__other__'
/**
* Native renderer for an agent's AskUserQuestion prompt as a wizard: one
* question per step with tabs across the top (tap to jump, a check once
* answered), single- or multi-select option rows, an "Other…" row that reveals a
* free-text input, and a Next button that advances and becomes "Send answer" on
* the last step. Matches the desktop chat's neutral shadcn styling.
* Native renderer for an agent's AskUserQuestion prompt: a numbered pick-list
* (mobile/Claude-Code parity) with a header + close, a hover-highlighted row per
* option, and an always-present free-text row for a custom answer. Single-select
* commits on click; multi-select toggles and confirms via the trailing action.
* Multi-question prompts step through tabs across the top. Neutral shadcn tokens.
*/
export function NativeChatQuestionCard({
prompt,
onAnswer,
onCancel
onCancel,
answerInputRef
}: NativeChatQuestionCardProps): React.JSX.Element {
const [index, setIndex] = useState(0)
const [selections, setSelections] = useState<string[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
const toggle = (qi: number, label: string, multi: boolean): void => {
setSelections((prev) => {
const next = prev.map((s) => [...s])
const cur = next[qi] ?? []
if (multi) {
next[qi] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label]
} else {
next[qi] = cur.includes(label) ? [] : [label]
}
return next
})
}
const total = prompt.questions.length
const isLast = index === total - 1
const q = prompt.questions[index]!
const setOther = (qi: number, value: string): void => {
setOtherText((prev) => {
@@ -53,28 +44,19 @@ export function NativeChatQuestionCard({
})
}
// The resolved answer for a question: picked labels plus the typed "Other"
// value (which replaces the synthetic OTHER marker).
const answerFor = (qi: number): string => {
const picked = (selections[qi] ?? []).filter((l) => l !== OTHER)
const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : ''
return [...picked, other].filter((p) => p.length > 0).join(', ')
// The resolved answer for a question: picked labels plus any typed free-text.
const answerFor = (qi: number, sel = selections, oth = otherText): string => {
const picked = sel[qi] ?? []
const other = (oth[qi] ?? '').trim()
return [...picked, ...(other ? [other] : [])].join(', ')
}
const total = prompt.questions.length
const isLast = index === total - 1
const currentAnswered = useMemo(
() => answerFor(index).length > 0,
// eslint-disable-next-line react-hooks/exhaustive-deps
[selections, otherText, index]
)
const currentAnswered = answerFor(index).length > 0
const submit = (): void => {
// Build per-question label lists, substituting the typed Other value, then
// format to one line per answered question.
const submitAll = (sel: string[][], oth: string[]): void => {
const resolved = prompt.questions.map((_, i) => {
const picked = (selections[i] ?? []).filter((l) => l !== OTHER)
const other = (selections[i] ?? []).includes(OTHER) ? (otherText[i] ?? '').trim() : ''
const picked = sel[i] ?? []
const other = (oth[i] ?? '').trim()
return [...picked, ...(other ? [other] : [])]
})
const text = formatAskAnswer(prompt, resolved)
@@ -83,22 +65,60 @@ export function NativeChatQuestionCard({
}
}
const advance = (): void => {
// Advance to the next question, or submit on the last one — always from an
// explicit snapshot so a just-committed single-select pick isn't lost to the
// async setState.
const advanceOrSubmit = (sel: string[][], oth: string[]): void => {
if (isLast) {
submit()
submitAll(sel, oth)
} else {
setIndex((i) => Math.min(i + 1, total - 1))
}
}
const q = prompt.questions[index]!
const otherSelected = (selections[index] ?? []).includes(OTHER)
// Selecting only highlights the row; submitting is an explicit step via the
// trailing Send/Next button. (Auto-submitting on the first click dismissed the
// card before the user saw any feedback, which read as "nothing happened".)
const pickOption = (label: string): void => {
setSelections((prev) => {
const next = prev.map((s) => [...s])
const cur = next[index] ?? []
if (q.multiSelect) {
next[index] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label]
} else {
next[index] = cur.includes(label) ? [] : [label]
}
return next
})
}
// Trailing action (also fired by Enter). On any non-final question this just
// advances — "Next" when answered, "Skip" when not — so skipping one question
// never discards answers already given on the others. Only the final question
// submits; an explicit Skip click there with nothing answered anywhere
// dismisses, but a reflexive Enter in the empty field is a no-op so it can't
// throw away the whole prompt.
const confirm = (fromKeyboard = false): void => {
if (!isLast) {
advanceOrSubmit(selections, otherText)
return
}
const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0)
if (anyAnswered) {
submitAll(selections, otherText)
} else if (!fromKeyboard) {
onCancel()
}
}
return (
<div className="shrink-0 border-t border-border bg-muted/30">
<div className="mx-auto flex max-h-[22rem] w-full max-w-3xl flex-col px-3 py-2 sm:px-4">
// Part of the composer: docked in the bottom input region, matching the
// composer's width and padding, rendered as the "ask" dialog card directly
// above the text input. Its free-text row is the answer input.
<div className="shrink-0 bg-background">
<div className="mx-auto w-full max-w-4xl px-3 pt-2 pb-4 sm:px-4">
{total > 1 ? (
<div className="flex gap-1 overflow-x-auto border-b border-border pb-2 scrollbar-sleek">
<div className="mb-2 flex gap-1 overflow-x-auto pb-1 scrollbar-sleek">
{prompt.questions.map((qq, i) => (
<button
key={i}
@@ -125,77 +145,95 @@ export function NativeChatQuestionCard({
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto py-2 scrollbar-sleek">
<p className="mb-2 text-sm font-semibold text-foreground">{q.question}</p>
<div className="flex flex-col gap-1.5">
{q.options.map((opt) => (
<div className="overflow-hidden rounded-lg border border-input bg-card shadow-xs">
<div className="flex items-center justify-between gap-2 px-3.5 py-2.5">
<p
className="min-w-0 truncate text-sm font-semibold text-foreground"
title={q.question}
>
{q.question}
</p>
<button
type="button"
onClick={onCancel}
aria-label={translate('components.native-chat.question.cancel', 'Cancel')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</div>
{/* Scroll only kicks in on long option lists; the sleek scrollbar rides
the card's right edge instead of crowding the choices. */}
<div className="max-h-[50vh] divide-y divide-border/60 overflow-y-auto border-t border-border scrollbar-sleek">
{q.options.map((opt, i) => (
<OptionRow
key={opt.label}
badge={String(i + 1)}
label={opt.label}
description={opt.description}
selected={(selections[index] ?? []).includes(opt.label)}
onSelect={() => toggle(index, opt.label, q.multiSelect)}
onSelect={() => pickOption(opt.label)}
/>
))}
<OptionRow
label={translate('components.native-chat.question.other', 'Other…')}
selected={otherSelected}
onSelect={() => toggle(index, OTHER, q.multiSelect)}
/>
{otherSelected ? (
<textarea
autoFocus
<div className="flex items-center gap-3 px-3.5 py-2.5">
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Pencil className="size-3.5" />
</span>
<input
ref={answerInputRef}
value={otherText[index]}
onChange={(e) => setOther(index, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirm(true)
}
}}
placeholder={translate(
'components.native-chat.question.otherPlaceholder',
'Type your answer'
)}
rows={2}
className="w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/60"
/>
) : null}
<button
type="button"
onClick={() => confirm()}
className={cn(
'shrink-0 rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
currentAnswered
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
{currentAnswered
? isLast
? translate('components.native-chat.question.send', 'Send answer')
: translate('components.native-chat.question.next', 'Next')
: translate('components.native-chat.question.skip', 'Skip')}
</button>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border pt-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-2 py-1 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{translate('components.native-chat.question.cancel', 'Cancel')}
</button>
{total > 1 ? (
<span className="text-xs text-muted-foreground">
{index + 1}/{total}
</span>
) : null}
<button
type="button"
onClick={advance}
disabled={!currentAnswered}
className={cn(
'rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors',
'hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50'
)}
>
{isLast
? translate('components.native-chat.question.send', 'Send answer')
: translate('components.native-chat.question.next', 'Next')}
</button>
</div>
{total > 1 ? (
<p className="mt-2 text-right text-xs text-muted-foreground">
{index + 1}/{total}
</p>
) : null}
</div>
</div>
)
}
function OptionRow({
badge,
label,
description,
selected,
onSelect
}: {
badge: string
label: string
description?: string
selected: boolean
@@ -205,23 +243,30 @@ function OptionRow({
<button
type="button"
onClick={onSelect}
// Selection is otherwise only the visual check/badge swap; expose it to
// assistive tech.
aria-pressed={selected}
className={cn(
'flex w-full items-start gap-2.5 rounded-md border bg-background px-3 py-2 text-left transition-colors',
selected ? 'border-primary' : 'border-border hover:bg-accent/50'
'flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors',
selected ? 'bg-accent' : 'hover:bg-accent'
)}
>
<span
className={cn(
'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border',
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground'
'flex size-6 shrink-0 items-center justify-center rounded-md text-xs font-medium',
selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
>
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
{selected ? <Check className="size-3.5" strokeWidth={3} /> : badge}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block truncate text-sm text-foreground" title={label}>
{label}
</span>
{description ? (
<span className="block text-xs text-muted-foreground">{description}</span>
<span className="block truncate text-xs text-muted-foreground" title={description}>
{description}
</span>
) : null}
</span>
</button>
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { ChevronDown, SquareChevronRight } from 'lucide-react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
@@ -8,26 +8,36 @@ import {
type NativeChatBlock
} from '../../../../shared/native-chat-types'
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
import { countToolCalls, summarizeToolInput, summarizeToolRun } from './native-chat-tool-summary'
import {
countToolCalls,
formatToolInput,
summarizeToolInput,
summarizeToolRun
} from './native-chat-tool-summary'
import { NativeChatDiffView } from './NativeChatDiffView'
const MAX_TOOL_RESULT_CHARS = 4000
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
* show the call's diff/input or the result's body. Tool calls read as flat
* lines in the conversation rather than boxed blocks (mobile parity). */
* lines in the conversation rather than boxed blocks (mobile parity). Lines only
* mount while the parent run is open, so each starts expanded (opening the run
* reveals every line at once) and is then individually collapsible. */
function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
const [expanded, setExpanded] = useState(false)
const [expanded, setExpanded] = useState(true)
let name: string
let preview: string
let diff: DiffLine[] | null = null
let body: { output: string; isError?: boolean } | null = null
// Full, formatted input shown when a diff-less tool call is expanded.
let detail: string | null = null
if (isToolCallBlock(block)) {
name = block.name
preview = summarizeToolInput(block.input)
diff = diffFromToolCall(block.name, block.input)
detail = diff ? null : formatToolInput(block.input)
} else if (isToolResultBlock(block)) {
name = translate('components.native-chat.tool.result', 'Result')
preview = block.output.split('\n')[0]?.slice(0, 80) ?? ''
@@ -37,7 +47,10 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu
return null
}
const hasDetail = diff !== null || body !== null || preview.length > 40
// Only offer expansion when there's more than the inline preview already shows —
// avoids re-rendering the same truncated string in a box below it.
const detailAddsInfo = detail !== null && detail.replace(/\s+/g, ' ').trim() !== preview
const hasDetail = diff !== null || body !== null || detailAddsInfo
return (
<div>
@@ -45,27 +58,34 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu
type="button"
onClick={() => hasDetail && setExpanded((v) => !v)}
className={cn(
'flex w-full items-center gap-1.5 py-0.5 text-left',
'group flex w-full items-center gap-1.5 py-0.5 text-left',
hasDetail ? 'cursor-pointer' : 'cursor-default'
)}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<code className="shrink-0 font-mono text-xs font-semibold text-foreground/90">{name}</code>
<code className="shrink-0 font-mono text-xs font-semibold text-foreground/90 transition-colors group-hover:text-foreground">
{name}
</code>
{preview ? (
<span
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground"
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/70"
title={preview}
>
{preview}
</span>
) : null}
{hasDetail ? (
// Chevron sits on the right; hidden until hover when collapsed, always
// shown (pointing down) when expanded — mirrors Codex's disclosure affordance.
<ChevronRight
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-all',
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
/>
) : null}
</button>
{expanded ? (
<div className="space-y-1.5 py-1 pl-5">
{hasDetail && expanded ? (
<div className="space-y-1.5 py-1">
{diff ? <NativeChatDiffView lines={diff} /> : null}
{!diff && body ? (
<pre
@@ -79,9 +99,11 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu
: body.output}
</pre>
) : null}
{!diff && !body && preview ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] text-foreground/80 scrollbar-sleek">
{preview}
{!diff && !body && detail ? (
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] text-foreground/80 scrollbar-sleek">
{detail.length > MAX_TOOL_RESULT_CHARS
? `${detail.slice(0, MAX_TOOL_RESULT_CHARS)}…`
: detail}
</pre>
) : null}
</div>
@@ -114,26 +136,31 @@ export function NativeChatToolRun({
)
return (
<div className="mt-1">
// Extra top margin sets the tool run apart from the assistant prose above it
// so the turn's activity doesn't crowd the message text.
<div className="mt-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 py-0.5 text-left"
className="group flex w-full items-center gap-1.5 py-0.5 text-left"
>
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground">
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover:text-foreground/80">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{summary || fallbackLabel}
</span>
{/* Chevron on the right, revealed on hover when collapsed and pointing
down when open — matches Codex's tool-run disclosure. */}
<ChevronRight
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-all',
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
/>
</button>
{open ? (
<div className="mt-1 border-l-2 border-border/60 pl-2.5">
<div className="mt-1">
{blocks.map((block, i) => (
<ToolLine key={i} block={block} />
))}
@@ -43,7 +43,10 @@ import {
shouldFocusNativeChatPaneFromPointerTarget,
shouldRedirectNativeChatTyping
} from './native-chat-typing-redirect'
import { useNativeChatContextMenu } from './use-native-chat-context-menu'
import {
emptyNativeChatContextMenuActions,
useNativeChatContextMenu
} from './use-native-chat-context-menu'
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
import {
resolveNativeChatFileLink,
@@ -54,22 +57,6 @@ import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
onSplitRight: () => {},
onSplitDown: () => {},
canEqualizePaneSizes: false,
onEqualizePaneSizes: () => {},
canExpandPane: false,
isPaneExpanded: false,
onToggleExpand: () => {},
onForkAgentSession: () => {},
onSetTitle: () => {},
onCopyTerminalId: () => {},
onCopyPaneId: () => {},
canClosePane: false,
onClosePane: () => {}
}
export type NativeChatViewProps = {
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
terminalTabId: string
@@ -186,12 +173,21 @@ function NativeChatResolvedView({
// stop (Stop sends ESC, the agent-TUI interrupt key).
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, targetPtyId, agent)
const [workingInterrupted, setWorkingInterrupted] = useState(false)
// True while a question card owns the input region, so the composer is hidden.
const [questionActive, setQuestionActive] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
// The question card's free-text row; keeps Paste working while the card
// replaces the composer.
const questionAnswerInputRef = useRef<HTMLInputElement>(null)
const fileLinkContext = useAppStore(
useShallow((s) => resolveNativeChatFileLinkContext(s, terminalTabId))
)
const pasteClipboardIntoComposer = useNativeChatPasteBridge({ rootRef, composerRef })
const pasteClipboardIntoComposer = useNativeChatPasteBridge({
rootRef,
composerRef,
questionAnswerInputRef
})
const contextMenu = useNativeChatContextMenu({
rootRef,
onSwitchToTerminal,
@@ -439,24 +435,33 @@ function NativeChatResolvedView({
/>
)}
</div>
{/* Live interactive cards (question / approval) render just above the
composer while the agent's interactivePrompt is present (mobile parity). */}
<NativeChatInteractiveCard paneKey={paneKey} send={interactiveSend} canSend={canSend} />
{/* Live interactive prompt (question / approval) is the bottom input region
(mobile parity). A question card supplies its own answer input, so it
fully replaces the composer while active — no stray "Send a message". */}
<NativeChatInteractiveCard
paneKey={paneKey}
send={interactiveSend}
canSend={canSend}
onShowingQuestionChange={setQuestionActive}
answerInputRef={questionAnswerInputRef}
/>
{/* canSend reflects the mobile presence-lock: when a mobile client holds
the pty, the composer shows its guarded state instead of racing the
mobile driver (R8). */}
<NativeChatComposer
ref={composerRef}
terminalTabId={terminalTabId}
targetPtyId={targetPtyId}
agent={agent}
canSend={canSend}
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onOptimisticSendCanceled={onOptimisticSendCanceled}
onSlashCommand={onSlashCommand}
/>
{questionActive ? null : (
<NativeChatComposer
ref={composerRef}
terminalTabId={terminalTabId}
targetPtyId={targetPtyId}
agent={agent}
canSend={canSend}
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onOptimisticSendCanceled={onOptimisticSendCanceled}
onSlashCommand={onSlashCommand}
/>
)}
{contextMenu.menu}
</div>
)
@@ -155,11 +155,11 @@ describe('empty prompt submit', () => {
describe('nativeChatQuestionOffsets', () => {
it('paces each question a full step apart, Enter 500ms after its body', () => {
expect(NATIVE_CHAT_QUESTION_STEP_MS).toBe(800)
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(300)
expect(NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000)
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500)
expect(nativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 })
expect(nativeChatQuestionOffsets(1)).toEqual({ bodyAt: 800, enterAt: 1300 })
expect(nativeChatQuestionOffsets(2)).toEqual({ bodyAt: 1600, enterAt: 2100 })
expect(nativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 })
expect(nativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 })
})
})
@@ -27,8 +27,10 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
// render the next question before writing its body, or that body lands on the
// wrong (or no) active question. This buffer is added ON TOP of the body→Enter
// gap; a previous attempt that spaced Enters only ~350ms apart fired them faster
// than the next question rendered, leaking answers as fresh prompts.
export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 300
// than the next question rendered, leaking answers as fresh prompts. Kept
// generous so the next step still renders in time on slower machines / under SSH
// round-trip latency, where a tighter cadence misfires the answer.
export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 500
/** Per-question wall-clock cadence: body→Enter gap plus the advance buffer that
* lets the next AskUserQuestion step render before its body is written. */
@@ -21,6 +21,26 @@ export function summarizeToolInput(input: unknown): string {
return `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…`
}
/** Full, pretty-printed tool-call input for the expanded detail view. Strings
* pass through as-is; objects/arrays print as indented JSON so a diff-less call
* (e.g. a question payload) reads cleanly instead of one long minified line. */
export function formatToolInput(input: unknown): string {
if (input === null || input === undefined) {
return ''
}
if (typeof input === 'string') {
return input
}
if (typeof input === 'number' || typeof input === 'boolean') {
return String(input)
}
try {
return JSON.stringify(input, null, 2) ?? ''
} catch {
return ''
}
}
/** A very short hint for a tool call in a one-line run summary: the target file's
* basename when present, else a clipped preview of the input. */
export function briefToolArg(input: unknown): string {
@@ -60,6 +60,23 @@ export type NativeChatContextMenuActions = {
onClosePane: () => void
}
/** No-op defaults for when the view has no pane-management actions wired. */
export const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
onSplitRight: () => {},
onSplitDown: () => {},
canEqualizePaneSizes: false,
onEqualizePaneSizes: () => {},
canExpandPane: false,
isPaneExpanded: false,
onToggleExpand: () => {},
onForkAgentSession: () => {},
onSetTitle: () => {},
onCopyTerminalId: () => {},
onCopyPaneId: () => {},
canClosePane: false,
onClosePane: () => {}
}
export function useNativeChatContextMenu({
rootRef,
onSwitchToTerminal,
@@ -13,8 +13,10 @@ import {
const ESC = '\x1b'
export type NativeChatInteractiveSend = {
/** Send answer text (bracketed-paste wrapped + Enter, like the composer). */
sendAnswer: (text: string) => void
/** Send answer text (bracketed-paste wrapped + Enter, like the composer).
* Returns the ms after which every scheduled write has fired (0 if nothing
* was sent) so the caller can keep the card up until the send settles. */
sendAnswer: (text: string) => number
/** Send a raw control string (e.g. an approval option number or ESC) as-is. */
sendRaw: (raw: string) => void
/** Send ESC to interrupt — cancels a question / denies an approval. */
@@ -57,12 +59,12 @@ export function useNativeChatInteractiveSend(
)
const sendAnswer = useCallback(
(text: string) => {
(text: string): number => {
if (text.trim() === '') {
return
return 0
}
if (!targetPtyId) {
return
return 0
}
// Cancel any prior in-flight answer before starting a new one.
cancelInFlight()
@@ -74,10 +76,12 @@ export function useNativeChatInteractiveSend(
// so each Enter lands on its rendered question and only the last submits.
// Other agents (e.g. Codex) submit the whole answer with one Enter, so
// gate the stepping on Claude and send a single body + Enter otherwise.
inFlightRef.current =
const handle =
agent === 'claude'
? sendNativeChatAnswer(settings, targetPtyId, text.split('\n'))
: sendNativeChatMessage(settings, targetPtyId, text)
inFlightRef.current = handle
return handle.settleAfterMs
},
[terminalTabId, targetPtyId, agent, cancelInFlight]
)
@@ -1,20 +1,41 @@
import { useCallback, useEffect } from 'react'
import type { RefObject } from 'react'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import { pasteTextIntoTextControl, TEXT_CONTROL_PASTE_MAX_BYTES } from '@/lib/text-control-paste'
import type { NativeChatComposerHandle } from './NativeChatComposer'
type NativeChatPasteBridgeRefs = {
rootRef: RefObject<HTMLDivElement | null>
composerRef: RefObject<NativeChatComposerHandle | null>
/** The question card's free-text answer input; the paste target while the
* card owns the input region (the composer is unmounted then). */
questionAnswerInputRef?: RefObject<HTMLInputElement | null>
}
export function useNativeChatPasteBridge({
rootRef,
composerRef
composerRef,
questionAnswerInputRef
}: NativeChatPasteBridgeRefs): () => void {
const pasteClipboardIntoComposer = useCallback(() => {
composerRef.current?.pasteFromClipboard()
}, [composerRef])
if (composerRef.current) {
composerRef.current.pasteFromClipboard()
return
}
const answerInput = questionAnswerInputRef?.current
if (!answerInput) {
return
}
// Text-only on purpose: the answer input takes no image attachments.
void (async () => {
const text = await window.api.ui
.readClipboardText({ maxBytes: TEXT_CONTROL_PASTE_MAX_BYTES })
.catch(() => '')
if (text.length > 0) {
await pasteTextIntoTextControl(answerInput, text, { source: 'programmatic' })
}
})()
}, [composerRef, questionAnswerInputRef])
// Capture at the pane root so repeated composer mounts do not miss image paste.
useEffect(() => {
@@ -40,6 +61,11 @@ export function useNativeChatPasteBridge({
if (!root || !(activeElement instanceof Element) || !root.contains(activeElement)) {
return
}
// No paste target mounted: leave the event unclaimed so the shared
// app-menu handler can resolve the focused text control itself.
if (!composerRef.current && !questionAnswerInputRef?.current) {
return
}
event.preventDefault()
event.stopPropagation()
pasteClipboardIntoComposer()
@@ -49,7 +75,7 @@ export function useNativeChatPasteBridge({
return () => {
window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
}
}, [pasteClipboardIntoComposer, rootRef])
}, [composerRef, pasteClipboardIntoComposer, questionAnswerInputRef, rootRef])
return pasteClipboardIntoComposer
}
@@ -143,14 +143,18 @@ describe('source-control Create PR intent flow helpers', () => {
).toEqual(['safe.ts', 'new.ts'])
})
it('prefers the current compare base over stale eligibility defaults', () => {
it('prefers the remote-validated eligibility default so it cannot diverge from the composer', () => {
// Why: the intent flow's eligibility is recomputed from the same compare
// base right before creation, so its default already corrects a local-only
// stacked parent to the repo default. The one-click path must target that
// same base as the composer, not the raw (possibly unpushable) compare base.
expect(
resolveCreatePrIntentReviewBase({
currentBaseRef: 'refs/remotes/origin/release',
currentBaseRef: 'stacked-parent',
eligibilityDefaultBaseRef: 'refs/remotes/origin/main',
composerBaseRef: 'main'
})
).toBe('release')
).toBe('main')
expect(
resolveCreatePrIntentReviewBase({
@@ -161,6 +165,19 @@ describe('source-control Create PR intent flow helpers', () => {
).toBe('develop')
})
it('falls back to the compare base when eligibility supplies no default', () => {
// Why: never blank the base. If the main process could not resolve a default
// (no candidate on remote and repo default unavailable), keep the user's
// compare base rather than dropping to an empty target.
expect(
resolveCreatePrIntentReviewBase({
currentBaseRef: 'refs/remotes/origin/release',
eligibilityDefaultBaseRef: null,
composerBaseRef: 'main'
})
).toBe('release')
})
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
expect(
resolveCreatePrIntentRemoteStep({
@@ -103,10 +103,15 @@ export function resolveCreatePrIntentReviewBase({
eligibilityDefaultBaseRef?: string | null
composerBaseRef?: string | null
}): string {
// Why: the compare-base picker is the user's latest target; eligibility can
// lag behind while Create PR intent is preparing the branch.
// Why: prefer the remote-validated eligibility default over the raw compare
// base. The intent flow auto-submits, and its eligibility is recomputed from
// this same compare base right before creation — so `eligibilityDefaultBaseRef`
// already keeps a pushed base verbatim and corrects a local-only stacked parent
// to the repo default. Using it keeps the one-click path consistent with the
// composer instead of submitting a base the remote cannot resolve. Fall back to
// the compare base only when eligibility supplied no default.
return normalizeHostedReviewBaseRef(
currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || composerBaseRef?.trim() || ''
eligibilityDefaultBaseRef?.trim() || currentBaseRef?.trim() || composerBaseRef?.trim() || ''
)
}
@@ -66,6 +66,9 @@ export function resolveBlockedCreateReviewNoticeMessage(
case 'existing_review':
case 'fork_head_unsupported':
case 'unsupported_provider':
// Why: base_not_on_remote is a create-time hard failure surfaced as an error
// result, not an inline-actionable eligibility state, so it is non-clickable.
case 'base_not_on_remote':
case null:
return null
}
@@ -513,6 +513,8 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
return `A ${createReviewCopy.reviewLabel} already exists`
case 'fork_head_unsupported':
return 'Fork head unsupported'
case 'base_not_on_remote':
return 'Base branch is not on the remote'
case null:
case undefined:
return upstreamLoading ? 'Checking branch status…' : 'Branch is not ready'
@@ -180,6 +180,7 @@ export function resolveDisabledCreatePrHeaderAction(
case 'existing_review':
case 'fork_head_unsupported':
case 'unsupported_provider':
case 'base_not_on_remote':
case null:
title = translate(
'auto.components.right.sidebar.source.control.primary.action.f0c6e2a581',
@@ -311,11 +312,7 @@ export function resolveCreatePrHeaderAction(inputs: PrimaryActionInputs): Primar
return createPrIntent
}
// Why: blocked notices are only for states the preparation intent cannot
// safely resolve, such as auth/default-branch/unsafe sync blockers.
if (canClickBlockedCreateReviewReason(inputs.hostedReviewCreation?.blockedReason)) {
return resolveDisabledCreatePrHeaderAction(inputs)
}
// Why: any remaining blocked state (including create-time-only base_not_on_remote)
// falls back to the disabled header action with its explanatory title.
return resolveDisabledCreatePrHeaderAction(inputs)
}
@@ -155,14 +155,36 @@ describe('useCreatePullRequestDialogFields', () => {
}
})
it('prefers the selected current base ref over stale eligibility defaults', async () => {
it('prefers the remote-validated eligibility default over a stacked local-only base', async () => {
// Why: for a stacked worktree the current base is the local-only parent
// branch, which the main process resolves to the repo default. The seeded
// field must follow the remote-validated eligibility default, not the parent.
const harness = renderDialogFields({
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }),
currentBaseRef: 'refs/remotes/origin/release'
currentBaseRef: 'stacked-parent'
})
try {
await harness.rerender({
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }),
currentBaseRef: 'stacked-parent'
})
expect(harness.current().base).toBe('main')
} finally {
harness.unmount()
}
})
it('falls back to the current base ref when eligibility supplies no default', async () => {
// Why: when the main process cannot resolve a default (e.g. origin/HEAD
// unset and no probes match), keep the current base rather than blanking it.
const harness = renderDialogFields({
eligibility: createEligibility({ defaultBaseRef: null }),
currentBaseRef: 'refs/remotes/origin/release'
})
try {
await harness.rerender({
eligibility: createEligibility({ defaultBaseRef: null }),
currentBaseRef: 'refs/remotes/origin/release'
})
@@ -212,7 +234,7 @@ describe('useCreatePullRequestDialogFields', () => {
const seedRevisions = { ...harness.current().fieldRevisions }
await harness.rerender({
eligibility: createEligibility(),
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/release' }),
currentBaseRef: 'refs/remotes/origin/release'
})
expect(harness.current().base).toBe('release')
@@ -91,7 +91,13 @@ function resolveCreateReviewDefaultBaseRef({
currentBaseRef?: string | null
eligibilityDefaultBaseRef?: string | null
}): string {
return stripBaseRef(currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || '')
// Why: prefer the remote-validated main-process default over the worktree's
// local parent base. For a stacked worktree whose parent is local-only,
// `currentBaseRef` is that unpushable parent; the eligibility default has
// already fallen back to a ref the remote can resolve. Fall back to
// `currentBaseRef` only when eligibility supplied no default. Manual
// `setUserBase` still wins via the base-resync suppression.
return stripBaseRef(eligibilityDefaultBaseRef?.trim() || currentBaseRef?.trim() || '')
}
export function normalizeCreateReviewBaseSearchResults(
@@ -68,6 +68,57 @@ describe('groupWorkspaceKanbanWorktrees', () => {
expect(grouped.get('doing')?.map((item) => item.id)).toEqual(['b', 'c', 'a'])
})
it('does not crash when a worktree is missing its displayName under Manual sort', () => {
// Repro for crash 99657ab1: a worktree reached the sidebar with an
// undefined displayName, so `a.displayName.localeCompare(...)` threw
// `Cannot read properties of undefined (reading 'localeCompare')`.
expect(() =>
groupWorkspaceKanbanWorktrees({
worktrees: [
worktree({
id: 'a',
displayName: undefined as unknown as string,
workspaceStatus: 'doing',
manualOrder: 100
}),
worktree({
id: 'b',
displayName: undefined as unknown as string,
workspaceStatus: 'doing',
manualOrder: 100
})
],
visibleWorktreeIds: new Set(['a', 'b']),
workspaceStatuses: statuses,
sortBy: 'manual'
})
).not.toThrow()
})
it('does not crash when a worktree is missing its displayName outside Manual sort', () => {
expect(() =>
groupWorkspaceKanbanWorktrees({
worktrees: [
worktree({
id: 'a',
displayName: undefined as unknown as string,
workspaceStatus: 'doing',
lastActivityAt: 10
}),
worktree({
id: 'b',
displayName: undefined as unknown as string,
workspaceStatus: 'doing',
lastActivityAt: 10
})
],
visibleWorktreeIds: new Set(['a', 'b']),
workspaceStatuses: statuses,
sortBy: 'recent'
})
).not.toThrow()
})
it('keeps pinned then recent ordering outside Manual sort', () => {
const grouped = groupWorkspaceKanbanWorktrees({
worktrees: [
@@ -2,15 +2,20 @@ import type { WorkspaceStatus, WorkspaceStatusDefinition, Worktree } from '../..
import type { SortBy } from './smart-sort'
import { getWorkspaceStatus } from './workspace-status'
// Why: displayName is typed non-optional, but persisted/discovered worktrees
// have reached the sidebar with an undefined name (crash 99657ab1), which made
// `displayName.localeCompare(...)` throw and took down the sidebar. Compare on a
// safe string so a missing name only affects tie-break order, never crashes.
function compareDisplayName(a: Worktree, b: Worktree): number {
return (a.displayName ?? '').localeCompare(b.displayName ?? '')
}
function sortBoardWorktrees(a: Worktree, b: Worktree): number {
return b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName)
return b.lastActivityAt - a.lastActivityAt || compareDisplayName(a, b)
}
function sortManualBoardWorktrees(a: Worktree, b: Worktree): number {
return (
(b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) ||
a.displayName.localeCompare(b.displayName)
)
return (b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) || compareDisplayName(a, b)
}
export function groupWorkspaceKanbanWorktrees(params: {
@@ -5,6 +5,7 @@ import type { TerminalTab, TuiAgent } from '../../../../shared/types'
import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers'
import { ShellIcon } from './shell-icons'
import type { TerminalTabActivityStatus } from './terminal-tab-activity-status'
import { translate } from '@/i18n/i18n'
type TerminalTabLeadingIconProps = {
agent: TuiAgent | null
@@ -35,7 +36,8 @@ function activityDotState(status: TerminalTabActivityStatus): AgentDotState | nu
return 'permission'
case 'done':
return 'done'
default:
case 'active':
case 'inactive':
return null
}
}
@@ -69,7 +71,10 @@ export function TerminalTabLeadingIcon({
return (
<span
data-testid="tab-activity-bell"
aria-label="Unread agent completion"
aria-label={translate(
'auto.components.tab.bar.TerminalTabLeadingIcon.7ab2964bea',
'Unread agent completion'
)}
className="mr-1 inline-flex shrink-0 items-center gap-1"
>
<FilledBellIcon className="size-3 text-amber-500 drop-shadow-sm" />
@@ -0,0 +1,280 @@
import { describe, expect, it, vi } from 'vitest'
import type { GitHubWorkItem } from '../../../shared/types'
import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination'
function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem {
return {
id,
type: 'issue',
number: Number.parseInt(id.split(':')[1] ?? '0', 10),
title: id,
state: 'open',
url: `https://example.test/${id}`,
labels: [],
updatedAt,
author: null,
repoId
}
}
// Build a run of issues numbered `from`..`to` (inclusive) with strictly
// decreasing updatedAt, on repo `r`, so `at(-1)` is the oldest.
function run(r: string, from: number, to: number): GitHubWorkItem[] {
const out: GitHubWorkItem[] = []
for (let n = from; n <= to; n += 1) {
// Larger number => newer; encode as a descending timestamp so sort order is
// unambiguous and distinct.
const ts = `2026-01-01T00:00:${String(1000 - n).padStart(4, '0')}Z`
out.push(item(r, `issue:${n}`, ts))
}
return out
}
describe('workItemIdentity', () => {
it('qualifies the bare id with the repo so cross-repo collisions are distinct', () => {
expect(workItemIdentity(item('repo-a', 'issue:9', 't'))).toBe('repo-a:issue:9')
expect(workItemIdentity(item('repo-b', 'issue:9', 't'))).not.toBe(
workItemIdentity(item('repo-a', 'issue:9', 't'))
)
})
})
describe('accumulateWorkItemPages', () => {
it('drops the re-fetched boundary row that shares the previous page cursor', async () => {
const boundary = item('r', 'issue:2', '2026-07-02')
const existing = [[item('r', 'issue:1', '2026-07-03'), boundary]]
// Inclusive cursor re-returns issue:2 (boundary) then genuinely older rows,
// enough to fill a full page of size 2 after dedup.
const fetchPage = vi.fn().mockResolvedValue({
items: [boundary, item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
})
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 1,
pageSize: 2,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
expect(result.newPages).toEqual([
[item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
])
expect(result.loadedPages).toBe(2)
expect(fetchPage).toHaveBeenCalledWith('2026-07-02')
})
it('backfills across fetches so a deduped page stays full and the tail stays reachable', async () => {
// Regression: pageSize 3. Page 0 = issues 1..3. Inclusive cursor re-fetches
// the boundary (issue:3), so a naive "one fetch = one page" scheme would emit
// a 2-item page and the count-derived totalPages would strand the tail.
const existing = [run('r', 1, 3)]
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
const fetchPage = vi
.fn()
// <=cursor0 re-returns issue:3 (boundary) + 4,5 => 2 fresh, not yet a page.
.mockResolvedValueOnce({ items: run('r', 3, 5) })
// Backfill fetches again from issue:5's ts and gets 5 (boundary) + 6 => 1 fresh.
.mockResolvedValueOnce({ items: run('r', 5, 6) })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 1,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
// One uniform full page of the 3 genuinely-new items 4,5,6 — nothing stranded.
expect(result.newPages).toEqual([run('r', 4, 6)])
expect(result.loadedPages).toBe(2)
expect(fetchPage).toHaveBeenCalledTimes(2)
expect(fetchPage).toHaveBeenNthCalledWith(2, run('r', 3, 5).at(-1)!.updatedAt)
})
it('does not confuse same-numbered items from different repos', async () => {
const existing = [[item('repo-a', 'issue:9', '2026-07-02')]]
// repo-b's issue:9 is a different item and must survive dedup.
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [item('repo-b', 'issue:9', '2026-07-01')] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 1,
pageSize: 1,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
expect(result.newPages).toEqual([[item('repo-b', 'issue:9', '2026-07-01')]])
expect(result.loadedPages).toBe(2)
})
it('flushes a short final page when the source is exhausted mid-page', async () => {
const existing = [run('r', 1, 3)]
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
const fetchPage = vi
.fn()
// 2 fresh rows, short of pageSize 3...
.mockResolvedValueOnce({ items: run('r', 3, 5) })
// ...then the source is exhausted.
.mockResolvedValueOnce({ items: [] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 1,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
// Short final page rather than dropping issues 4,5.
expect(result.newPages).toEqual([run('r', 4, 5)])
expect(result.loadedPages).toBe(2)
})
it('stops (flushing progress) when a full page yields nothing new', async () => {
const existing = [run('r', 1, 3)]
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
// First fetch adds issues 4,5 (2 fresh); second fetch re-returns only
// already-seen rows (a >pageSize same-timestamp run) — no forward progress.
const fetchPage = vi
.fn()
.mockResolvedValueOnce({ items: run('r', 3, 5) })
.mockResolvedValueOnce({ items: run('r', 4, 5) })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 5,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
// The 2 buffered fresh rows are flushed rather than lost.
expect(result.newPages).toEqual([run('r', 4, 5)])
expect(fetchPage).toHaveBeenCalledTimes(2)
})
it('chains full pages until the target page is reached', async () => {
const existing = [run('r', 1, 2)]
const cursor0 = run('r', 1, 2).at(-1)!.updatedAt
const fetchPage = vi
.fn()
.mockResolvedValueOnce({ items: run('r', 2, 4) }) // boundary 2 + 3,4
.mockResolvedValueOnce({ items: run('r', 4, 6) }) // boundary 4 + 5,6
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 2,
pageSize: 2,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
expect(result.newPages).toEqual([run('r', 3, 4), run('r', 5, 6)])
expect(result.loadedPages).toBe(3)
})
it('stops at an empty page (source exhausted) with no buffered rows', async () => {
const existing = [[item('r', 'issue:1', '2026-07-02')]]
const fetchPage = vi.fn().mockResolvedValue({ items: [] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 3,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result).toEqual({ cancelled: false, newPages: [], loadedPages: 1 })
})
it('recovers leftover rows discarded at the target boundary on the next call', async () => {
const pageSize = 3
// Call 1: page0 = 1..3, target page 1. The fetch returns more than one
// page of fresh rows; issue 7 is past the emitted page and gets discarded
// (it belongs to page 2, which the user hasn't requested yet).
const existing1 = [run('r', 1, 3)]
const fetch1 = vi.fn().mockResolvedValueOnce({ items: run('r', 3, 7) }) // boundary 3 + 4,5,6,7
const call1 = await accumulateWorkItemPages({
existingPages: existing1,
initialCursor: run('r', 1, 3).at(-1)!.updatedAt,
targetPage: 1,
pageSize,
fetchPage: fetch1,
isCancelled: () => false
})
expect(call1.cancelled).toBe(false)
if (call1.cancelled) {
return
}
expect(call1.newPages).toEqual([run('r', 4, 6)]) // 7 discarded
// Call 2: page1 is now on screen. The inclusive cursor re-fetches the
// boundary (6, deduped) plus the discarded 7 and beyond — 7 must reappear
// exactly once, in order, with no duplicate of 4..6.
const call2 = await accumulateWorkItemPages({
existingPages: [...existing1, ...call1.newPages],
initialCursor: call1.newPages.at(-1)!.at(-1)!.updatedAt, // ts(6)
targetPage: 2,
pageSize,
fetchPage: vi.fn().mockResolvedValueOnce({ items: run('r', 6, 9) }), // boundary 6 + 7,8,9
isCancelled: () => false
})
expect(call2.cancelled).toBe(false)
if (call2.cancelled) {
return
}
expect(call2.newPages).toEqual([run('r', 7, 9)]) // 7 recovered, deduped, in order
})
it('reports cancellation and discards fetched pages when superseded', async () => {
const existing = [[item('r', 'issue:1', '2026-07-02')]]
const fetchPage = vi.fn().mockResolvedValue({ items: [item('r', 'issue:2', '2026-07-01')] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 1,
pageSize: 1,
fetchPage,
isCancelled: () => true
})
expect(result).toEqual({ cancelled: true })
})
})
@@ -0,0 +1,125 @@
import type { GitHubWorkItem } from '../../../shared/types'
/**
* Cross-repo Tasks pagination is cursor-based on `updatedAt`: each page's oldest
* row seeds the next fetch as `updated:<=<cursor>`. The bound is inclusive so
* items sharing the boundary row's exact timestamp aren't skipped between pages
* (#8649) — which means the boundary rows come back on the next fetch and must
* be deduped by identity here.
*
* Because dedup removes the re-fetched overlap, a single fetch no longer yields
* a full page. If we emitted each deduped fetch as its own page, pages 1+ would
* be one row short of `pageSize` while `totalPages` (count ÷ pageSize) still
* assumed full pages — stranding the tail items. So we backfill: accumulate
* fresh rows across as many fetches as needed and emit uniform `pageSize` pages,
* flushing a short final page only when the source is exhausted.
*
* Kept as a pure function (out of the TaskPage component) so the dedup +
* cursor-advance + fixed-page contract is unit-testable without a DOM.
*
* Tradeoff: when the per-repo fetch size equals `pageSize` the boundary dedup
* costs one extra fetch per page (and re-fetches the sub-page leftover on the
* next advance). Acceptable for interactive pagination and bounded by the gh
* rate-limit guard; persisting the cursor/buffer across calls to avoid the
* re-fetch is a possible follow-up.
*/
// Why: `item.id` (e.g. "issue:9") is only unique within a repo — two selected
// repos can carry the same bare id. Key dedup on repo + id, matching the row
// key the table renders with.
export function workItemIdentity(item: Pick<GitHubWorkItem, 'id' | 'repoId'>): string {
return `${item.repoId}:${item.id}`
}
export type WorkItemPageFetchResult = { items: GitHubWorkItem[] }
export type AccumulateWorkItemPagesArgs = {
/** Pages already on screen; their items seed the dedup set. */
existingPages: readonly GitHubWorkItem[][]
/** updatedAt of the oldest currently-loaded row — the first fetch cursor. */
initialCursor: string
/** 0-indexed page the user is trying to reach. */
targetPage: number
/** Uniform display page size (the component's effectivePageSize). */
pageSize: number
/** Fetch one page for the given inclusive cursor. */
fetchPage: (cursor: string) => Promise<WorkItemPageFetchResult>
/** Returns true if the request was superseded and results should be dropped. */
isCancelled: () => boolean
}
export type AccumulateWorkItemPagesResult =
| { cancelled: true }
| {
cancelled: false
/** Freshly fetched, deduped, uniform-sized pages to append. */
newPages: GitHubWorkItem[][]
/** Total loaded page count after appending (existing + new). */
loadedPages: number
}
/**
* Fetch pages until `targetPage` is reached (or the source is exhausted),
* deduping re-fetched boundary rows and emitting uniform `pageSize` pages so
* every item lands on exactly one page and page sizes stay consistent with the
* count-derived `totalPages`.
*/
export async function accumulateWorkItemPages(
args: AccumulateWorkItemPagesArgs
): Promise<AccumulateWorkItemPagesResult> {
const { existingPages, initialCursor, targetPage, pageSize, fetchPage, isCancelled } = args
const seen = new Set<string>()
for (const page of existingPages) {
for (const item of page) {
seen.add(workItemIdentity(item))
}
}
let cursor = initialCursor
let loadedPages = existingPages.length
const newPages: GitHubWorkItem[][] = []
// Fresh rows not yet emitted as a page, held until they fill `pageSize`.
let buffer: GitHubWorkItem[] = []
const emitFullPages = (): void => {
while (buffer.length >= pageSize && loadedPages <= targetPage) {
newPages.push(buffer.slice(0, pageSize))
buffer = buffer.slice(pageSize)
loadedPages += 1
}
}
while (loadedPages <= targetPage) {
const { items } = await fetchPage(cursor)
if (isCancelled()) {
return { cancelled: true }
}
if (items.length === 0) {
break
}
const fresh = items.filter((item) => !seen.has(workItemIdentity(item)))
// Advance from the raw fetch, not the deduped rows, so the cursor tracks
// real data. If a full page yields nothing new the cursor can't move past
// this timestamp (a rare >pageSize same-timestamp run), so stop rather than
// re-fetch the same window forever.
cursor = items.at(-1)!.updatedAt
if (fresh.length === 0) {
break
}
for (const item of fresh) {
seen.add(workItemIdentity(item))
}
buffer.push(...fresh)
emitFullPages()
}
// Source exhausted (or stalled) with a partial page still buffered: emit it as
// a short final page so those items remain reachable.
if (buffer.length > 0 && loadedPages <= targetPage) {
newPages.push(buffer)
loadedPages += 1
}
return { cancelled: false, newPages, loadedPages }
}
@@ -1832,14 +1832,29 @@ describe('agent completion coordinator', () => {
const turn = { prompt: 'fix the bug', agentType: 'codex' as const }
coordinator.observeHookStatus({ state: 'working', ...turn })
coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command', toolInput: 'ls' })
coordinator.observeHookStatus({
state: 'waiting',
...turn,
toolName: 'exec_command',
toolInput: 'ls'
})
// First pause auto-resolves before the window elapses.
coordinator.observeHookStatus({ state: 'working', ...turn, toolName: 'exec_command', toolInput: 'ls' })
coordinator.observeHookStatus({
state: 'working',
...turn,
toolName: 'exec_command',
toolInput: 'ls'
})
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS)
expect(dispatchAttention).not.toHaveBeenCalled()
// A later, genuinely-distinct pause must re-arm the debounce and fire.
coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'apply_patch', toolInput: 'diff' })
coordinator.observeHookStatus({
state: 'waiting',
...turn,
toolName: 'apply_patch',
toolInput: 'diff'
})
expect(dispatchAttention).not.toHaveBeenCalled()
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS)
expect(dispatchAttention).toHaveBeenCalledTimes(1)
@@ -7,6 +7,8 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport'
import { isXtermInstanceDisposed } from '@/lib/pane-manager/xterm-instance-disposed'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import {
getLeftmostLeafId,
normalizeTerminalLayoutSnapshot,
@@ -264,6 +266,17 @@ export function restoreScrollbackBuffers(
if (!pane) {
continue
}
// Why this breadcrumb: a restore write into an already-disposed xterm is
// fully silent (no throw; completion callbacks are dropped) and is the
// suspected deterministic producer of zombie panes at startup — the field
// signature is a wedged-release drip with zero error breadcrumbs. Naming
// the moment here turns the last unproven link into a logged fact.
if (isXtermInstanceDisposed(pane.terminal)) {
recordRendererCrashBreadcrumb('terminal_restore_write_target_disposed', {
paneId: pane.id
})
continue
}
try {
const renderOptions = {
shouldRefreshViewportSynchronously: () => !manager.hasWebglRenderer(pane.id)
@@ -293,8 +306,14 @@ export function restoreScrollbackBuffers(
// fresh-shell paths should move these visible rows into scrollback.
restoredViewportBlankingPanesRef?.current.add(pane.id)
}
} catch {
// If restore fails, continue with blank terminal.
} catch (error: unknown) {
// If restore fails, continue with blank terminal — but leave a trace:
// this catch was fully silent while zombie panes went undiagnosed.
recordRendererCrashBreadcrumb('terminal_restore_write_failed', {
paneId: pane.id,
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error)
})
}
}
}
@@ -50,6 +50,11 @@ import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-clai
import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay'
import { createPtySizeReassertion } from './pty-size-reassertion'
import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard'
import {
isTerminalWritePipelineCertifiedDead,
registerUndeliverableWriteHandler
} from '@/lib/pane-manager/terminal-write-pipeline-health'
import { requestTerminalPaneRecovery } from './terminal-pane-recovery'
import {
isDocumentVisibilityProvenStale,
registerStaleDocumentVisibilityRecovery
@@ -3326,6 +3331,66 @@ export function connectPanePty(
}
}
// Why: an unbound transport (detached during a remount/move and never
// rebound) silently rejects every keystroke while the PTY stays alive and
// the last frame stays painted — the pane looks healthy and eats input
// (issue #8104 class). None of the dead-session reconciles cover it because
// the PTY is live; recover by remounting the tab over the live PTY.
let transportConnectInFlightSince: number | null = null
// Why a grace window instead of a plain flag: a connect that never settles
// (SSH RPC timeout class, wedged daemon call) would otherwise suppress
// input-triggered recovery FOREVER — and such a pane has no output flowing,
// so no other detector can fire. Past the grace, undeliverable input may
// recover again; the transport's destroyed-check no longer kills a
// pre-existing session when a late reattach resolves, so a remount racing
// a slow-but-alive connect costs a wasted view rebuild, not a shell.
const TRANSPORT_CONNECT_SETTLE_GRACE_MS = 60_000
const requestRecoveryForUndeliverableInput = (): void => {
if (transport.isConnected?.() && transport.getPtyId() !== null) {
return
}
// Why: input rejected while a connect/reattach is still settling is "not
// deliverable YET", not a dead binding. Remounting here destroys the
// unbound transport, and pty-transport's destroyed check then kills the
// PTY the in-flight reattach resolves to — the live shell recovery exists
// to preserve. The fossil case this detector targets has no pending
// connect, so it still recovers. Same for a late async reject landing
// after dispose: the successor pane owns the tab now.
const connectStillSettling =
transportConnectInFlightSince !== null &&
Date.now() - transportConnectInFlightSince < TRANSPORT_CONNECT_SETTLE_GRACE_MS
if (connectStillSettling || disposed) {
return
}
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null
const undeliverablePtyId = transport.getPtyId() ?? storePtyId
void requestTerminalPaneRecovery({
tabId: deps.tabId,
ptyId: undeliverablePtyId,
reason: 'input-undeliverable',
// Why: pty:hasPty answers null for ids the local registry doesn't own,
// and a disconnected remote pane would otherwise remount-churn on every
// cooldown window while typing. Local panes keep the lenient gate.
requireAuthoritativeLiveness:
Boolean(transport.getConnectionId?.()) || isRemoteRuntimePtyId(undeliverablePtyId)
})
}
// Why: the write-pipeline health watch (scheduler stall probe, replay-guard
// wedge certification) detects a dead xterm pipeline; route its verdict to
// the same tab remount. Registered per xterm instance — recovery replaces
// the instance, which resets certification naturally.
const unregisterUndeliverableWriteHandler = registerUndeliverableWriteHandler(
pane.terminal,
(reason) => {
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null
void requestTerminalPaneRecovery({
tabId: deps.tabId,
ptyId: transport.getPtyId() ?? storePtyId,
reason
})
}
)
const onDataDisposable = pane.terminal.onData((data) => {
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
// OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes
@@ -3398,6 +3463,10 @@ export function connectPanePty(
observeAcceptedTerminalInput(data, acknowledgedIntent)
interruptInference.observeInputIntent(acknowledgedIntent)
observeTitleOnlyInterrupt()
} else {
// Why: Esc/Ctrl+C are the first keys users press on a frozen pane;
// an unbound-transport reject here must arm recovery too.
requestRecoveryForUndeliverableInput()
}
})
.catch((err) => {
@@ -3412,6 +3481,8 @@ export function connectPanePty(
markAcceptedTerminalInputSent()
observeAcceptedShellCommandInput(data)
observeAcceptedTerminalInput(data, intent)
} else {
requestRecoveryForUndeliverableInput()
}
clearPendingTerminalInputIntent()
return
@@ -3424,6 +3495,7 @@ export function connectPanePty(
observeSentTerminalInputIntent(data)
} else {
clearPendingTerminalInputIntent()
requestRecoveryForUndeliverableInput()
}
})
@@ -4309,6 +4381,7 @@ export function connectPanePty(
? Promise.resolve(null)
: window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null)
transportConnectInFlightSince = Date.now()
const spawnedRaw = transport.connect({
url: '',
cols,
@@ -4328,6 +4401,11 @@ export function connectPanePty(
}
})
void Promise.resolve(spawnedRaw)
.catch(() => null)
.finally(() => {
transportConnectInFlightSince = null
})
const trackedPromise: Promise<string | null> = Promise.resolve(spawnedRaw)
.then(async (spawnedPtyId) => {
const resolvedPtyId =
@@ -6070,6 +6148,14 @@ export function connectPanePty(
}
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {
// Why: once the write pipeline is probe-certified dead, a restore can
// never parse — replaying just re-arms the wedged breadcrumb drip and
// wastes a snapshot fetch each time the delivery watchdog heals stuck
// in-flight bytes (~60s while idle). Recovery owns the pane now; the
// remounted pane gets a fresh xterm and a fresh restore.
if (isTerminalWritePipelineCertifiedDead(pane.terminal)) {
return false
}
resetHiddenOutputRestoreIfPtyChanged()
const ptyId = hiddenOutputRestorePtyId ?? transport.getPtyId()
if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) {
@@ -6878,6 +6964,7 @@ export function connectPanePty(
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
clearPaneMode2031State()
clearHiddenOutputRestoreState()
transportConnectInFlightSince = Date.now()
const reattachPromise = transport.connect({
url: '',
cols,
@@ -6907,6 +6994,11 @@ export function connectPanePty(
}
}
})
void Promise.resolve(reattachPromise)
.catch(() => null)
.finally(() => {
transportConnectInFlightSince = null
})
void Promise.resolve(reattachPromise)
.then(async (result) => {
console.warn(
@@ -7064,6 +7156,7 @@ export function connectPanePty(
let expiredReattachError = false
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
transportConnectInFlightSince = Date.now()
const reattachPromise = transport.connect({
url: '',
cols,
@@ -7092,6 +7185,11 @@ export function connectPanePty(
}
})
void Promise.resolve(reattachPromise)
.catch(() => null)
.finally(() => {
transportConnectInFlightSince = null
})
void Promise.resolve(reattachPromise)
.then(async (result) => {
if (!result && expiredReattachError) {
@@ -7418,6 +7516,7 @@ export function connectPanePty(
reconcileIfSessionMissing,
dispose() {
disposed = true
unregisterUndeliverableWriteHandler()
// Why: the post-spawn reconcile polls across frames; cancel its pending
// rAF so a torn-down pane cannot keep fitting/resizing after disposal.
ptySizeReconcileHandle?.cancel()
@@ -1900,6 +1900,51 @@ describe('createIpcPtyTransport', () => {
})
})
it('does not kill a pre-existing session when a reattach resolves after destroy', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null }
const spawnPromise = new Promise<{ id: string }>((resolve) => {
spawnControls.resolve = resolve
})
const spawnMock = vi.fn().mockReturnValue(spawnPromise)
const killMock = vi.fn()
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
spawn: spawnMock,
write: vi.fn(),
resize: vi.fn(),
kill: killMock,
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {})
}
}
} as unknown as typeof window
const transport = createIpcPtyTransport({})
const connectPromise = transport.connect({
url: '',
callbacks: {},
// A reattach targets a session that existed before this transport;
// destroying the view must not reap the user's live shell.
sessionId: 'pty-preexisting'
})
transport.destroy?.()
if (!spawnControls.resolve) {
throw new Error('Expected spawn resolver to be captured')
}
spawnControls.resolve({ id: 'pty-preexisting' })
await connectPromise
expect(killMock).not.toHaveBeenCalledWith('pty-preexisting')
})
it('kills a PTY that finishes spawning after the transport was destroyed', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null }
@@ -828,8 +828,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
? spawnResult.launchAgent
: undefined
// If destroyed while spawn was in flight, kill only a newly created PTY.
// A reattach belongs to an existing session outside this transport.
// If destroyed while the connect was in flight, kill only what this
// connect actually created. Reattaches remain owned by tab lifecycle.
if (destroyed) {
if (!spawnResult.isReattach) {
void killPtyRetainingRetryOwnership(
@@ -7,6 +7,11 @@ import {
type ReplayingPanesRef
} from './replay-guard'
import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
import {
_resetWritePipelineHealthForTests,
notifyUndeliverableWrite,
registerUndeliverableWriteHandler
} from '@/lib/pane-manager/terminal-write-pipeline-health'
const mocks = vi.hoisted(() => ({
recordRendererCrashBreadcrumb: vi.fn()
@@ -234,6 +239,71 @@ describe('replay-guard', () => {
}
})
it('a wedged release hands the terminal to pane recovery', () => {
vi.useFakeTimers()
try {
const ref = makeRef()
const { pane } = makeFakePane(1)
const onUndeliverable = vi.fn()
registerUndeliverableWriteHandler(pane.terminal, onUndeliverable)
replayIntoTerminal(pane, ref, 'fossil frame bytes', { stallCheckMs: 400 })
// Never flush: probe never parses → wedged release.
vi.advanceTimersByTime(1000)
expect(onUndeliverable).toHaveBeenCalledTimes(1)
expect(onUndeliverable).toHaveBeenCalledWith('replay-wedged')
_resetWritePipelineHealthForTests(pane.terminal)
} finally {
vi.useRealTimers()
}
})
it('short-circuits replays into a certified-dead terminal', () => {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
notifyUndeliverableWrite(pane.terminal, 'write-stalled')
replayIntoTerminal(pane, ref, 'zombie restore bytes')
// No write reaches the dead pipeline and no guard is engaged — the
// watchdog-driven restore loop can no longer produce the wedged drip.
expect(terminal.lastData).toHaveLength(0)
expect(isPaneReplaying(ref, 1)).toBe(false)
_resetWritePipelineHealthForTests(pane.terminal)
})
it('resolves async replays into a certified-dead terminal without writing', async () => {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
notifyUndeliverableWrite(pane.terminal, 'replay-wedged')
await replayIntoTerminalAsync(pane, ref, 'zombie restore bytes')
expect(terminal.lastData).toHaveLength(0)
expect(isPaneReplaying(ref, 1)).toBe(false)
_resetWritePipelineHealthForTests(pane.terminal)
})
it('a healthy parse completion never notifies pane recovery', () => {
vi.useFakeTimers()
try {
const ref = makeRef()
const { pane, terminal } = makeFakePane(1)
const onUndeliverable = vi.fn()
registerUndeliverableWriteHandler(pane.terminal, onUndeliverable)
replayIntoTerminal(pane, ref, 'healthy bytes', { stallCheckMs: 400 })
terminal.flush()
vi.advanceTimersByTime(1000)
expect(onUndeliverable).not.toHaveBeenCalled()
_resetWritePipelineHealthForTests(pane.terminal)
} finally {
vi.useRealTimers()
}
})
it('parse completion cancels the stall probe without over-releasing', () => {
vi.useFakeTimers()
try {
@@ -2,6 +2,10 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-foreground-render-settle'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
import {
isTerminalWritePipelineCertifiedDead,
notifyUndeliverableWrite
} from '@/lib/pane-manager/terminal-write-pipeline-health'
// Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`,
// DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by
@@ -105,6 +109,9 @@ function engageReplayGuard(
`[terminal] replay guard released for pane ${paneId} — the probe write never parsed (wedged xterm write pipeline; pane likely needs recovery)`
)
recordRendererCrashBreadcrumb('terminal_replay_guard_wedged_release', { paneId })
// Why: the probe already certified the pipeline dead — hand the pane to
// recovery instead of leaving a fossil frame that eats keystrokes.
notifyUndeliverableWrite(terminal, 'replay-wedged')
}
onRelease?.()
}
@@ -141,6 +148,13 @@ export function replayIntoTerminal(
if (!data) {
return
}
// Why: a probe-certified dead pipeline can never parse this replay — each
// attempt only re-arms a guard destined for another wedged release (the
// production "zombie drip": restore retries every watchdog heal, forever).
// Recovery owns the pane once certified; skip the futile write.
if (isTerminalWritePipelineCertifiedDead(pane.terminal)) {
return
}
ensureArabicShapingJoinerForText(pane.terminal, data)
const releaseParsed = engageReplayGuard(
replayingPanesRef.current,
@@ -167,6 +181,11 @@ export function replayIntoTerminalAsync(
if (!data) {
return Promise.resolve()
}
// Why: same certified-dead short-circuit as replayIntoTerminal; resolve so
// awaited restore chains complete instead of hanging on a dead parser.
if (isTerminalWritePipelineCertifiedDead(pane.terminal)) {
return Promise.resolve()
}
ensureArabicShapingJoinerForText(pane.terminal, data)
return new Promise((resolve) => {
// Why resolve on either release path: callers await this to sequence
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
_resetTerminalPaneRecoveryForTests,
requestTerminalPaneRecovery
} from './terminal-pane-recovery'
const mocks = vi.hoisted(() => ({
remountTerminalTabForRecovery: vi.fn<(tabId: string) => boolean>(() => true),
recordRendererCrashBreadcrumb: vi.fn(),
hasPty: vi.fn<(id: string) => Promise<boolean | null>>(async () => true)
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
remountTerminalTabForRecovery: mocks.remountTerminalTabForRecovery
})
}
}))
vi.mock('@/lib/crash-breadcrumb-recorder', () => ({
recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb
}))
beforeEach(() => {
_resetTerminalPaneRecoveryForTests()
mocks.remountTerminalTabForRecovery.mockClear()
mocks.remountTerminalTabForRecovery.mockReturnValue(true)
mocks.recordRendererCrashBreadcrumb.mockClear()
mocks.hasPty.mockClear()
mocks.hasPty.mockResolvedValue(true)
vi.stubGlobal('window', {
api: { pty: { hasPty: mocks.hasPty } }
})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('requestTerminalPaneRecovery', () => {
it('remounts the tab and records a breadcrumb for a certified-dead pipeline', async () => {
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'pty-1',
reason: 'write-stalled'
})
expect(result).toBe(true)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1')
expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_pane_recovery_remount',
{ tabId: 'tab-1', reason: 'write-stalled' }
)
// Pipeline-death reasons are already probe-certified — no liveness gate.
expect(mocks.hasPty).not.toHaveBeenCalled()
})
it('coalesces repeat requests inside the cooldown window', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' })
).toBe(true)
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'replay-wedged' })
).toBe(false)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(1)
vi.setSystemTime(16_000)
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'replay-wedged' })
).toBe(true)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2)
})
it('caps recoveries per window to prevent remount storms', async () => {
vi.useFakeTimers()
for (let attempt = 0; attempt < 5; attempt += 1) {
vi.setSystemTime(attempt * 20_000)
await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'pty-1',
reason: 'write-stalled'
})
}
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3)
})
it('a window-cap decline schedules a retry that heals when the window reopens', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
for (let attempt = 0; attempt < 3; attempt += 1) {
vi.setSystemTime(attempt * 20_000)
await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'pty-1',
reason: 'write-stalled'
})
}
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3)
// Cap-declined: without a retry this pane is a permanent zombie — its
// certified-dead xterm no longer produces write signals to re-request.
vi.setSystemTime(60_000)
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' })
).toBe(false)
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'replay-wedged' })
).toBe(false)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3)
// One retry (deduped across the two declines) fires once the first
// attempt ages out of the window, and remounts.
await vi.advanceTimersByTimeAsync(250_000)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(400_000)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(4)
})
it('a cooldown decline schedules no retry (the tab remount already covers it)', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' })
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'replay-wedged' })
).toBe(false)
await vi.advanceTimersByTimeAsync(600_000)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(1)
})
it('budgets tabs independently', async () => {
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' })
).toBe(true)
expect(
await requestTerminalPaneRecovery({ tabId: 'tab-2', ptyId: 'pty-2', reason: 'write-stalled' })
).toBe(true)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2)
})
it('skips input-undeliverable recovery when the PTY is confirmed dead', async () => {
mocks.hasPty.mockResolvedValue(false)
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'pty-1',
reason: 'input-undeliverable'
})
expect(result).toBe(false)
expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled()
})
it('recovers input-undeliverable panes when the PTY is alive', async () => {
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'pty-1',
reason: 'input-undeliverable'
})
expect(result).toBe(true)
expect(mocks.hasPty).toHaveBeenCalledWith('pty-1')
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1')
})
it('proceeds when PTY liveness is unknown (probe threw)', async () => {
mocks.hasPty.mockRejectedValue(new Error('ipc down'))
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'pty-1',
reason: 'input-undeliverable'
})
expect(result).toBe(true)
})
it('requires a ptyId for input-undeliverable recovery', async () => {
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: null,
reason: 'input-undeliverable'
})
expect(result).toBe(false)
expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled()
})
it('requires authoritative liveness for remote panes (null hasPty blocks recovery)', async () => {
mocks.hasPty.mockResolvedValue(null)
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'remote:pty-1',
reason: 'input-undeliverable',
requireAuthoritativeLiveness: true
})
expect(result).toBe(false)
expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled()
})
it('recovers a remote pane when liveness is authoritative true', async () => {
mocks.hasPty.mockResolvedValue(true)
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'remote:pty-1',
reason: 'input-undeliverable',
requireAuthoritativeLiveness: true
})
expect(result).toBe(true)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1')
})
it('blocks remote recovery when the liveness probe throws', async () => {
mocks.hasPty.mockRejectedValue(new Error('runtime unreachable'))
const result = await requestTerminalPaneRecovery({
tabId: 'tab-1',
ptyId: 'remote:pty-1',
reason: 'input-undeliverable',
requireAuthoritativeLiveness: true
})
expect(result).toBe(false)
expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled()
})
it('never throws when the store surface is partial (timer/callback contexts)', async () => {
// Regression: recovery fires from stall-watch timers and write callbacks;
// an environment with a partial store (mocked suites, teardown races) must
// get a false return, not an unhandled TypeError.
mocks.remountTerminalTabForRecovery.mockImplementation(() => {
throw new TypeError('remountTerminalTabForRecovery is not a function')
})
await expect(
requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' })
).resolves.toBe(false)
// The failure must leave a trace — it is the only forensic signal for a
// production remount-failure loop (budget unconsumed → cooldown retries).
expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_pane_recovery_failed',
{ tabId: 'tab-1', reason: 'write-stalled' }
)
})
it('does not consume budget when the tab no longer exists', async () => {
mocks.remountTerminalTabForRecovery.mockReturnValue(false)
const result = await requestTerminalPaneRecovery({
tabId: 'tab-gone',
ptyId: 'pty-1',
reason: 'write-stalled'
})
expect(result).toBe(false)
expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,186 @@
import { useAppStore } from '@/store'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
// Why this module exists: a terminal pane can die renderer-side while its PTY
// stays alive — a wedged xterm WriteBuffer (issue #2836), a disposed xterm
// still receiving writes, or a transport that lost its PTY binding across a
// remount race. Every pre-existing recovery path (dead-session reconcile,
// hibernation wake) gates on the PTY being dead, so these panes stayed
// fossils: last frame painted, every keystroke silently dropped, until the
// user reloaded the window (issue #8104 class). Recovery here reuses the
// proven remount seam — bumping the tab's generation unmounts TerminalPane,
// detach() preserves the live PTY, and the remounted pane builds a fresh
// xterm that reattaches and replays the daemon snapshot. No shell restart.
export type TerminalPaneRecoveryReason = 'write-stalled' | 'replay-wedged' | 'input-undeliverable'
type RecoveryRequest = {
tabId: string
ptyId: string | null
reason: TerminalPaneRecoveryReason
/** Remote panes (runtime mirrors, app-SSH) must prove the PTY alive before
* an input-undeliverable remount: pty:hasPty answers null for ids the local
* registry doesn't own, and treating null as "proceed" would let a
* disconnected remote pane churn reconnects on every cooldown window. */
requireAuthoritativeLiveness?: boolean
}
// Why a cap exists: recovery must never loop. If the remounted pane wedges
// again (e.g. a deterministic parser throw in restored content), repeated
// bumps would remount-storm. The window is generous because a legitimate
// second recovery (new wedge minutes later) should still work.
const MAX_RECOVERIES_PER_WINDOW = 3
const RECOVERY_WINDOW_MS = 5 * 60_000
// Why a cooldown exists: one incident can trip several detectors (stall watch,
// replay guard, input path) within seconds; the first remount fixes all of
// them, the rest must coalesce instead of re-remounting mid-reattach.
const RECOVERY_COOLDOWN_MS = 15_000
const recoveryTimestampsByTabId = new Map<string, number[]>()
const pendingRetryTimerByTabId = new Map<string, ReturnType<typeof setTimeout>>()
type RecoveryBudget =
| { allowed: true }
| { allowed: false; declinedBy: 'window-cap'; retryInMs: number }
| { allowed: false; declinedBy: 'cooldown' }
function recoveryBudget(tabId: string, now: number): RecoveryBudget {
const timestamps = recoveryTimestampsByTabId.get(tabId) ?? []
const recent = timestamps.filter((t) => now - t < RECOVERY_WINDOW_MS)
if (recent.length !== timestamps.length) {
recoveryTimestampsByTabId.set(tabId, recent)
}
if (recent.length >= MAX_RECOVERIES_PER_WINDOW) {
return {
allowed: false,
declinedBy: 'window-cap',
retryInMs: recent[0] + RECOVERY_WINDOW_MS - now
}
}
const last = recent.at(-1)
if (last !== undefined && now - last < RECOVERY_COOLDOWN_MS) {
return { allowed: false, declinedBy: 'cooldown' }
}
return { allowed: true }
}
/**
* Why cap-declines retry and cooldown-declines do not: a cooldown decline
* means a remount just happened (or is imminent) for this TAB, and remounts
* are tab-scoped — every pane's xterm gets replaced, so a pane that dies
* again re-certifies on its fresh xterm and files a fresh request. A
* window-cap decline is the opposite: the pane keeps its certified-dead
* xterm, replays into it are short-circuited (nothing will ever re-certify),
* so without a scheduled retry the pane is a permanent zombie — the exact
* production drip this module exists to end.
*/
function scheduleRecoveryRetry(request: RecoveryRequest, delayMs: number): void {
if (pendingRetryTimerByTabId.has(request.tabId)) {
return
}
const timer = setTimeout(
() => {
pendingRetryTimerByTabId.delete(request.tabId)
void requestTerminalPaneRecovery(request)
},
Math.max(delayMs, 1_000)
)
pendingRetryTimerByTabId.set(request.tabId, timer)
}
function cancelPendingRecoveryRetry(tabId: string): void {
const timer = pendingRetryTimerByTabId.get(tabId)
if (timer !== undefined) {
clearTimeout(timer)
pendingRetryTimerByTabId.delete(tabId)
}
}
/**
* Remount the pane's tab to rebuild its renderer over the live PTY. Returns
* true when a remount was actually requested.
*
* For 'input-undeliverable' the PTY is liveness-checked first: a dead PTY is
* the dead-session reconcile's job (it tears down and reports "Process
* exited"), and remounting there would race it.
*/
export async function requestTerminalPaneRecovery(request: RecoveryRequest): Promise<boolean> {
const budget = recoveryBudget(request.tabId, Date.now())
if (!budget.allowed) {
if (budget.declinedBy === 'window-cap') {
scheduleRecoveryRetry(request, budget.retryInMs)
}
return false
}
if (request.reason === 'input-undeliverable') {
if (!request.ptyId) {
return false
}
try {
const live = await window.api.pty.hasPty(request.ptyId)
if (live === false) {
return false
}
if (request.requireAuthoritativeLiveness && live !== true) {
return false
}
} catch {
if (request.requireAuthoritativeLiveness) {
return false
}
// Liveness unknown (IPC hiccup) on a local pane: proceed — a remount
// over a dead PTY degrades to the existing dead-pane rendering, not a
// broken state.
}
// Re-check the budget across the await: a concurrent detector may have
// already consumed it for this tab.
const recheck = recoveryBudget(request.tabId, Date.now())
if (!recheck.allowed) {
if (recheck.declinedBy === 'window-cap') {
scheduleRecoveryRetry(request, recheck.retryInMs)
}
return false
}
}
let remounted = false
try {
remounted = useAppStore.getState().remountTerminalTabForRecovery(request.tabId)
} catch {
// Why: recovery fires from timer and write-callback contexts (stall watch,
// replay guard, onData) — it is best-effort by contract and must never
// surface a throw there (partial store surfaces in tests, teardown races).
// The breadcrumb is the only trace of a production failure loop here: the
// budget was not consumed, so the detector will retry each cooldown.
// recordRendererCrashBreadcrumb is itself guarded and cannot throw.
recordRendererCrashBreadcrumb('terminal_pane_recovery_failed', {
tabId: request.tabId,
reason: request.reason
})
return false
}
if (!remounted) {
return false
}
const timestamps = recoveryTimestampsByTabId.get(request.tabId) ?? []
timestamps.push(Date.now())
recoveryTimestampsByTabId.set(request.tabId, timestamps)
// A remount replaces every pane xterm in the tab; a previously scheduled
// retry would only re-remount the fresh, healthy panes.
cancelPendingRecoveryRetry(request.tabId)
console.error(
`[terminal] recovering pane tab ${request.tabId} — ${request.reason} with a live PTY (${request.ptyId ?? 'unbound'}); remounting to rebuild the renderer`
)
recordRendererCrashBreadcrumb('terminal_pane_recovery_remount', {
tabId: request.tabId,
reason: request.reason
})
return true
}
export function _resetTerminalPaneRecoveryForTests(): void {
recoveryTimestampsByTabId.clear()
for (const timer of pendingRetryTimerByTabId.values()) {
clearTimeout(timer)
}
pendingRetryTimerByTabId.clear()
}
@@ -112,9 +112,8 @@ describe('agent hook completion notifications', () => {
// Why: the Codex permission-pause tests share a working→pause→quiet-window
// sequence; centralizing it keeps the debounce advance (issue #8387) in one spot.
async function observeCodexPermissionPause(state: 'waiting' | 'blocked'): Promise<void> {
const { observeAgentHookCompletionForNotification } = await import(
'./agent-hook-completion-notifications'
)
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
@@ -4873,6 +4873,104 @@ describe('useIpcEvents agent status snapshot integration', () => {
)
})
it('does not recurse when flushing a pending status re-enters via the store subscriber', async () => {
// Repro for crash 9fc89529 (RangeError: Maximum call stack size exceeded):
// the store subscriber calls flushPendingAgentStatuses() on every update.
// flush -> applyAgentStatus -> store.setAgentStatus notifies subscribers
// synchronously (like Zustand) -> subscriber -> flush again while the same
// event is still queued -> infinite recursion. Model setAgentStatus with a
// real synchronous notify so the re-entrancy is exercised end to end.
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
let setAgentStatusCalls = 0
const notify = (): void => {
const listener = subscribeListenerRef.current
if (listener) {
listener(storeState, storeState)
}
}
const storeState: StoreLike = buildStoreState({
// Why: mirror Zustand — a state mutation notifies subscribers synchronously.
setAgentStatus: (paneKey: string, entry: unknown) => {
setAgentStatusCalls += 1
storeState.agentStatusByPaneKey = {
...(storeState.agentStatusByPaneKey as Record<string, unknown>),
[paneKey]: entry
}
notify()
},
workspaceSessionReady: true,
settings: { terminalFontSize: 13, notifications: { enabled: false } },
// Pane does not exist yet -> the incoming event is buffered as pending.
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn((listener: StoreSubscribeListener) => {
subscribeListenerRef.current = listener
return () => {
subscribeListenerRef.current = null
}
}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
if (typeof subscribeListenerRef.current !== 'function') {
throw new Error('Expected useAppStore.subscribe listener to be registered')
}
// Event lands before the tab exists -> buffered as a pending retry.
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'p',
agentType: 'claude',
receivedAt: 1_700_000_000_100,
stateStartedAt: 1_699_999_999_100
})
expect(setAgentStatusCalls).toBe(0)
// Tab hydrates; the next store update flushes the pending event. Without the
// re-entrancy guard this overflows the stack instead of applying once.
storeState.tabsByWorktree = {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
}
storeState.terminalLayoutsByTabId = {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
}
expect(() => notify()).not.toThrow()
// Applied exactly once — the re-entrant flush is a no-op, not a loop.
expect(setAgentStatusCalls).toBe(1)
})
it('applies ready push events for an unmounted inactive terminal tab', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
+31 -14
View File
@@ -842,6 +842,11 @@ export function useIpcEvents(): void {
type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped'
const pendingAgentStatusEvents: PendingAgentStatusEvent[] = []
let pendingAgentStatusRetryTimer: ReturnType<typeof setTimeout> | null = null
// Why: applyAgentStatus -> store.setAgentStatus notifies the store
// subscriber synchronously, which re-enters flushPendingAgentStatuses while
// the queue is still mid-drain. Guard against that re-entrancy so the same
// event is not reprocessed forever (crash 9fc89529: stack overflow).
let isFlushingAgentStatuses = false
unsubs.push(attachMobileMarkdownBridge())
@@ -2917,25 +2922,37 @@ export function useIpcEvents(): void {
}
function flushPendingAgentStatuses(): void {
// Why: a re-entrant call (store subscriber firing during a setAgentStatus
// inside the loop below) must not reprocess the still-queued events — the
// outer flush already owns them. Bailing here breaks the infinite
// recursion without dropping work; the outer loop finishes the drain.
if (isFlushingAgentStatuses) {
return
}
if (pendingAgentStatusEvents.length === 0) {
return
}
const now = Date.now()
const remaining: PendingAgentStatusEvent[] = []
for (const event of pendingAgentStatusEvents) {
if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
continue
isFlushingAgentStatuses = true
try {
const now = Date.now()
const remaining: PendingAgentStatusEvent[] = []
for (const event of pendingAgentStatusEvents) {
if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
continue
}
const result = applyAgentStatus(event.data, { retry: true })
if (result === 'pending') {
remaining.push(event)
}
}
const result = applyAgentStatus(event.data, { retry: true })
if (result === 'pending') {
remaining.push(event)
pendingAgentStatusEvents.length = 0
pendingAgentStatusEvents.push(...remaining)
if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
pendingAgentStatusRetryTimer = null
}
}
pendingAgentStatusEvents.length = 0
pendingAgentStatusEvents.push(...remaining)
if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
pendingAgentStatusRetryTimer = null
} finally {
isFlushingAgentStatuses = false
}
schedulePendingAgentStatusFlush()
}
+5 -1
View File
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "Split terminal"
},
"TerminalTabLeadingIcon": {
"7ab2964bea": "Unread agent completion"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "Type your answer",
"cancel": "Cancel",
"send": "Send answer",
"next": "Next"
"next": "Next",
"skip": "Skip"
},
"approval": {
"title": "Allow {{value0}}?",
+5 -1
View File
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "Dividir terminal"
},
"TerminalTabLeadingIcon": {
"7ab2964bea": "Finalización de agente sin leer"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "Escribe tu respuesta",
"cancel": "Cancelar",
"send": "Enviar respuesta",
"next": "Siguiente"
"next": "Siguiente",
"skip": "Omitir"
},
"approval": {
"title": "¿Permitir {{value0}}?",
+5 -1
View File
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "ターミナルを分割"
},
"TerminalTabLeadingIcon": {
"7ab2964bea": "未読のエージェント完了"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "答えを入力してください",
"cancel": "キャンセル",
"send": "回答を送信する",
"next": "次へ"
"next": "次へ",
"skip": "スキップ"
},
"approval": {
"title": "{{value0}} を許可しますか?",
+5 -1
View File
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "터미널 분할"
},
"TerminalTabLeadingIcon": {
"7ab2964bea": "읽지 않은 에이전트 완료"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "답변을 입력하세요",
"cancel": "취소",
"send": "답변 보내기",
"next": "다음"
"next": "다음",
"skip": "건너뛰기"
},
"approval": {
"title": "{{value0}}을(를) 허용하시겠습니까?",
+5 -1
View File
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "拆分终端"
},
"TerminalTabLeadingIcon": {
"7ab2964bea": "未读的代理完成"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "输入你的回答",
"cancel": "取消",
"send": "发送回答",
"next": "下一步"
"next": "下一步",
"skip": "跳过"
},
"approval": {
"title": "允许 {{value0}}?",
@@ -499,7 +499,7 @@ describe('pane terminal output scheduler', () => {
vi.advanceTimersByTime(50)
expect(terminal.write).toHaveBeenCalledTimes(1)
expect(terminal.write).toHaveBeenCalledWith('ab')
expect(terminal.write).toHaveBeenCalledWith('ab', expect.any(Function))
})
it('runs parsed callbacks after background output parses without foreground refresh', async () => {
@@ -557,10 +557,15 @@ describe('pane terminal output scheduler', () => {
vi.advanceTimersByTime(50)
expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024])
expect(parseCallbacks).toHaveLength(1)
// Why 2: every slice now carries a completion callback (it settles the
// write-pipeline stall watch); onParsed still fires only with the final one.
expect(parseCallbacks).toHaveLength(2)
expect(onParsed).not.toHaveBeenCalled()
parseCallbacks[0]?.()
expect(onParsed).not.toHaveBeenCalled()
parseCallbacks[1]?.()
expect(onParsed).toHaveBeenCalledTimes(1)
})
@@ -1000,7 +1005,7 @@ describe('pane terminal output scheduler', () => {
expect(beforeWrite).toHaveBeenCalledTimes(1)
expect(beforeWrite).toHaveBeenCalledWith('ab')
expect(terminal.write).toHaveBeenCalledWith('ab')
expect(terminal.write).toHaveBeenCalledWith('ab', expect.any(Function))
})
it('keeps preparation attached when a later producer omits it', async () => {
@@ -1014,7 +1019,7 @@ describe('pane terminal output scheduler', () => {
vi.advanceTimersByTime(50)
expect(beforeWrite).toHaveBeenCalledWith('مرحبا fallback notice')
expect(terminal.write).toHaveBeenCalledWith('مرحبا fallback notice')
expect(terminal.write).toHaveBeenCalledWith('مرحبا fallback notice', expect.any(Function))
})
it('ignores unforced chunks when resolving a coalesced forced refresh', async () => {
@@ -1051,7 +1056,7 @@ describe('pane terminal output scheduler', () => {
expect(beforeWrite).toHaveBeenCalledTimes(1)
expect(beforeWrite).toHaveBeenCalledWith('hidden')
expect(terminal.write).toHaveBeenCalledWith('hidden')
expect(terminal.write).toHaveBeenCalledWith('hidden', expect.any(Function))
})
it('supports bounded explicit flushes for visibility resume', async () => {
@@ -1081,12 +1086,12 @@ describe('pane terminal output scheduler', () => {
})
vi.advanceTimersByTime(50)
expect(terminals[0].write).toHaveBeenCalledWith('pane-0')
expect(terminals[1].write).toHaveBeenCalledWith('pane-1')
expect(terminals[0].write).toHaveBeenCalledWith('pane-0', expect.any(Function))
expect(terminals[1].write).toHaveBeenCalledWith('pane-1', expect.any(Function))
expect(terminals[2].write).not.toHaveBeenCalled()
vi.advanceTimersByTime(16)
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
expect(terminals[2].write).toHaveBeenCalledWith('pane-2', expect.any(Function))
})
it('drains active foreground backlog before older background terminal backlog', async () => {
@@ -1126,14 +1131,14 @@ describe('pane terminal output scheduler', () => {
vi.advanceTimersByTime(50)
expect(terminals[0].write).toHaveBeenCalledTimes(1)
expect(terminals[1].write).toHaveBeenCalledWith('pane-1')
expect(terminals[1].write).toHaveBeenCalledWith('pane-1', expect.any(Function))
expect(terminals[2].write).not.toHaveBeenCalled()
// Why: a terminal with leftover bytes is deleted/re-set after each drain
// chunk, moving it to the back of the Map so a big burst cannot starve
// other queued panes.
vi.advanceTimersByTime(16)
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
expect(terminals[2].write).toHaveBeenCalledWith('pane-2', expect.any(Function))
expect(terminals[0].write).toHaveBeenCalledTimes(2)
})
@@ -17,6 +17,10 @@ import {
discardInFlightTerminalOutputAckCredits,
registerTerminalOutputAckCredits
} from './pane-terminal-output-ack-credit'
import {
armTerminalWriteStallWatch,
settleTerminalWriteStallWatch
} from './terminal-write-pipeline-health'
import {
TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS,
terminalOutputBacklogCapChars
@@ -924,19 +928,21 @@ function makeParseClockPacer(): () => void {
}
function composeParsedCallback(
terminal: TerminalOutputTarget,
onParsed: TerminalOutputParsedCallback | undefined,
ackCreditsParsed: (() => void) | undefined,
pacer: (() => void) | undefined
): TerminalOutputParsedCallback | undefined {
if (!onParsed && !ackCreditsParsed && !pacer) {
return undefined
}
): TerminalOutputParsedCallback {
// Why always non-undefined: the write-completion callback doubles as the
// pipeline-health settle signal. A write with no callback could never settle
// the stall watch, forcing a probe round-trip for every healthy idle pane.
return () => {
try {
onParsed?.()
} finally {
ackCreditsParsed?.()
pacer?.()
settleTerminalWriteStallWatch(terminal)
}
}
}
@@ -948,6 +954,16 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
}
const pacer = entry.highPriority ? makeParseClockPacer() : undefined
const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits)
// Why armed BEFORE the write: a wedged WriteBuffer (escaping callback throw,
// issue #2836) or a disposed xterm (write() silently drops its completion —
// verified against 6.1.0-beta.287) never runs the parsed callback below. The
// stall watch probe-certifies dead-vs-slow and hands the pane to recovery;
// the entry cleanup credits queued deliveries so main's in-flight window
// never leaks. Arming first keeps a synchronously-completing write able to
// settle its own watch.
armTerminalWriteStallWatch(entry.terminal, {
onCertifiedDead: () => discardTerminalOutput(entry.terminal)
})
try {
queuedWrite.beforeWrite?.(queuedWrite.data)
if (queuedWrite.foreground) {
@@ -960,14 +976,19 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
forceViewportRefresh: queuedWrite.forceForegroundRefresh,
followupViewportRefresh: queuedWrite.followupForegroundRefresh,
shouldRefreshViewportSynchronously: queuedWrite.shouldRefreshForegroundSynchronously,
onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer)
onParsed: composeParsedCallback(
entry.terminal,
queuedWrite.onParsed,
ackCreditsParsed,
pacer
)
}
)
} else {
writeBackgroundTerminalChunk(
entry.terminal,
queuedWrite.data,
composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer)
composeParsedCallback(entry.terminal, queuedWrite.onParsed, ackCreditsParsed, pacer)
)
}
} catch {
@@ -1213,6 +1234,9 @@ export function writeTerminalOutput(
terminal,
options.ackCredit ? [options.ackCredit] : []
)
armTerminalWriteStallWatch(terminal, {
onCertifiedDead: () => discardTerminalOutput(terminal)
})
try {
options.beforeWrite?.(data)
writeForegroundTerminalChunkWithIntent(
@@ -1223,7 +1247,7 @@ export function writeTerminalOutput(
followupViewportRefresh: options.followupForegroundRefresh === true,
shouldRefreshViewportSynchronously:
options.shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY,
onParsed: composeParsedCallback(options.onParsed, ackCreditsParsed, undefined)
onParsed: composeParsedCallback(terminal, options.onParsed, ackCreditsParsed, undefined)
}
)
} catch (error) {
@@ -1296,6 +1320,9 @@ export function flushTerminalOutput(
debugState.flushWriteCount++
}
const ackCreditsParsed = registerTerminalOutputAckCredits(terminal, queuedWrite.ackCredits)
armTerminalWriteStallWatch(terminal, {
onCertifiedDead: () => discardTerminalOutput(terminal)
})
try {
queuedWrite.beforeWrite?.(queuedWrite.data)
if (queuedWrite.foreground) {
@@ -1308,14 +1335,19 @@ export function flushTerminalOutput(
forceViewportRefresh: queuedWrite.forceForegroundRefresh,
followupViewportRefresh: queuedWrite.followupForegroundRefresh,
shouldRefreshViewportSynchronously: queuedWrite.shouldRefreshForegroundSynchronously,
onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined)
onParsed: composeParsedCallback(
terminal,
queuedWrite.onParsed,
ackCreditsParsed,
undefined
)
}
)
} else {
writeBackgroundTerminalChunk(
terminal,
queuedWrite.data,
composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined)
composeParsedCallback(terminal, queuedWrite.onParsed, ackCreditsParsed, undefined)
)
}
} catch {
@@ -1408,6 +1440,9 @@ export function discardTerminalOutput(terminal: TerminalOutputTarget): void {
discardInFlightTerminalOutputAckCredits(terminal)
queuedByTerminal.delete(terminal)
discardForegroundRenderSettle(terminal)
// Why: a legitimately disposed pane must not leave a pending stall watch to
// probe the dead terminal later and report a false wedge.
settleTerminalWriteStallWatch(terminal)
recordQueueDebugPressure()
}
@@ -0,0 +1,206 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
_resetWritePipelineHealthForTests,
armTerminalWriteStallWatch,
isTerminalWritePipelineCertifiedDead,
notifyUndeliverableWrite,
registerUndeliverableWriteHandler,
settleTerminalWriteStallWatch,
WRITE_PIPELINE_STALL_CHECK_MS
} from './terminal-write-pipeline-health'
type FakeTerminal = {
write: (data: string, cb?: () => void) => void
pendingCallbacks: (() => void)[]
flush: () => void
throwOnWrite?: boolean
dropCallbacks?: boolean
}
function makeTerminal(): FakeTerminal {
const pendingCallbacks: (() => void)[] = []
const terminal: FakeTerminal = {
pendingCallbacks,
write(_data, cb) {
if (terminal.throwOnWrite) {
throw new Error('disposed')
}
if (cb && !terminal.dropCallbacks) {
pendingCallbacks.push(cb)
}
},
flush() {
while (pendingCallbacks.length > 0) {
pendingCallbacks.shift()?.()
}
}
}
return terminal
}
afterEach(() => {
vi.useRealTimers()
})
describe('terminal write pipeline health', () => {
it('a settled write never probes or notifies', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
const handler = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
armTerminalWriteStallWatch(terminal)
settleTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 3)
expect(terminal.pendingCallbacks).toHaveLength(0)
expect(handler).not.toHaveBeenCalled()
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(false)
_resetWritePipelineHealthForTests(terminal)
})
it('a slow-but-alive pipeline disarms when the probe parses', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
const handler = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
armTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS)
// Probe write queued; the pipeline is alive and parses it.
expect(terminal.pendingCallbacks).toHaveLength(1)
terminal.flush()
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 3)
expect(handler).not.toHaveBeenCalled()
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(false)
_resetWritePipelineHealthForTests(terminal)
})
it('certifies dead and notifies when the probe never parses (wedged pipeline)', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
terminal.dropCallbacks = true
const handler = vi.fn()
const onCertifiedDead = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
armTerminalWriteStallWatch(terminal, { onCertifiedDead })
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS)
expect(onCertifiedDead).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledWith('write-stalled')
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true)
_resetWritePipelineHealthForTests(terminal)
})
it('a throwing discard callback does not suppress the recovery notification', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
terminal.dropCallbacks = true
const handler = vi.fn()
const onCertifiedDead = vi.fn(() => {
throw new TypeError('window.api is gone')
})
registerUndeliverableWriteHandler(terminal, handler)
armTerminalWriteStallWatch(terminal, { onCertifiedDead })
expect(() => vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2)).not.toThrow()
expect(handler).toHaveBeenCalledWith('write-stalled')
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true)
_resetWritePipelineHealthForTests(terminal)
})
it('a throwing handler never escapes the certification timer', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
terminal.dropCallbacks = true
registerUndeliverableWriteHandler(terminal, () => {
throw new TypeError('partial store surface')
})
armTerminalWriteStallWatch(terminal)
expect(() => vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2)).not.toThrow()
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true)
_resetWritePipelineHealthForTests(terminal)
})
it('certifies dead immediately when the probe write throws (disposed terminal)', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
const handler = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
armTerminalWriteStallWatch(terminal)
terminal.throwOnWrite = true
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS)
expect(handler).toHaveBeenCalledWith('write-stalled')
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true)
_resetWritePipelineHealthForTests(terminal)
})
it('a settle landing between probe and certification cancels the pending verdict', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
terminal.dropCallbacks = true
const handler = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
armTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS)
// The stalled completion finally arrives while the probe is pending.
settleTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 3)
expect(handler).not.toHaveBeenCalled()
expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(false)
_resetWritePipelineHealthForTests(terminal)
})
it('notifies at most once per terminal instance', () => {
const terminal = makeTerminal()
const handler = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
notifyUndeliverableWrite(terminal, 'replay-wedged')
notifyUndeliverableWrite(terminal, 'write-stalled')
expect(handler).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledWith('replay-wedged')
_resetWritePipelineHealthForTests(terminal)
})
it('does not re-arm a certified-dead terminal', () => {
vi.useFakeTimers()
const terminal = makeTerminal()
terminal.dropCallbacks = true
const handler = vi.fn()
registerUndeliverableWriteHandler(terminal, handler)
armTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2)
expect(handler).toHaveBeenCalledTimes(1)
armTerminalWriteStallWatch(terminal)
vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 3)
expect(handler).toHaveBeenCalledTimes(1)
_resetWritePipelineHealthForTests(terminal)
})
it('unregistering a handler stops notifications', () => {
const terminal = makeTerminal()
const handler = vi.fn()
const unregister = registerUndeliverableWriteHandler(terminal, handler)
unregister()
notifyUndeliverableWrite(terminal, 'write-stalled')
expect(handler).not.toHaveBeenCalled()
_resetWritePipelineHealthForTests(terminal)
})
})
@@ -0,0 +1,152 @@
// Why this module exists: a pane's xterm write pipeline can die while its PTY
// stays alive — a synchronous throw escaping an unguarded write callback wedges
// WriteBuffer (issue #2836), and write() on a disposed terminal silently drops
// its completion callback (verified against vendored xterm 6.1.0-beta.287). In
// both states every later write queues forever: output stops rendering,
// delivery ack credits leak, and the pane becomes a fossil the user can only
// cure by reloading the window. Detection here is probe-certified (mirroring
// replay-guard.ts): a stalled completion triggers an empty probe write; xterm
// parses in FIFO order, so a probe that also never completes proves the
// pipeline is dead rather than slow. Certification notifies a per-terminal
// handler (registered by the pane's PTY connection) that requests pane
// recovery — a remount that rebuilds the xterm and reattaches the live PTY.
type WriteTarget = {
write(data: string, callback?: () => void): void
}
export type UndeliverableWriteReason = 'write-stalled' | 'replay-wedged'
type UndeliverableWriteHandler = (reason: UndeliverableWriteReason) => void
const handlersByTerminal = new WeakMap<object, UndeliverableWriteHandler>()
const certifiedDeadTerminals = new WeakSet<object>()
type StallWatch = {
timer: ReturnType<typeof setTimeout>
onCertifiedDead?: () => void
}
const stallWatchByTerminal = new WeakMap<object, StallWatch>()
export const WRITE_PIPELINE_STALL_CHECK_MS = 10_000
export function registerUndeliverableWriteHandler(
terminal: object,
handler: UndeliverableWriteHandler
): () => void {
handlersByTerminal.set(terminal, handler)
return () => {
if (handlersByTerminal.get(terminal) === handler) {
handlersByTerminal.delete(terminal)
}
}
}
/** One notification per terminal instance: recovery replaces the xterm, so a
* second notification for the same object is always a duplicate. */
export function notifyUndeliverableWrite(terminal: object, reason: UndeliverableWriteReason): void {
if (certifiedDeadTerminals.has(terminal)) {
return
}
certifiedDeadTerminals.add(terminal)
try {
handlersByTerminal.get(terminal)?.(reason)
} catch {
// Why: notify fires from timer and write-callback contexts where a throw
// becomes an unhandled error; recovery is best-effort by contract (see
// terminal-pane-recovery.ts).
}
}
export function isTerminalWritePipelineCertifiedDead(terminal: object): boolean {
return certifiedDeadTerminals.has(terminal)
}
/**
* Arm (or keep armed) the stall watch for a terminal that just had a write
* issued. Cleared by settleTerminalWriteStallWatch from the write-completion
* callback. If the completion never arrives, an empty probe write certifies
* dead-vs-slow exactly like replay-guard.ts: probe completes → pipeline is
* alive (slow parse), re-arm and keep waiting; probe silent for another
* interval → dead, notify.
*/
export function armTerminalWriteStallWatch(
terminal: WriteTarget,
options: { onCertifiedDead?: () => void; stallCheckMs?: number } = {}
): void {
if (stallWatchByTerminal.has(terminal) || certifiedDeadTerminals.has(terminal)) {
return
}
const stallCheckMs = options.stallCheckMs ?? WRITE_PIPELINE_STALL_CHECK_MS
const watch: StallWatch = {
onCertifiedDead: options.onCertifiedDead,
timer: setTimeout(probeForStall, stallCheckMs)
}
const certifyDead = (): void => {
// Why the ownership check: a settle (healthy completion) can land between
// the probe write and this timeout; it removes the watch, and a stale
// timer must not certify a live pipeline dead.
if (stallWatchByTerminal.get(terminal) !== watch) {
return
}
stallWatchByTerminal.delete(terminal)
try {
watch.onCertifiedDead?.()
} catch {
// Why: the discard callback bottoms out in window.api (ack credits); a
// partial surface must not kill the timer unhandled — or suppress the
// recovery notification below, which is the whole point of certifying.
}
notifyUndeliverableWrite(terminal, 'write-stalled')
}
function probeForStall(): void {
if (stallWatchByTerminal.get(terminal) !== watch) {
return
}
let probeParsed = false
try {
terminal.write('', () => {
probeParsed = true
// Why: a parsed probe proves the pipeline is alive — the stalled
// completion was just slow. Disarm; the next write re-arms.
const current = stallWatchByTerminal.get(terminal)
if (current === watch) {
clearTimeout(current.timer)
stallWatchByTerminal.delete(terminal)
}
})
} catch {
certifyDead()
return
}
watch.timer = setTimeout(() => {
if (!probeParsed) {
certifyDead()
}
}, stallCheckMs)
}
stallWatchByTerminal.set(terminal, watch)
}
/** Write completed normally — the pipeline is healthy; drop any pending watch. */
export function settleTerminalWriteStallWatch(terminal: object): void {
const watch = stallWatchByTerminal.get(terminal)
if (!watch) {
return
}
clearTimeout(watch.timer)
stallWatchByTerminal.delete(terminal)
}
export function _resetWritePipelineHealthForTests(terminal?: object): void {
if (terminal) {
const watch = stallWatchByTerminal.get(terminal)
if (watch) {
clearTimeout(watch.timer)
}
stallWatchByTerminal.delete(terminal)
handlersByTerminal.delete(terminal)
certifiedDeadTerminals.delete(terminal)
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/xterm'
import { isXtermInstanceDisposed } from './xterm-instance-disposed'
describe('isXtermInstanceDisposed', () => {
it('tracks dispose on the real vendored xterm build', () => {
// Pinned against the vendored build on purpose: if an xterm upgrade moves
// the private field, this must fail loudly — the probe silently returning
// false would blind the zombie-pane instrumentation.
const terminal = new Terminal({ allowProposedApi: true })
expect(isXtermInstanceDisposed(terminal)).toBe(false)
terminal.dispose()
expect(isXtermInstanceDisposed(terminal)).toBe(true)
})
it('answers false for non-terminal shapes', () => {
expect(isXtermInstanceDisposed(null)).toBe(false)
expect(isXtermInstanceDisposed(undefined)).toBe(false)
expect(isXtermInstanceDisposed({})).toBe(false)
})
})
@@ -0,0 +1,13 @@
// Why a private-field probe: xterm exposes no public disposed flag, and
// write() on a disposed instance silently DROPS its completion callback — no
// throw, no event (verified against the vendored 6.1.0-beta.287). That silent
// drop is the invisible producer behind zombie panes: restore writes routed
// into a disposed instance leave zero trace of any kind. This probe exists to
// name that moment in breadcrumbs. `_core._store._isDisposed` is the only
// field that flips on dispose in the vendored build; the test pins it so a
// vendored upgrade that moves the field fails loudly instead of silently
// blinding the instrumentation.
export function isXtermInstanceDisposed(terminal: unknown): boolean {
const core = (terminal as { _core?: { _store?: { _isDisposed?: unknown } } } | null)?._core
return core?._store?._isDisposed === true
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
const WORKTREE_ID = 'repo1::/path/wt1'
function seedWorktreeWithTab(store: ReturnType<typeof createTestStore>): string {
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })]
}
})
return store.getState().createTab(WORKTREE_ID).id
}
describe('remountTerminalTabForRecovery', () => {
it('bumps the tab generation so TerminalPane remounts', () => {
const store = createTestStore()
const tabId = seedWorktreeWithTab(store)
const before = store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === tabId)
const remounted = store.getState().remountTerminalTabForRecovery(tabId)
expect(remounted).toBe(true)
const after = store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === tabId)
expect(after?.generation ?? 0).toBe((before?.generation ?? 0) + 1)
// Recovery is not user interaction — the remount's PTY updates must not
// reshuffle Recent, mirroring the activation-time generation bump.
expect(after?.pendingActivationSpawn).toBeTruthy()
})
it('leaves sibling tabs untouched', () => {
const store = createTestStore()
const tabId = seedWorktreeWithTab(store)
const siblingId = store.getState().createTab(WORKTREE_ID).id
store.getState().remountTerminalTabForRecovery(tabId)
const sibling = store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === siblingId)
expect(sibling?.generation ?? 0).toBe(0)
expect(sibling?.pendingActivationSpawn).toBeFalsy()
})
it('returns false when the tab no longer exists', () => {
const store = createTestStore()
seedWorktreeWithTab(store)
expect(store.getState().remountTerminalTabForRecovery('missing-tab')).toBe(false)
})
})
@@ -247,6 +247,14 @@ export type WorktreeSlice = {
*/
seedActiveWorktreeLastVisitedIfMissing: () => void
setActiveWorktree: (worktreeId: string | null) => void
/**
* Health-driven remount of one terminal tab: bumps the tab's generation so
* TerminalPane unmounts, detaches (preserving a live PTY), and remounts with
* a fresh xterm that reattaches and replays. Used by terminal-pane-recovery
* when a pane's write pipeline is certified dead or its input is
* undeliverable while the PTY is alive. Returns false when the tab is gone.
*/
remountTerminalTabForRecovery: (tabId: string) => boolean
setActiveFolderWorkspace: (folderWorkspaceId: string) => void
setRenamingWorktreeId: (request: string | WorktreeRenameRequest | null) => void
allWorktrees: () => Worktree[]
@@ -4395,6 +4395,41 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
})
},
remountTerminalTabForRecovery: (tabId) => {
let remounted = false
set((s) => {
for (const [worktreeId, tabs] of Object.entries(s.tabsByWorktree)) {
const index = tabs.findIndex((tab) => tab.id === tabId)
if (index < 0) {
continue
}
const tab = tabs[index]
const nextTabs = tabs.slice()
nextTabs[index] = {
...tab,
// Why: TerminalPane keys on `${tab.id}-${generation}` — the bump is
// the remount. Same mechanism as the dead-transport activation bump
// above; here it is health-driven for a pane whose renderer died
// while its PTY stayed alive (wedged/disposed xterm, unbound
// transport), so the remounted pane reattaches instead of spawning.
generation: (tab.generation ?? 0) + 1,
// Why: recovery is not a user interaction — suppress the resulting
// PTY updates from reshuffling Recent, like activation remounts do.
pendingActivationSpawn: getActivationSpawnSuppression(s.terminalLayoutsByTabId[tab.id])
}
remounted = true
return {
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: nextTabs
}
}
}
return {}
})
return remounted
},
setActiveWorktree: (worktreeId) => {
if (worktreeId && shouldDeferActivationTerminalPrep()) {
markInputQuietSchedulerInput()
+4
View File
@@ -104,6 +104,10 @@ export type HostedReviewCreationBlockedReason =
| 'fork_head_unsupported'
| 'unsupported_provider'
| 'existing_review'
// Why: a stacked worktree's local-only parent base is unresolvable on the
// remote; blocked at create-time so the submit fails with actionable copy
// instead of the provider's opaque error.
| 'base_not_on_remote'
| null
export type HostedReviewCreationNextAction =
@@ -0,0 +1,161 @@
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
focusActiveTerminalInput,
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
// Repro for the permanent frozen-pane state behind issue #8104-class reports:
// once a pane's xterm WriteBuffer wedges (an escaping throw from an unguarded
// write callback, or a write into a disposed terminal silently dropping its
// completion — both verified against vendored xterm 6.1.0-beta.287), every
// later write queues forever: output stops rendering while the PTY stays
// alive. The replay guard's probe certifies the wedge and fires the
// terminal_replay_guard_wedged_release breadcrumb ("pane likely needs
// recovery") — but nothing performs that recovery, so the pane stays a fossil
// until the user reloads the window. These tests pin the recovery contract.
async function wedgeActivePaneWritePipeline(
page: Parameters<typeof waitForSessionReady>[0]
): Promise<void> {
await page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to wedge')
}
// Same escape class as issue #2836: WriteBuffer._innerWrite invokes write
// callbacks with no try/catch; a synchronous throw skips the loop's tail
// re-schedule and write() only re-arms on an EMPTY buffer, so the pipeline
// never drains again.
pane.terminal.write('', () => {
throw new Error('e2e: simulated unguarded write-completion throw')
})
})
}
test.describe('Wedged terminal write pipeline recovery', () => {
test('pane recovers rendering and input after its write pipeline wedges', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await waitForActivePanePtyId(orcaPage)
await focusActiveTerminalInput(orcaPage)
// Prove the pane is healthy first.
const runId = Date.now()
const beforeMarker = `WEDGE_BASELINE_${runId}`
await orcaPage.keyboard.type(`echo ${beforeMarker}`, { delay: 20 })
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => (await getTerminalContent(orcaPage)).includes(beforeMarker), {
timeout: 15_000,
message: 'Baseline echo did not render — pane unhealthy before wedge'
})
.toBe(true)
await wedgeActivePaneWritePipeline(orcaPage)
// Type through the wedge. The PTY is alive: bytes reach the shell and the
// shell echoes them — but a wedged pipeline never parses the echo, so
// without recovery the marker never renders.
const afterMarker = `WEDGE_RECOVERED_${runId}`
await focusActiveTerminalInput(orcaPage)
await orcaPage.keyboard.type(`echo ${afterMarker}`, { delay: 20 })
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => (await getTerminalContent(orcaPage)).includes(afterMarker), {
// Generous: recovery is certified by a probe write with a 10s stall
// check, so the rebuild can legitimately take >20s to kick in.
timeout: 45_000,
message:
'Pane never rendered output typed after the write pipeline wedged — wedged pane was not recovered'
})
.toBe(true)
})
test('pane recovers after its xterm is disposed under live bindings (zombie pane)', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
await focusActiveTerminalInput(orcaPage)
const runId = Date.now()
const beforeMarker = `ZOMBIE_BASELINE_${runId}`
await orcaPage.keyboard.type(`echo ${beforeMarker}`, { delay: 20 })
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => (await getTerminalContent(orcaPage)).includes(beforeMarker), {
timeout: 15_000,
message: 'Baseline echo did not render — pane unhealthy before dispose'
})
.toBe(true)
// The production zombie: disposePane/teardown raced pane bindings, leaving
// delivery and input routed at a disposed xterm. write() on a disposed
// terminal silently drops its completion callback (verified against
// 6.1.0-beta.287), so delivery acks leak and keyboard onData never fires —
// the pane looks painted but is a fossil: input dead, output dead, PTY alive.
await orcaPage.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to dispose')
}
pane.terminal.dispose()
})
// Generate PTY output daemon-side; delivery into the disposed xterm is the
// health signal recovery must catch (typing can't be one here — a disposed
// xterm emits no onData at all).
const outputMarker = `ZOMBIE_OUTPUT_${runId}`
await sendToTerminal(orcaPage, ptyId, `echo ${outputMarker}\r`)
await expect
.poll(async () => (await getTerminalContent(orcaPage)).includes(outputMarker), {
timeout: 45_000,
message:
'Output written after the pane xterm was disposed never rendered — zombie pane was not recovered'
})
.toBe(true)
// Input must be live again end-to-end after recovery.
await focusActiveTerminalInput(orcaPage)
const typedMarker = `ZOMBIE_INPUT_${runId}`
await orcaPage.keyboard.type(`echo ${typedMarker}`, { delay: 20 })
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => (await getTerminalContent(orcaPage)).includes(typedMarker), {
timeout: 15_000,
message: 'Typed input never reached the PTY after zombie-pane recovery'
})
.toBe(true)
})
})