mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge latest main into PR review fixes
# Conflicts: # src/renderer/src/components/terminal-pane/pty-transport.ts
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1239
-926
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 |
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+20
-3
@@ -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() || ''
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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'
|
||||
|
||||
+3
-6
@@ -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 } = {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}}?",
|
||||
|
||||
@@ -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}}?",
|
||||
|
||||
@@ -2884,6 +2884,9 @@
|
||||
},
|
||||
"TerminalTabSplitMenuSection": {
|
||||
"splitTerminal": "ターミナルを分割"
|
||||
},
|
||||
"TerminalTabLeadingIcon": {
|
||||
"7ab2964bea": "未読のエージェント完了"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13038,7 +13041,8 @@
|
||||
"otherPlaceholder": "答えを入力してください",
|
||||
"cancel": "キャンセル",
|
||||
"send": "回答を送信する",
|
||||
"next": "次へ"
|
||||
"next": "次へ",
|
||||
"skip": "スキップ"
|
||||
},
|
||||
"approval": {
|
||||
"title": "{{value0}} を許可しますか?",
|
||||
|
||||
@@ -2884,6 +2884,9 @@
|
||||
},
|
||||
"TerminalTabSplitMenuSection": {
|
||||
"splitTerminal": "터미널 분할"
|
||||
},
|
||||
"TerminalTabLeadingIcon": {
|
||||
"7ab2964bea": "읽지 않은 에이전트 완료"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13038,7 +13041,8 @@
|
||||
"otherPlaceholder": "답변을 입력하세요",
|
||||
"cancel": "취소",
|
||||
"send": "답변 보내기",
|
||||
"next": "다음"
|
||||
"next": "다음",
|
||||
"skip": "건너뛰기"
|
||||
},
|
||||
"approval": {
|
||||
"title": "{{value0}}을(를) 허용하시겠습니까?",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user