+ // 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.
+
{open ? (
-
+
{blocks.map((block, i) => (
))}
diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx
index 1f8af9bcef7..8caf7d835c8 100644
--- a/src/renderer/src/components/native-chat/NativeChatView.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatView.tsx
@@ -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 = {
- 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(null)
const composerRef = useRef(null)
+ // The question card's free-text row; keeps Paste working while the card
+ // replaces the composer.
+ const questionAnswerInputRef = useRef(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({
/>
)}
- {/* Live interactive cards (question / approval) render just above the
- composer while the agent's interactivePrompt is present (mobile parity). */}
-
+ {/* 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". */}
+
{/* 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). */}
-
+ {questionActive ? null : (
+
+ )}
{contextMenu.menu}
)
diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts
index 25efca9af09..c2fd1b87ed6 100644
--- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts
@@ -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 })
})
})
diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts
index 1f5721e07df..e1bb88625f8 100644
--- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts
+++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts
@@ -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. */
diff --git a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts
index 6eb2a4112e1..d79ab1f43c9 100644
--- a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts
+++ b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts
@@ -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 {
diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx
index ff208e0c504..affaa63ab8d 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx
@@ -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
= {
+ onSplitRight: () => {},
+ onSplitDown: () => {},
+ canEqualizePaneSizes: false,
+ onEqualizePaneSizes: () => {},
+ canExpandPane: false,
+ isPaneExpanded: false,
+ onToggleExpand: () => {},
+ onForkAgentSession: () => {},
+ onSetTitle: () => {},
+ onCopyTerminalId: () => {},
+ onCopyPaneId: () => {},
+ canClosePane: false,
+ onClosePane: () => {}
+}
+
export function useNativeChatContextMenu({
rootRef,
onSwitchToTerminal,
diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
index 36d7d8cb1ea..12e50ec0b12 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
@@ -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]
)
diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts
index b41b4fdf964..e3dcc9a10c8 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts
@@ -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
composerRef: RefObject
+ /** 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
}
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
}
diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts
index 74853dd4147..516c0711369 100644
--- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts
@@ -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({
diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts
index 7e183122ec5..566ee54e1d7 100644
--- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts
@@ -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() || ''
)
}
diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts
index e59354da5c8..2475a7d293e 100644
--- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts
@@ -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
}
diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
index 58bbfe9454e..62801dcfe31 100644
--- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
@@ -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'
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts
index ce4849a89f9..6ee7bce037f 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts
@@ -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)
}
diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts
index e23db872e1b..d09fc41cc32 100644
--- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts
+++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts
@@ -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')
diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
index a480689334e..08a3b311de6 100644
--- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
+++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
@@ -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(
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts
index b30c7815c1d..00fbbe84067 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts
@@ -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: [
diff --git a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts
index ffa17cbde9c..3a23c176ba7 100644
--- a/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts
+++ b/src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.ts
@@ -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: {
diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx
index 5c3b0c7dc24..21424792845 100644
--- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx
+++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx
@@ -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 (
diff --git a/src/renderer/src/components/task-page-work-item-pagination.test.ts b/src/renderer/src/components/task-page-work-item-pagination.test.ts
new file mode 100644
index 00000000000..b3bc3301d94
--- /dev/null
+++ b/src/renderer/src/components/task-page-work-item-pagination.test.ts
@@ -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 })
+ })
+})
diff --git a/src/renderer/src/components/task-page-work-item-pagination.ts b/src/renderer/src/components/task-page-work-item-pagination.ts
new file mode 100644
index 00000000000..a332fe675e2
--- /dev/null
+++ b/src/renderer/src/components/task-page-work-item-pagination.ts
@@ -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:<=`. 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): 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
+ /** 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 {
+ const { existingPages, initialCursor, targetPage, pageSize, fetchPage, isCancelled } = args
+
+ const seen = new Set()
+ 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 }
+}
diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
index 6a4c8b197cf..957e0a9bb1e 100644
--- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
+++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
@@ -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)
diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts
index 5f581cb9e94..0300bfd6b6e 100644
--- a/src/renderer/src/components/terminal-pane/layout-serialization.ts
+++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts
@@ -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)
+ })
}
}
}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 4c56bc6458f..a9c2d7515a6 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -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 = 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()
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
index b52e5bf59d8..c632f44d0c0 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
@@ -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 }
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts
index 4be710e2693..c933923ce4f 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
@@ -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(
diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts
index e69194db7cc..f872c22faca 100644
--- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts
+++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts
@@ -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 {
diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts
index fa2cc1913be..74d009b9bf3 100644
--- a/src/renderer/src/components/terminal-pane/replay-guard.ts
+++ b/src/renderer/src/components/terminal-pane/replay-guard.ts
@@ -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
diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts
new file mode 100644
index 00000000000..97913af41bf
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts
@@ -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>(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()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts
new file mode 100644
index 00000000000..73e45c1c906
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts
@@ -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()
+const pendingRetryTimerByTabId = new Map>()
+
+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 {
+ 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()
+}
diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts
index 1801c78bace..228af0058eb 100644
--- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts
+++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts
@@ -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 {
- const { observeAgentHookCompletionForNotification } = await import(
- './agent-hook-completion-notifications'
- )
+ const { observeAgentHookCompletionForNotification } =
+ await import('./agent-hook-completion-notifications')
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts
index ec7764ec287..bc0ca7fec92 100644
--- a/src/renderer/src/hooks/useIpcEvents.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents.test.ts
@@ -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),
+ [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 } = {
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index e341ff8a185..96697628bf4 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -842,6 +842,11 @@ export function useIpcEvents(): void {
type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped'
const pendingAgentStatusEvents: PendingAgentStatusEvent[] = []
let pendingAgentStatusRetryTimer: ReturnType | 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()
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index d6d8725c2d8..4fc41a01115 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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}}?",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index a2bbc411665..788d29209e5 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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}}?",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 8ce317e4ae4..b92cf73d46e 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "ターミナルを分割"
+ },
+ "TerminalTabLeadingIcon": {
+ "7ab2964bea": "未読のエージェント完了"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "答えを入力してください",
"cancel": "キャンセル",
"send": "回答を送信する",
- "next": "次へ"
+ "next": "次へ",
+ "skip": "スキップ"
},
"approval": {
"title": "{{value0}} を許可しますか?",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 37326de084a..05eacf25060 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "터미널 분할"
+ },
+ "TerminalTabLeadingIcon": {
+ "7ab2964bea": "읽지 않은 에이전트 완료"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "답변을 입력하세요",
"cancel": "취소",
"send": "답변 보내기",
- "next": "다음"
+ "next": "다음",
+ "skip": "건너뛰기"
},
"approval": {
"title": "{{value0}}을(를) 허용하시겠습니까?",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 060903dce0e..5cc57711f81 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -2884,6 +2884,9 @@
},
"TerminalTabSplitMenuSection": {
"splitTerminal": "拆分终端"
+ },
+ "TerminalTabLeadingIcon": {
+ "7ab2964bea": "未读的代理完成"
}
}
},
@@ -13038,7 +13041,8 @@
"otherPlaceholder": "输入你的回答",
"cancel": "取消",
"send": "发送回答",
- "next": "下一步"
+ "next": "下一步",
+ "skip": "跳过"
},
"approval": {
"title": "允许 {{value0}}?",
diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
index 363264c9cb3..e3e35134a8b 100644
--- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
+++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
@@ -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)
})
diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
index e12eeaccb89..5f604996757 100644
--- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
+++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
@@ -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()
}
diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts
new file mode 100644
index 00000000000..44bdfe0e372
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts
@@ -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)
+ })
+})
diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts
new file mode 100644
index 00000000000..4386a1b2fe3
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts
@@ -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