Merge remote-tracking branch 'origin/main' into orca-performance

This commit is contained in:
Jinwoo-H
2026-07-04 02:47:20 -04:00
39 changed files with 1066 additions and 320 deletions
+17 -6
View File
@@ -153,7 +153,7 @@ function validateProtection(gate, failures) {
if (gate.flakeHistory?.status !== 'stable') {
failures.push(`${gate.id}: protection active gates must have stable flakeHistory`)
}
if (!['complete', 'not-required'].includes(gate.redGreenEvidence?.status)) {
if (!hasCompleteRedGreenEvidence(gate)) {
failures.push(`${gate.id}: protection active gates must have complete red/green evidence`)
}
}
@@ -166,6 +166,17 @@ function isIsoDate(value) {
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
}
// Why: cross-field validators run even after an earlier type check flagged a
// malformed field, so coerce to an array before `.includes` to report the error
// instead of throwing on hand-edited manifests.
function asArray(value) {
return Array.isArray(value) ? value : []
}
function hasCompleteRedGreenEvidence(gate) {
return ['complete', 'not-required'].includes(gate.redGreenEvidence?.status)
}
function validateEvidenceRun(gate, run, index, failures) {
const owner = `${gate.id}: evidenceRuns[${index}]`
if (!isRecord(run)) {
@@ -186,7 +197,7 @@ function validateEvidenceRun(gate, run, index, failures) {
}
if (!isNonEmptyString(run.command)) {
failures.push(`${owner}.command must be a non-empty string`)
} else if (!gate.commands.includes(run.command)) {
} else if (!asArray(gate.commands).includes(run.command)) {
failures.push(`${owner}.command must match one of the gate commands`)
}
if (!Number.isFinite(run.durationSeconds) || run.durationSeconds < 0) {
@@ -222,7 +233,7 @@ function validateAssertionRef(gate, ref, index, failures) {
}
if (!isNonEmptyString(ref.file)) {
failures.push(`${owner}.file must be a non-empty string`)
} else if (!gate.testFiles.includes(ref.file)) {
} else if (!asArray(gate.testFiles).includes(ref.file)) {
failures.push(`${owner}.file must be one of the gate testFiles`)
}
if (!hasNonEmptyStringArray(ref.assertions)) {
@@ -253,12 +264,12 @@ function validateCoveredScope(gate, failures) {
return
}
for (const platform of gate.coveredPlatforms) {
if (!gate.platforms.includes(platform)) {
if (!asArray(gate.platforms).includes(platform)) {
failures.push(`${gate.id}: covered platform is outside risk scope: ${platform}`)
}
}
for (const provider of gate.coveredProviders) {
if (!gate.providers.includes(provider)) {
if (!asArray(gate.providers).includes(provider)) {
failures.push(`${gate.id}: covered provider is outside risk scope: ${provider}`)
}
}
@@ -343,7 +354,7 @@ async function validateGate(gate, maturities, root) {
if (!['soaking', 'stable'].includes(gate.flakeHistory?.status)) {
failures.push(`${gate.id}: ${gate.maturity} gates must have soaking or stable flakeHistory`)
}
if (!['complete', 'not-required'].includes(gate.redGreenEvidence?.status)) {
if (!hasCompleteRedGreenEvidence(gate)) {
failures.push(`${gate.id}: ${gate.maturity} gates must have complete red/green evidence`)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.122-rc.0",
"version": "1.4.122-rc.2",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -46,14 +46,22 @@ export class IosEmulatorBackend implements EmulatorBackend {
logcat: false
}
private readonly serveSimExecutable: ServeSimExecutable
private cachedServeSimExecutable: ServeSimExecutable | undefined
private readonly waitForEndpointReady: (endpoint: string) => Promise<boolean>
constructor(options: EmulatorBridgeOptions = {}) {
this.serveSimExecutable = resolveServeSimExecutable()
this.waitForEndpointReady = options.waitForEndpointReady ?? waitForServeSimEndpointReady
}
// Why: resolving the executable can materialize the serve-sim runtime (a one-time
// recursive copy + xattr subprocess on macOS after each version bump). Defer it
// off the startup path — the bridge is constructed before the main window shows —
// so it only runs when an emulator command is actually issued.
private get serveSimExecutable(): ServeSimExecutable {
this.cachedServeSimExecutable ??= resolveServeSimExecutable()
return this.cachedServeSimExecutable
}
isSupportedOnHost(): boolean {
return platform() === 'darwin'
}
+46 -30
View File
@@ -211,10 +211,11 @@ let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
let keybindings: KeybindingService | null = null
let expectedRendererReload: { webContentsId: number; until: number } | null = null
// Why: the crash/freeze-recovery reload re-fires did-finish-load; flag it so the
// local-PTY orphan sweep spares live sessions across that one reload (#5787).
let recoveryReloadInFlight: { webContentsId: number; until: number } | null = null
// Why: a reload/teardown intent set for one renderer must not leak to a later load.
// The recovery reload re-fires did-finish-load, so its flag lets the local-PTY orphan
// sweep spare live sessions across that one reload (#5787).
const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
// Why: GPU child crashes clustered right after launch indicate a broken driver;
// track them so Orca can move this build onto software rendering.
@@ -437,51 +438,66 @@ function focusExistingWindow(): void {
})
}
// Why: a webContents-scoped flag that auto-expires so an intent set for one renderer
// can't leak to a later load. `consume` clears on a positive match for one-shot
// signals (the recovery reload fires exactly one did-finish-load).
function createWebContentsTimedFlag(defaultDurationMs = 10_000): {
mark: (webContentsId: number, durationMs?: number) => void
clear: (webContentsId?: number) => void
matches: (webContentsId: number, options?: { consume?: boolean }) => boolean
} {
let state: { webContentsId: number; until: number } | null = null
return {
mark(webContentsId, durationMs = defaultDurationMs) {
state = { webContentsId, until: Date.now() + durationMs }
},
clear(webContentsId) {
if (webContentsId === undefined || state?.webContentsId === webContentsId) {
state = null
}
},
matches(webContentsId, options) {
if (!state || Date.now() > state.until) {
state = null
return false
}
if (state.webContentsId !== webContentsId) {
return false
}
if (options?.consume) {
state = null
}
return true
}
}
}
function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void {
expectedRendererReload = { webContentsId, until: Date.now() + durationMs }
expectedRendererReload.mark(webContentsId, durationMs)
}
function clearExpectedRendererReload(webContentsId?: number): void {
if (webContentsId === undefined || expectedRendererReload?.webContentsId === webContentsId) {
expectedRendererReload = null
}
expectedRendererReload.clear(webContentsId)
}
function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope {
if (isQuitting || isQuittingForUpdate()) {
return 'app-shutdown'
}
if (!expectedRendererReload) {
if (webContentsId === undefined) {
return 'none'
}
if (Date.now() > expectedRendererReload.until) {
expectedRendererReload = null
return 'none'
}
return webContentsId !== undefined && expectedRendererReload.webContentsId === webContentsId
? 'renderer-reload'
: 'none'
return expectedRendererReload.matches(webContentsId) ? 'renderer-reload' : 'none'
}
function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void {
recoveryReloadInFlight = { webContentsId, until: Date.now() + durationMs }
recoveryReloadInFlight.mark(webContentsId, durationMs)
}
function isRecoveryReloadInFlight(webContentsId: number): boolean {
if (!recoveryReloadInFlight) {
return false
}
if (Date.now() > recoveryReloadInFlight.until) {
recoveryReloadInFlight = null
return false
}
if (recoveryReloadInFlight.webContentsId !== webContentsId) {
return false
}
// Why: consume on read — the recovery reload fires exactly one did-finish-load,
// so clearing here keeps a later genuine reload sweeping orphaned local PTYs.
recoveryReloadInFlight = null
return true
// so a later genuine reload still sweeps orphaned local PTYs.
return recoveryReloadInFlight.matches(webContentsId, { consume: true })
}
function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void {
+8 -26
View File
@@ -17,34 +17,16 @@ function isDisabledByEnv(): boolean {
}
/**
* Wrap a macOS POSIX shell spawn in `/usr/bin/login` so terminal children carry
* their own TCC identity instead of collapsing into Orca's bundle identifier.
* Wrap a macOS shell spawn in `/usr/bin/login -flpq <user> …` so terminal children
* get their own TCC identity instead of collapsing into Orca's bundle id — signed
* CLIs like `op` otherwise re-prompt every launch because tccd attributes the grant
* to Orca and never persists it (#6996). This mirrors how Terminal.app spawns shells.
*
* Why: when Orca spawns a shell directly, macOS attributes a spawned CLI's
* "access other apps' data" request (kTCCServiceSystemPolicyAppData) to Orca's
* bundle id and never persists the grant, so signed CLIs like `op` re-prompt on
* every launch (#6996). Native terminals (Terminal.app and others) launch shells
* through login(1), which lets tccd resolve each child's own code identity and
* remember the decision. This matches that spawn shape without a native patch.
* Why the env(1) interposition: login(1) overwrites SHELL from the account DB even
* under -p, so `/usr/bin/env SHELL=<shell>` re-asserts the shell Orca actually runs
* without disturbing login's attribution (skipped when the shell path contains `=`).
*
* Flags: -f (skip auth; we are the logged-in user relaunching as ourselves),
* -l (do not chdir to home — node-pty already set cwd — and skip the login
* dash-argv0 marker), -p (preserve Orca's env, including its ZDOTDIR shell
* integration), -q (suppress the login banner so wrapped terminals look
* unchanged). The underlying shell keeps its own args (e.g. zsh's `-l`) so
* login-shell behavior is unchanged.
*
* SHELL: even under -p, login(1) overwrites SHELL with the account shell from
* the user database, while Orca terminals deliberately export the shell they
* actually run (fallback shells, custom shell settings). Interposing
* `/usr/bin/env SHELL=<shell>` between login and the shell re-asserts the
* intended value — and, as a same-process exec, does not disturb login's TCC
* attribution. Skipped only if the shell path itself contains `=`, which env(1)
* would misparse as an assignment.
*
* No-op off macOS, when already wrapped, when the login binary or username is
* unavailable, or when disabled via {@link DISABLE_ENV_VAR}, so terminal
* spawning never regresses.
* No-op off macOS, when already wrapped, or when disabled via {@link DISABLE_ENV_VAR}.
*/
export function wrapShellSpawnForMacosTccAttribution(
file: string,
+6 -2
View File
@@ -24,8 +24,12 @@ const SESSION_RE = /current\s*session/i
const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i
const FABLE_WORD_RE = /\bfable\b/i
const FABLE_LABEL_RE = /^\s*fable\s*$/i
const FABLE_WEEKLY_LABEL_RE =
/(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)\s*(?:\([^)]*\bfable\b[^)]*\)|[-:]?\s*\bfable\b)/i
// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place
// instead of silently reopening the Fable-weekly parsing gap this fix closed.
const FABLE_WEEKLY_LABEL_RE = new RegExp(
`${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`,
'i'
)
const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
@@ -3626,7 +3626,8 @@ function BrowserPagePane({
let needsInitialDefaultZoom = ensuredWebview.created
if (!ensuredWebview.created) {
webview.style.pointerEvents = inputLockedRef.current ? 'none' : 'auto'
// pointerEvents is already applied inside ensureBrowserPageWebview for the
// reused-webview path, so it isn't repeated here.
syncNavigationState(webview)
// Why: seed the ref with the store URL so the URL sync effect does not
// force-navigate an already-mounted webview that is on the right page.
@@ -0,0 +1,24 @@
import type * as React from 'react'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import {
resolveNativeChatSession,
type NativeChatPaneResolution,
type NativeChatPaneResolutionInput
} from './native-chat-pane-resolution'
export type NativeChatSessionGateProps = NativeChatPaneResolutionInput & {
children: (resolution: NativeChatPaneResolution) => React.ReactNode
}
/** Keeps NativeChatView's agent/session resolution separate from the heavy
* conversation surface so unsupported panes fail before transcript IO starts. */
export function NativeChatSessionGate({
children,
...input
}: NativeChatSessionGateProps): React.JSX.Element {
const resolution = resolveNativeChatSession(input)
if (!resolution) {
return <NativeChatEmptyState kind="not-agent" />
}
return <>{children(resolution)}</>
}
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import type * as React from 'react'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { NativeChatSessionGate } from './NativeChatSessionGate'
function entry(overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>) {
return {
state: 'working' as const,
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
...overrides
}
}
function renderResolution(
props: Omit<React.ComponentProps<typeof NativeChatSessionGate>, 'children'>
): void {
render(
<NativeChatSessionGate {...props}>
{(resolution) => (
<div data-testid="native-chat-resolution">
{resolution.agent}:{resolution.sessionId ?? 'no-session'}:{resolution.paneKey}
</div>
)}
</NativeChatSessionGate>
)
}
describe('NativeChatSessionGate', () => {
afterEach(() => {
cleanup()
})
it.each(['codex', 'claude'] as const)(
'opens the resolved native chat session from a %s title fallback',
(resolvedAgent) => {
renderResolution({
paneKey: 'tab-1:leaf-1',
launchAgent: null,
resolvedAgent,
ptyId: 'pty-1'
})
expect(screen.getByTestId('native-chat-resolution')).toHaveTextContent(
`${resolvedAgent}:no-session:tab-1:leaf-1`
)
}
)
it('keeps live hook identity ahead of a stale title fallback', () => {
renderResolution({
paneKey: 'tab-1:leaf-1',
launchAgent: null,
agentStatusEntry: entry({
paneKey: 'tab-1:leaf-1',
agentType: 'claude',
providerSession: { key: 'session_id', id: 'claude-session' }
}),
resolvedAgent: 'codex',
ptyId: 'pty-1'
})
expect(screen.getByTestId('native-chat-resolution')).toHaveTextContent(
'claude:claude-session:tab-1:leaf-1'
)
})
it('does not open native chat from an unsupported title fallback', () => {
renderResolution({
paneKey: 'tab-1:leaf-1',
launchAgent: null,
resolvedAgent: 'grok',
ptyId: 'pty-1'
})
expect(screen.getByText('No conversation here')).toBeInTheDocument()
expect(screen.queryByTestId('native-chat-resolution')).not.toBeInTheDocument()
})
})
@@ -4,7 +4,6 @@ import { useAppStore } from '../../store'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import type { TuiAgent } from '../../../../shared/types'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
import { useNativeChatLiveSession } from './use-native-chat-live-session'
import { selectNativeChatViewState } from './native-chat-view-state'
import { NativeChatMessageList } from './NativeChatMessageList'
@@ -13,6 +12,7 @@ import { useNativeChatFontScale } from './use-native-chat-font-scale'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { NativeChatSessionGate } from './NativeChatSessionGate'
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
import {
@@ -47,8 +47,8 @@ import {
resolveNativeChatFileLink,
resolveNativeChatFileLinkContext
} from './native-chat-file-link'
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing'
const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
onSplitRight: () => {},
@@ -75,6 +75,8 @@ export type NativeChatViewProps = {
targetPtyId?: string | null
/** Launch-time agent hint from the TerminalTab, when Orca started one. */
launchAgent?: TuiAgent | null
/** Trusted title/foreground fallback for manually-started agents. */
resolvedAgent?: TuiAgent | null
/** Return this pane to the hosted terminal surface. */
onSwitchToTerminal?: () => void
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
@@ -93,6 +95,7 @@ export default function NativeChatView({
paneKey: preferredPaneKey,
targetPtyId = null,
launchAgent,
resolvedAgent,
onSwitchToTerminal,
contextMenuActions
}: NativeChatViewProps): React.JSX.Element {
@@ -106,33 +109,30 @@ export default function NativeChatView({
)
)
const resolution = useMemo(() => {
// paneKey: prefer the live entry's key; fall back to the tab id so the hook
// still has a stable key to select live status by before any pane reports.
const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:`
return resolveNativeChatSession({
paneKey,
launchAgent,
...(agentStatusEntry ? { agentStatusEntry } : {}),
ptyId: targetPtyId
})
}, [agentStatusEntry, terminalTabId, preferredPaneKey, targetPtyId, launchAgent])
if (!resolution) {
return <NativeChatEmptyState kind="not-agent" />
}
// paneKey: prefer the live entry's key; fall back to the tab id so the hook
// still has a stable key to select live status by before any pane reports.
const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:`
return (
<NativeChatResolvedView
paneKey={resolution.paneKey}
agent={resolution.agent}
sessionId={resolution.sessionId}
transcriptPath={resolution.transcriptPath}
targetPtyId={targetPtyId}
terminalTabId={terminalTabId}
onSwitchToTerminal={onSwitchToTerminal}
contextMenuActions={contextMenuActions}
/>
<NativeChatSessionGate
paneKey={paneKey}
launchAgent={launchAgent}
resolvedAgent={resolvedAgent}
agentStatusEntry={agentStatusEntry}
ptyId={targetPtyId}
>
{(resolution) => (
<NativeChatResolvedView
paneKey={resolution.paneKey}
agent={resolution.agent}
sessionId={resolution.sessionId}
transcriptPath={resolution.transcriptPath}
targetPtyId={targetPtyId}
terminalTabId={terminalTabId}
onSwitchToTerminal={onSwitchToTerminal}
contextMenuActions={contextMenuActions}
/>
)}
</NativeChatSessionGate>
)
}
@@ -87,6 +87,40 @@ describe('canToggleNativeChat', () => {
).toBe(false)
})
it('rejects a stale supported title when live detection found an unsupported agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
detectedAgent: 'gemini',
resolvedAgent: 'codex'
})
).toBe(false)
})
it('rejects stale launch metadata when live detection found an unsupported agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: 'codex',
detectedAgent: 'gemini'
})
).toBe(false)
})
it('rejects a stale supported title when launch metadata names an unsupported agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: 'grok',
resolvedAgent: 'claude'
})
).toBe(false)
})
it('rejects otherwise eligible terminals while the experimental flag is off', () => {
expect(
canToggleNativeChat({
@@ -47,10 +47,9 @@ export type NativeChatAvailabilityInput = {
/** Native chat is a rendering of a coding-agent conversation, so the toggle is
* only meaningful on terminals that actually run an agent we can parse. Plain
* shells, non-terminal surfaces (editor, browser, …), and unsupported agents
* (Grok, Gemini, …) never qualify. Eligibility is the union of the launch-time
* hint, live detection, and title resolution — but only when that signal names
* a supported agent — so the control appears for both Orca-launched and
* user-started Claude/Codex sessions. */
* (Grok, Gemini, …) never qualify. Live identity is authoritative when present;
* launch metadata is next, and title resolution only fills the pre-hook gap for
* manually-started Claude/Codex sessions. */
export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean {
if (input.experimentalNativeChatEnabled !== true) {
return false
@@ -58,10 +57,9 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean
if (input.contentType !== 'terminal') {
return false
}
return (
input.isChatViewMode === true ||
isNativeChatSupportedAgent(input.launchAgent) ||
isNativeChatSupportedAgent(input.detectedAgent) ||
isNativeChatSupportedAgent(input.resolvedAgent)
)
if (input.isChatViewMode === true) {
return true
}
const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent
return isNativeChatSupportedAgent(agent)
}
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TuiAgent } from '../../../../shared/types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
function entry(
@@ -116,7 +117,64 @@ describe('resolveNativeChatSession', () => {
})
})
it('derives the agent from the status entry when no launchAgent is set', () => {
it('derives a supported agent from the status entry when no launchAgent is set', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: null,
agentStatusEntry: entry({
paneKey,
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-1' }
}),
ptyId: 'pty-1'
})
).toEqual({
agent: 'codex',
sessionId: 'codex-1',
transcriptPath: null,
ptyId: 'pty-1',
paneKey
})
})
it.each(['codex', 'claude', 'openclaude'] as TuiAgent[])(
'resolves supported title fallback %s when no hook or launch identity exists',
(resolvedAgent) => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: null,
resolvedAgent,
ptyId: 'pty-1'
})
).toEqual({
agent: resolvedAgent,
sessionId: null,
transcriptPath: null,
ptyId: 'pty-1',
paneKey
})
}
)
it.each(['gemini', 'grok'] as TuiAgent[])(
'does not resolve unsupported title fallback %s',
(resolvedAgent) => {
expect(
resolveNativeChatSession({
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
launchAgent: null,
resolvedAgent,
ptyId: 'pty-1'
})
).toBeNull()
}
)
it('does not resolve an unsupported live status entry', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
@@ -129,7 +187,97 @@ describe('resolveNativeChatSession', () => {
}),
ptyId: 'pty-1'
})
).toEqual({ agent: 'gemini', sessionId: 'g-1', transcriptPath: null, ptyId: 'pty-1', paneKey })
).toBeNull()
})
it('does not fall back to a supported title agent when live status is unsupported', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: null,
agentStatusEntry: entry({
paneKey,
agentType: 'gemini',
providerSession: { key: 'session_id', id: 'g-1' }
}),
resolvedAgent: 'codex',
ptyId: 'pty-1'
})
).toBeNull()
})
it('does not fall back to a supported launch agent when live status is unsupported', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'codex',
agentStatusEntry: entry({
paneKey,
agentType: 'gemini',
providerSession: { key: 'session_id', id: 'g-1' }
}),
resolvedAgent: 'claude',
ptyId: 'pty-1'
})
).toBeNull()
})
it('does not resolve an unsupported launch agent', () => {
expect(
resolveNativeChatSession({
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
launchAgent: 'grok',
ptyId: 'pty-1'
})
).toBeNull()
})
it('does not fall back to a supported title agent when launchAgent is unsupported', () => {
expect(
resolveNativeChatSession({
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
launchAgent: 'grok',
resolvedAgent: 'codex',
ptyId: 'pty-1'
})
).toBeNull()
})
it('keeps launch identity ahead of the title fallback', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'claude',
resolvedAgent: 'codex',
ptyId: 'pty-1'
})?.agent
).toBe('claude')
})
it('keeps live hook identity and provider session ahead of the title fallback', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'claude',
agentStatusEntry: entry({
paneKey,
agentType: 'codex',
providerSession: { key: 'session_id', id: 'codex-live' }
}),
resolvedAgent: 'claude',
ptyId: 'pty-1'
})
).toEqual({
agent: 'codex',
sessionId: 'codex-live',
transcriptPath: null,
ptyId: 'pty-1',
paneKey
})
})
it('returns null for a non-agent pane (no launchAgent, no entry)', () => {
@@ -1,5 +1,6 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { TuiAgent } from '../../../../shared/types'
import { isNativeChatSupportedAgent } from './native-chat-availability'
/** Inputs that resolve the active pane to the agent/session/pty triple the
* native-chat data + input layers need. Kept as a plain shape (not the live
@@ -19,6 +20,9 @@ export type NativeChatPaneResolutionInput = {
/** Runtime PTY id bound to this pane. ptyId is pane-manager runtime state, so
* it's passed in rather than looked up inside this pure function. */
ptyId: string | null
/** Agent identity resolved from trusted terminal title/foreground signals.
* Fallback only: launch metadata and hook status remain authoritative. */
resolvedAgent?: TuiAgent | null
}
export type NativeChatPaneResolution = {
@@ -34,16 +38,16 @@ export type NativeChatPaneResolution = {
}
/** Resolve the active pane to `{ agent, sessionId, ptyId, paneKey }`, or null
* when the pane runs no agent. A pane qualifies when either a launch-time
* agent hint or a live agent-status entry is present (mirrors the eligibility
* union in native-chat-availability). sessionId comes from the entry's
* `providerSession.id` (the captured agent session id) — null until the agent
* reports one, so a just-launched pane resolves without throwing. */
* when the pane runs no agent. A pane qualifies when a live agent-status entry,
* launch-time hint, or the same title-derived fallback used by the toggle is
* present. sessionId comes from the entry's `providerSession.id` (the captured
* agent session id) — null until the agent reports one, so a just-launched
* pane resolves without throwing. */
export function resolveNativeChatSession(
input: NativeChatPaneResolutionInput
): NativeChatPaneResolution | null {
const agent = input.launchAgent ?? input.agentStatusEntry?.agentType
if (!agent) {
const agent = input.agentStatusEntry?.agentType ?? input.launchAgent ?? input.resolvedAgent
if (!agent || !isNativeChatSupportedAgent(agent)) {
return null
}
return {
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
isNativeChatShortcutTitleFallbackSafe,
resolveNativeChatToggleShortcutDetectedAgent
} from './use-native-chat-toggle-shortcut'
describe('resolveNativeChatToggleShortcutDetectedAgent', () => {
it('uses the active split leaf instead of the first tab agent entry', () => {
expect(
resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: 'tab-1',
activeLeafId: 'leaf-2',
agentStatusByPaneKey: {
'tab-1:leaf-1': { agentType: 'gemini' },
'tab-1:leaf-2': { agentType: 'codex' }
}
})
).toBe('codex')
})
it('keeps an unsupported active leaf authoritative over a supported sibling', () => {
expect(
resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: 'tab-1',
activeLeafId: 'leaf-2',
agentStatusByPaneKey: {
'tab-1:leaf-1': { agentType: 'claude' },
'tab-1:leaf-2': { agentType: 'grok' }
}
})
).toBe('grok')
})
it('falls back to the tab entry before a leaf is known', () => {
expect(
resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: 'tab-1',
activeLeafId: null,
agentStatusByPaneKey: {
'tab-2:leaf-1': { agentType: 'codex' },
'tab-1:leaf-1': { agentType: 'claude' }
}
})
).toBe('claude')
})
})
describe('isNativeChatShortcutTitleFallbackSafe', () => {
it('allows title fallback before a layout snapshot exists', () => {
expect(isNativeChatShortcutTitleFallbackSafe(null)).toBe(true)
})
it('allows title fallback for a single leaf layout', () => {
expect(isNativeChatShortcutTitleFallbackSafe({ type: 'leaf', leafId: 'leaf-1' })).toBe(true)
})
it('rejects title fallback for split layouts', () => {
expect(
isNativeChatShortcutTitleFallbackSafe({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-1' },
second: { type: 'leaf', leafId: 'leaf-2' }
})
).toBe(false)
})
})
@@ -1,9 +1,36 @@
import { useEffect } from 'react'
import { useAppStore } from '../../store'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
import { canToggleNativeChat } from './native-chat-availability'
import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut'
export function isNativeChatShortcutTitleFallbackSafe(
root: TerminalPaneLayoutNode | null | undefined
): boolean {
return !root || root.type === 'leaf'
}
export function resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId,
activeLeafId,
agentStatusByPaneKey
}: {
terminalTabId: string
activeLeafId: string | null
agentStatusByPaneKey: Record<string, { agentType?: AgentType }>
}): AgentType | null {
if (activeLeafId) {
return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null
}
return (
Object.entries(agentStatusByPaneKey).find(([paneKey]) =>
paneKey.startsWith(`${terminalTabId}:`)
)?.[1].agentType ?? null
)
}
/** Toggles the active worktree's focused agent-terminal tab between the terminal
* and native chat views via the keyboard. Gated to the active worktree so only
* one listener acts at a time, and to agent terminals so the chord is inert on
@@ -37,19 +64,24 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive
// inert on unsupported agents like Grok, matching the menu/header gate.
// Pane keys are `${entityId}:${leafId}` — the backing terminal tab id, not
// the unified tab id.
const detectedAgent =
Object.entries(state.agentStatusByPaneKey).find(([paneKey]) =>
paneKey.startsWith(`${tab.entityId}:`)
)?.[1].agentType ?? null
const terminalLayout = state.terminalLayoutsByTabId[tab.entityId]
const activeLeafId = terminalLayout?.activeLeafId ?? null
const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: tab.entityId,
activeLeafId,
agentStatusByPaneKey: state.agentStatusByPaneKey
})
const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root)
? (resolveTabAgentFromTitle(tab.label ?? '') ??
(terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null))
: null
if (
!canToggleNativeChat({
experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true,
contentType: 'terminal',
launchAgent: terminalTab?.launchAgent,
launchAgent: detectedAgent ? null : terminalTab?.launchAgent,
detectedAgent,
resolvedAgent:
resolveTabAgentFromTitle(tab.label ?? '') ??
(terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null),
resolvedAgent: detectedAgent ? null : titleFallbackAgent,
isChatViewMode: tab.viewMode === 'chat'
})
) {
@@ -64,6 +64,23 @@ function entriesToTreeNodes(
})
}
async function readWorktreeDirectory(
activeWorktreeId: string | null | undefined,
worktreePath: string | null,
dirPath: string
): Promise<DirEntry[]> {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
return readRuntimeDirectory(
{
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
dirPath
)
}
export async function refreshFileExplorerExpandedDirs({
dirs,
worktreePath,
@@ -180,16 +197,7 @@ export function useFileExplorerTree(
}
}))
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const entries = await readRuntimeDirectory(
{
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
dirPath
)
const entries = await readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath)
if (!dirLoadTrackerRef.current.isCurrent(loadToken)) {
return false
}
@@ -278,18 +286,7 @@ export function useFileExplorerTree(
worktreePath,
dirLoadTracker: dirLoadTrackerRef.current,
setDirCache,
readDirectory: async (dirPath) => {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
return readRuntimeDirectory(
{
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
dirPath
)
}
readDirectory: (dirPath) => readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath)
})
}, [activeWorktreeId, expanded, loadDir, worktreePath])
@@ -12,32 +12,41 @@ export type RepositoryGitHubAvatarResolution = {
upstream: GitHubRepositoryIdentity | null
}
export async function resolveRepositoryUpstreamLive(
function resolveRepositoryIdentityLive(
runtimeTarget: RuntimeTarget,
repo: Repo
repo: Repo,
method: 'github.repoUpstream' | 'github.repoSlug',
localCall: (args: {
repoPath: string
repoId: string
}) => Promise<GitHubRepositoryIdentity | null>
): Promise<GitHubRepositoryIdentity | null> {
return runtimeTarget.kind === 'environment'
? await callRuntimeRpc<GitHubRepositoryIdentity | null>(
? callRuntimeRpc<GitHubRepositoryIdentity | null>(
runtimeTarget,
'github.repoUpstream',
method,
{ repo: repo.id },
{ timeoutMs: 30_000 }
)
: await window.api.gh.repoUpstream({ repoPath: repo.path, repoId: repo.id })
: localCall({ repoPath: repo.path, repoId: repo.id })
}
async function resolveRepositorySlugLive(
export function resolveRepositoryUpstreamLive(
runtimeTarget: RuntimeTarget,
repo: Repo
): Promise<GitHubRepositoryIdentity | null> {
return runtimeTarget.kind === 'environment'
? await callRuntimeRpc<GitHubRepositoryIdentity | null>(
runtimeTarget,
'github.repoSlug',
{ repo: repo.id },
{ timeoutMs: 30_000 }
)
: await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id })
return resolveRepositoryIdentityLive(runtimeTarget, repo, 'github.repoUpstream', (args) =>
window.api.gh.repoUpstream(args)
)
}
function resolveRepositorySlugLive(
runtimeTarget: RuntimeTarget,
repo: Repo
): Promise<GitHubRepositoryIdentity | null> {
return resolveRepositoryIdentityLive(runtimeTarget, repo, 'github.repoSlug', (args) =>
window.api.gh.repoSlug(args)
)
}
export async function resolveRepositoryGitHubAvatar(
@@ -52,9 +61,8 @@ export async function resolveRepositoryGitHubAvatar(
if (upstream) {
return { repoIcon: githubAvatarIcon(upstream), upstream }
}
// Why: a null live upstream is ambiguous (offline/unauthed vs genuinely not a
// fork). Don't downgrade a known fork identity to the origin slug — keep the
// last-known parent avatar so a transient failure can't clobber fork identity.
// Why: a null live upstream is ambiguous (offline/unauthed vs. not-a-fork). Keep
// the last-known parent avatar so a transient failure can't clobber fork identity.
if (repo.upstream) {
return { repoIcon: githubAvatarIcon(repo.upstream), upstream: repo.upstream }
}
@@ -89,7 +89,9 @@ export function WorktreeTitleInlineRename({
// root owns that stale-write guard without a mount-only Effect.
mountedRef.current = node !== null
titleElementRef.current = node
if (!node || editingRef.current) {
// Why: wrapped titles render in full and never truncate, so skip the measure +
// ResizeObserver entirely — for that mode it could only churn unused state.
if (!node || editingRef.current || wrapTitle) {
measureTitleTruncated(null)
return
}
@@ -109,7 +111,7 @@ export function WorktreeTitleInlineRename({
observer.observe(node)
titleResizeObserverRef.current = observer
},
[measureTitleTruncated]
[measureTitleTruncated, wrapTitle]
)
const titleElementKey = `${displayName}:${showUnreadEmphasis ? 'unread' : 'read'}`
@@ -28,6 +28,28 @@ export function isGitHubUserAttachmentVideoLink(
return isGitHubUserAttachmentUrl(href) && isBareAutolink(children, href)
}
// Shared fallback link for attachments that can't render inline (see the image
// note below on why load failures drop to a session-scoped link).
function AttachmentFallbackLink({
href,
children
}: {
href: string
children: React.ReactNode
}): React.ReactElement {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
)
}
export function GitHubUserAttachmentVideo({
href,
children
@@ -38,17 +60,7 @@ export function GitHubUserAttachmentVideo({
const [failed, setFailed] = React.useState(false)
if (failed) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
)
return <AttachmentFallbackLink href={href}>{children}</AttachmentFallbackLink>
}
return (
@@ -82,17 +94,7 @@ export function GitHubUserAttachmentImage({
// user's GitHub session cookies, so wrap in a top-level link (opening the
// URL where that session exists) and drop to a text link on load error.
if (failed) {
return (
<a
href={src}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline underline-offset-2 hover:text-primary/80"
onClick={(e) => e.stopPropagation()}
>
{label}
</a>
)
return <AttachmentFallbackLink href={src}>{label}</AttachmentFallbackLink>
}
return (
@@ -6,36 +6,30 @@ const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId']
const EMPTY_REPOS: AppState['repos'] = []
const EMPTY_WORKTREES: ReturnType<typeof getAllWorktreesFromState> = []
function shouldReadPopoverSlices(open: boolean): boolean {
return open
}
export function getResourceUsageTabsByWorktree(
state: Pick<AppState, 'tabsByWorktree'>,
open: boolean
): AppState['tabsByWorktree'] {
return shouldReadPopoverSlices(open) ? state.tabsByWorktree : EMPTY_TABS_BY_WORKTREE
return open ? state.tabsByWorktree : EMPTY_TABS_BY_WORKTREE
}
export function getResourceUsageRuntimePaneTitlesByTabId(
state: Pick<AppState, 'runtimePaneTitlesByTabId'>,
open: boolean
): AppState['runtimePaneTitlesByTabId'] {
return shouldReadPopoverSlices(open)
? state.runtimePaneTitlesByTabId
: EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID
return open ? state.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID
}
export function getResourceUsageRepos(
state: Pick<AppState, 'repos'>,
open: boolean
): AppState['repos'] {
return shouldReadPopoverSlices(open) ? state.repos : EMPTY_REPOS
return open ? state.repos : EMPTY_REPOS
}
export function getResourceUsageAllWorktrees(
state: Pick<AppState, 'worktreesByRepo'>,
open: boolean
): ReturnType<typeof getAllWorktreesFromState> {
return shouldReadPopoverSlices(open) ? getAllWorktreesFromState(state) : EMPTY_WORKTREES
return open ? getAllWorktreesFromState(state) : EMPTY_WORKTREES
}
@@ -136,7 +136,7 @@ export function TabBarQuickCommandsButton({
return null
}
// Empty state: single "+ Command" button that opens the dialog directly.
// Empty state: single button that opens the dialog directly.
if (!hasAnyCommands) {
return (
<>
@@ -168,7 +168,7 @@ import {
} from './terminal-pane-attention-subscriptions'
import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup'
import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup'
import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent'
import { useRepoById } from '@/store/selectors'
import {
isXtermHelperTextarea,
@@ -591,6 +591,9 @@ export default function TerminalPane({
(t) => t.contentType === 'terminal' && t.entityId === tabId
)?.label
)
const runtimePaneTitlesByPaneId = useAppStore(
useShallow((store) => store.runtimePaneTitlesByTabId[tabId] ?? {})
)
// The native-chat toggle joins the pane header's split/close cluster. Eligible
// when Orca launched a *supported* agent here or one was detected live for the
// leaf, keyed `${tabId}:${leafId}`. Carry the agent identity, not just "an
@@ -614,11 +617,17 @@ export default function TerminalPane({
const terminalTab = useAppStore((store) =>
getCachedTerminalTabForWorktree(store.tabsByWorktree, worktreeId, tabId)
)
// Why: manually-started/resumed TUIs can be recognized by the unified tab
// label before the backing terminal title or hook state catches up.
const titleResolvedAgent =
resolveTabAgentFromTitle(unifiedTabLabel ?? '') ??
(terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null)
const resolveTitleAgentForLeaf = useCallback(
(leafId: string | null) =>
resolveNativeChatLeafTitleAgent({
leafId,
panes: managerRef.current?.getPanes() ?? [],
runtimePaneTitlesByPaneId,
tabLabel: unifiedTabLabel,
terminalTitle: terminalTab?.title
}),
[runtimePaneTitlesByPaneId, terminalTab?.title, unifiedTabLabel]
)
// Per-leaf eligibility: a split can mix a supported agent in one leaf with an
// unsupported one in another, so the toggle is gated by the specific leaf.
// A leaf's own live agent is authoritative; the tab-wide launch/title hints
@@ -636,7 +645,7 @@ export default function TerminalPane({
contentType: 'terminal',
launchAgent: detectedAgent ? null : terminalTab?.launchAgent,
detectedAgent,
resolvedAgent: detectedAgent ? null : titleResolvedAgent,
resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId),
isChatViewMode: isChatViewForLeaf
})
},
@@ -646,7 +655,7 @@ export default function TerminalPane({
chatLeafId,
nativeChatEnabled,
terminalTab?.launchAgent,
titleResolvedAgent
resolveTitleAgentForLeaf
]
)
const toggleNativeChatForLeaf = useCallback(
@@ -2835,6 +2844,7 @@ export default function TerminalPane({
const chatPanePtyId = chatPane
? (paneTransportsRef.current.get(chatPane.id)?.getPtyId() ?? null)
: null
const chatPaneResolvedAgent = chatPane ? resolveTitleAgentForLeaf(chatPane.leafId) : null
const activePaneIsChatLeaf = Boolean(
isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId
)
@@ -2933,6 +2943,7 @@ export default function TerminalPane({
paneKey={makePaneKey(tabId, chatPane.leafId)}
targetPtyId={chatPanePtyId}
launchAgent={terminalTab?.launchAgent}
resolvedAgent={chatPaneResolvedAgent}
onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)}
contextMenuActions={{
onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight),
@@ -36,6 +36,8 @@ beforeAll(() => {
import {
buildFontFamily,
POST_REPLAY_MODE_RESET,
restoreScrollbackBuffers,
serializePaneTree,
serializeTerminalLayout,
replayTerminalLayout,
@@ -422,6 +424,38 @@ describe('replayTerminalLayout', () => {
})
})
describe('restoreScrollbackBuffers', () => {
it('marks panes with restored scrollback for fresh-shell viewport blanking', () => {
const writes: string[] = []
const pane = {
id: 1,
terminal: {
write: vi.fn((data: string, callback?: () => void) => {
writes.push(data)
callback?.()
})
}
}
const manager = {
getPanes: vi.fn(() => [pane])
}
const replayingPanesRef = { current: new Map<number, number>() }
const restoredViewportBlankingPanesRef = { current: new Set<number>() }
restoreScrollbackBuffers(
manager as unknown as Parameters<typeof restoreScrollbackBuffers>[0],
{ [LEAF_1]: 'restored output' },
new Map([[LEAF_1, 1]]),
replayingPanesRef,
restoredViewportBlankingPanesRef
)
expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET])
expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true)
expect(replayingPanesRef.current.size).toBe(0)
})
})
// ---------------------------------------------------------------------------
// collectLeafIdsInReplayCreationOrder
// ---------------------------------------------------------------------------
@@ -6,6 +6,7 @@ import type {
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 {
getLeftmostLeafId,
normalizeTerminalLayoutSnapshot,
@@ -211,7 +212,8 @@ export function restoreScrollbackBuffers(
manager: PaneManager,
savedBuffers: Record<string, string> | undefined,
restoredPaneByLeafId: Map<string, number>,
replayingPanesRef: ReplayingPanesRef
replayingPanesRef: ReplayingPanesRef,
restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef
): void {
if (!savedBuffers) {
return
@@ -249,6 +251,9 @@ export function restoreScrollbackBuffers(
// The shell underneath is fresh and has no TUI consuming these modes.
// See POST_REPLAY_MODE_RESET comment.
replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET)
// Why: connection resolution happens after layout replay; only the
// fresh-shell paths should move these visible rows into scrollback.
restoredViewportBlankingPanesRef?.current.add(pane.id)
}
} catch {
// If restore fails, continue with blank terminal.
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent'
const panes = [
{ id: 1, leafId: 'leaf-1' },
{ id: 2, leafId: 'leaf-2' }
]
describe('resolveNativeChatLeafTitleAgent', () => {
it('uses the target split leaf runtime title', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-2',
panes,
runtimePaneTitlesByPaneId: { 1: 'PowerShell', 2: 'Codex - working' },
tabLabel: 'PowerShell'
})
).toBe('codex')
})
it('does not reuse the active leaf tab label for an inactive split leaf', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-2',
panes,
runtimePaneTitlesByPaneId: { 1: 'Codex - working', 2: 'PowerShell' },
tabLabel: 'Codex - working'
})
).toBeNull()
})
it('does not reuse a stale tab label for the active split leaf without a runtime title', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-1',
panes,
runtimePaneTitlesByPaneId: {},
tabLabel: 'Claude Code'
})
).toBeNull()
})
it('falls back to the terminal title in a single pane', () => {
expect(
resolveNativeChatLeafTitleAgent({
leafId: 'leaf-1',
panes: [panes[0]],
runtimePaneTitlesByPaneId: {},
terminalTitle: 'OpenClaude'
})
).toBe('openclaude')
})
})
@@ -0,0 +1,40 @@
import type { TuiAgent } from '../../../../shared/types'
import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
export type NativeChatLeafTitlePane = {
id: number
leafId: string
}
export type NativeChatLeafTitleAgentInput = {
leafId: string | null
panes: readonly NativeChatLeafTitlePane[]
runtimePaneTitlesByPaneId: Readonly<Record<number, string>>
tabLabel?: string | null
terminalTitle?: string | null
}
export function resolveNativeChatLeafTitleAgent({
leafId,
panes,
runtimePaneTitlesByPaneId,
tabLabel,
terminalTitle
}: NativeChatLeafTitleAgentInput): TuiAgent | null {
if (!leafId) {
return null
}
const targetPane = panes.find((pane) => pane.leafId === leafId)
const paneAgent = targetPane
? resolveTabAgentFromTitle(runtimePaneTitlesByPaneId[targetPane.id] ?? '')
: null
if (paneAgent) {
return paneAgent
}
// Tab titles can lag pane focus in split layouts, so use them only when there
// is no sibling leaf they could accidentally describe.
if (panes.length > 1) {
return null
}
return resolveTabAgentFromTitle(tabLabel ?? '') ?? resolveTabAgentFromTitle(terminalTitle ?? '')
}
@@ -1,5 +1,6 @@
import type { PtyTransport } from './pty-transport'
import type { ReplayingPanesRef } from './replay-guard'
import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport'
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
import type { EventProps } from '../../../../shared/telemetry-events'
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
@@ -38,6 +39,7 @@ export type PtyConnectionDeps = {
paneMode2031Ref: React.RefObject<Map<number, boolean>>
paneLastThemeModeRef: React.RefObject<Map<number, TerminalColorSchemeMode>>
replayingPanesRef: ReplayingPanesRef
restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>
@@ -9,6 +9,7 @@ import {
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from './layout-serialization'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator'
import type * as UseNotificationDispatchModule from './use-notification-dispatch'
import { getEagerPtyBufferHandle } from './pty-dispatcher'
@@ -66,6 +67,31 @@ async function renderHeadlessBuffer(writes: string[], cols = 80, rows = 8): Prom
}
}
async function renderHeadlessTerminalState(
writes: string[],
cols = 80,
rows = 8
): Promise<{ allLines: string[]; visibleLines: string[]; baseY: number }> {
const term = new Terminal({ cols, rows, allowProposedApi: true })
try {
for (const write of writes) {
await writeHeadlessTerminal(term, write)
}
const allLines: string[] = []
const buffer = term.buffer.active
for (let lineIndex = 0; lineIndex < buffer.length; lineIndex++) {
allLines.push(buffer.getLine(lineIndex)?.translateToString(true) ?? '')
}
const visibleLines: string[] = []
for (let row = 0; row < term.rows; row++) {
visibleLines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '')
}
return { allLines, visibleLines, baseY: buffer.baseY }
} finally {
term.dispose()
}
}
const toastInfo = vi.fn()
const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const
const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const
@@ -5222,6 +5248,114 @@ describe('connectPanePty', () => {
expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty')
})
it('blanks restored scrollback before fresh shell output', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
const written: string[] = []
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
callbacks.onData?.('PS >')
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
ptyIdsByTabId: { 'tab-1': [] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: {}
}
}
} as StoreState
const pane = createPane(1)
pane.terminal.rows = 4
pane.terminal.cols = 20
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
written.push(data)
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
restoredViewportBlankingPanesRef: { current: new Set([1]) }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
const blankViewport = buildFreshShellViewportBlankingSequence(4)
expect(written).toContain(blankViewport)
expect(written.indexOf(blankViewport)).toBeLessThan(written.indexOf('PS >'))
const rendered = await renderHeadlessTerminalState(
['old TUI row with a long tail\r\nold TUI row two', blankViewport, 'PS >'],
20,
4
)
expect(rendered.baseY).toBeGreaterThan(0)
expect(rendered.allLines.some((line) => line.includes('old TUI row'))).toBe(true)
expect(rendered.visibleLines).toEqual(['PS >', '', '', ''])
})
it('cold-restores scrollback then blanks the viewport without erasing scrollback', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
const written: string[] = []
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return {
id: 'fresh-pty',
coldRestore: { scrollback: 'cold TUI row one\r\ncold TUI row two', cwd: '/tmp/wt-1' }
}
}
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }]
}
} as StoreState
const pane = createPane(1)
pane.terminal.rows = 4
pane.terminal.cols = 20
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
written.push(data)
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
const blankViewport = buildFreshShellViewportBlankingSequence(4)
expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H')
expect(written).toEqual(
expect.arrayContaining([
'cold TUI row one\r\ncold TUI row two',
POST_REPLAY_MODE_RESET,
blankViewport
])
)
expect(written.indexOf('cold TUI row one\r\ncold TUI row two')).toBeLessThan(
written.indexOf(blankViewport)
)
const rendered = await renderHeadlessTerminalState([...written, 'PS >'], 20, 4)
expect(rendered.baseY).toBeGreaterThan(0)
expect(rendered.allLines.some((line) => line.includes('cold TUI row'))).toBe(true)
expect(rendered.visibleLines).toEqual(['PS >', '', '', ''])
})
it('resumes the provider agent session when daemon reattach cold-restores a fresh shell', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
@@ -68,6 +68,7 @@ import {
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from './layout-serialization'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan'
import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery'
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
@@ -404,6 +405,10 @@ type PendingStartupCommand = {
env?: Record<string, string>
}
type FreshSpawnOptions = {
forceBlankRestoredViewport?: boolean
}
type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
agent: ResumableTuiAgent
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['launchConfig']
@@ -1100,14 +1105,9 @@ export function connectPanePty(
)
return tab?.defaultTitle?.trim() || 'Terminal'
}
/**
* Resolves the authoritative owner agent type for this pane, checking tab launch,
* pane startup, typed command ownership, and store state configuration.
*
* Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an
* OMP-owned pane back to Pi; command ownership covers manually typed `omp`
* in generic terminals where launch metadata does not exist.
*/
// Why: infer pane ownership from a manually typed agent command (e.g. `omp`) by
// shadowing the shell's current command line, for generic terminals where no
// launch metadata exists. Consumed by getAuthoritativePaneAgent below.
let commandInferredPaneAgent: TuiAgent | null = null
let pendingShellCommandLine = ''
let pendingShellCommandCursor = 0
@@ -1208,9 +1208,13 @@ export function connectPanePty(
return null
}
const params = data.slice(index + 2, cursor)
if (final === 'D') {
// Why: only emulate a bare one-column move. Parameterized/modified cursor keys
// (e.g. Ctrl+Left `\x1b[1;5D` = word-jump) move the real cursor by more than
// one, so tracking them as ±1 would desync the shadow line — fall through to
// reset instead of silently corrupting the sampled command.
if (final === 'D' && params === '') {
movePendingShellCommandCursor(-1)
} else if (final === 'C') {
} else if (final === 'C' && params === '') {
movePendingShellCommandCursor(1)
} else if (final === 'H' || (final === '~' && params === '1')) {
pendingShellCommandCursor = 0
@@ -1315,6 +1319,14 @@ export function connectPanePty(
}
}
}
/**
* Resolves the authoritative owner agent type for this pane, checking tab launch,
* pane startup, typed command ownership, and store state configuration.
*
* Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an
* OMP-owned pane back to Pi; command ownership covers manually typed `omp`
* in generic terminals where launch metadata does not exist.
*/
const getAuthoritativePaneAgent = (): AgentType | undefined => {
const state = useAppStore.getState()
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
@@ -3460,10 +3472,11 @@ export function connectPanePty(
}
: undefined
const startFreshColdRestoreAgentResume = (
startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup()
startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(),
options: FreshSpawnOptions = {}
): void => {
applyColdRestoreAgentResumeStartup(startup)
startFreshSpawn(startup)
startFreshSpawn(startup, options)
}
// Why: the hibernation wake fires from noteVisibilityResume in the outer
// connection scope, long after this deferred-connect closure has run.
@@ -3540,9 +3553,13 @@ export function connectPanePty(
}, 50)
}
const startFreshSpawn = (startupOverride?: PendingStartupCommand | null): void => {
const startFreshSpawn = (
startupOverride?: PendingStartupCommand | null,
options: FreshSpawnOptions = {}
): void => {
clearPaneMode2031State()
clearHiddenOutputRestoreState()
prepareFreshShellViewportForSpawn(options)
if (connectionId && startupOverride?.command) {
// Why: SSH providers use `command` only as spawn metadata; the renderer
// must still submit the resume command to the fresh remote shell.
@@ -3810,6 +3827,24 @@ export function connectPanePty(
: POST_REPLAY_REATTACH_RESET
}
const consumeRestoredViewportBlankingMarker = (): boolean => {
return deps.restoredViewportBlankingPanesRef?.current.delete(pane.id) ?? false
}
const writeFreshShellViewportBlanking = (): void => {
writeReplayData(buildFreshShellViewportBlankingSequence(pane.terminal.rows))
}
const prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => {
const hadRestoredViewport = consumeRestoredViewportBlankingMarker()
if (!options.forceBlankRestoredViewport && !hadRestoredViewport) {
return
}
// Why: fresh Windows ConPTY output paints at screen coordinates, so
// restored rows must leave the viewport before the first prompt redraw.
writeFreshShellViewportBlanking()
}
const sendFocusedReattachFocusInAfterReplay = (): void => {
const scheduledGeneration = reattachReplayPayloadSignalGeneration
void waitForTerminalOutputParsed(pane.terminal).then(() => {
@@ -5662,7 +5697,9 @@ export function connectPanePty(
if (staleSessionId) {
deps.clearTabPtyId(deps.tabId, staleSessionId)
}
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
registerEffectiveLaunchConfig(connectResult?.launchConfig, {
@@ -5681,7 +5718,9 @@ export function connectPanePty(
// Why: SSH sleep/reconnect can invalidate the relay-held PTY while
// leaving the tab mounted. Replace the dead lease in-place instead of
// stranding the pane behind a stale expired-session overlay.
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
setPanePtyFitBinding(ptyId)
@@ -5740,14 +5779,10 @@ export function connectPanePty(
}
}
} else if (connectResult?.coldRestore) {
// restoreScrollbackBuffers() already wrote the saved xterm buffer
// before this rAF ran. The cold-restore scrollback overlaps with
// that content; clear first.
// replayIntoTerminal: the recorded scrollback is raw PTY output that
// may contain query sequences the previous agent CLI emitted;
// writing them through xterm.write would trigger auto-replies that
// land in the new shell's stdin. See replay-guard.ts.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.coldRestore.scrollback)
const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup()
const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup)
@@ -5762,6 +5797,8 @@ export function connectPanePty(
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
// reset them to match the fresh shell's expectations.
writeReplayData(POST_REPLAY_MODE_RESET)
consumeRestoredViewportBlankingMarker()
writeFreshShellViewportBlanking()
if (!isRemoteRuntimePtyId(ptyId)) {
window.api.pty.ackColdRestore(ptyId)
}
@@ -6008,7 +6045,9 @@ export function connectPanePty(
}
deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId)
deps.clearTabPtyId(deps.tabId, pendingSessionId)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
handleReattachResult(result, pendingSessionId, coldRestoreStartup)
@@ -6031,10 +6070,14 @@ export function connectPanePty(
if (isSshSessionExpiredError(err)) {
deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId)
deps.clearTabPtyId(deps.tabId, pendingSessionId)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
})
} else {
startFreshColdRestoreAgentResume()
@@ -6178,7 +6221,9 @@ export function connectPanePty(
}
deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId)
deps.clearTabPtyId(deps.tabId, deferredReattachSessionId)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
handleReattachResult(result, deferredReattachSessionId, coldRestoreStartup)
@@ -6206,11 +6251,15 @@ export function connectPanePty(
deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId)
deps.clearTabPtyId(deps.tabId, deferredReattachSessionId)
if (connectionId && isSshSessionExpiredError(err)) {
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
reportError(message)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
})
} else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
// Why: mirrored web terminal layouts mount one pane per host leaf.
@@ -1,6 +1,7 @@
import { isMacPlatform } from './terminal-link-open-hints'
export function isTerminalLinkActivation(
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey'> | undefined
): boolean {
const isMac = navigator.userAgent.includes('Mac')
return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey)
}
@@ -0,0 +1,37 @@
import { Terminal } from '@xterm/headless'
import { describe, expect, it } from 'vitest'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
function writeTerminal(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
}
function visibleLines(term: Terminal): string[] {
const buffer = term.buffer.active
return Array.from(
{ length: term.rows },
(_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? ''
)
}
describe('buildFreshShellViewportBlankingSequence', () => {
it('preserves restored rows in scrollback even after a stale TUI scroll region', async () => {
const term = new Terminal({ cols: 20, rows: 5, allowProposedApi: true })
try {
await writeTerminal(term, 'row1\r\nrow2\r\nrow3\r\nrow4\r\nrow5\x1b[2;4r\x1b[?6h\x1b[H')
await writeTerminal(term, buildFreshShellViewportBlankingSequence(term.rows))
expect(term.buffer.active.baseY).toBeGreaterThan(0)
expect(visibleLines(term)).toEqual(['', '', '', '', ''])
expect(
Array.from(
{ length: term.buffer.active.length },
(_, row) => term.buffer.active.getLine(row)?.translateToString(true) ?? ''
)
).toEqual(expect.arrayContaining(['row1', 'row2', 'row3', 'row4', 'row5']))
} finally {
term.dispose()
}
})
})
@@ -0,0 +1,10 @@
import type { RefObject } from 'react'
export type RestoredViewportBlankingPanesRef = RefObject<Set<number>>
export function buildFreshShellViewportBlankingSequence(rows: number): string {
const viewportRows = Math.max(1, Math.floor(Number.isFinite(rows) ? rows : 24))
// Why: newline scrolling preserves restored rows in xterm scrollback; CSI S
// drops them. Reset margins first so stale TUI scroll regions cannot trap it.
return `\x1b[?6l\x1b[r\x1b[${viewportRows};1H${'\r\n'.repeat(viewportRows)}\x1b[H`
}
@@ -570,6 +570,7 @@ export function useTerminalPaneLifecycle({
const imeCompositionDisposablesRef = useRef(new Map<number, IDisposable>())
const imeNativeTextForwarderDisposablesRef = useRef(new Map<number, IDisposable>())
const queuedInitialCwdRef = useRef<string | null | undefined>(undefined)
const restoredViewportBlankingPanesRef = useRef(new Set<number>())
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@@ -733,6 +734,7 @@ export function useTerminalPaneLifecycle({
paneMode2031Ref,
paneLastThemeModeRef,
replayingPanesRef,
restoredViewportBlankingPanesRef,
isActiveRef,
isVisibleRef,
onPtyExitRef,
@@ -1247,6 +1249,7 @@ export function useTerminalPaneLifecycle({
clearRuntimePaneTitle(tabId, paneId)
paneFontSizesRef.current.delete(paneId)
replayingPanesRef.current.delete(paneId)
restoredViewportBlankingPanesRef.current.delete(paneId)
// Clean up pane title state so closed panes don't leave stale entries.
setPaneTitles((prev) => {
if (!(paneId in prev)) {
@@ -1438,7 +1441,13 @@ export function useTerminalPaneLifecycle({
const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
const restoredBuffers = initialLayoutRef.current.buffersByLeafId
restoreScrollbackBuffers(manager, restoredBuffers, restoredPaneByLeafId, replayingPanesRef)
restoreScrollbackBuffers(
manager,
restoredBuffers,
restoredPaneByLeafId,
replayingPanesRef,
restoredViewportBlankingPanesRef
)
if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) {
const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current }
delete layoutWithoutRestoredBuffers.buffersByLeafId
@@ -166,7 +166,7 @@ type CloseManagedPaneArgs = {
setActivePaneId: (paneId: number | null) => void
}
export function closeManagedPane(args: CloseManagedPaneArgs): void {
function teardownManagedPane(args: CloseManagedPaneArgs, reason: 'close' | 'detach'): void {
const pane = args.panes.get(args.paneId)
if (!pane) {
return
@@ -183,33 +183,24 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void {
args.managerOptions.onPaneClosed?.(args.paneId, {
paneId: args.paneId,
leafId: closedLeafId,
reason: 'close'
reason
})
args.managerOptions.onLayoutChanged?.()
}
export function closeManagedPane(args: CloseManagedPaneArgs): void {
teardownManagedPane(args, 'close')
}
export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean {
const pane = args.panes.get(args.paneId)
if (!pane || args.panes.size <= 1) {
// Why: refuse to detach the last pane — there is no other pane to fall back to.
if (!args.panes.has(args.paneId) || args.panes.size <= 1) {
return false
}
const closedLeafId = pane.leafId
args.releasePaneIdentity(args.paneId)
removePaneContainer(args, pane)
const nextActivePaneId = activateReplacementPane(args)
applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions)
for (const p of args.panes.values()) {
safeFit(p)
}
updateMultiPaneState(args.getDragCallbacks())
// Why: pane-to-tab detach tears down only this renderer pane; the PTY is
// adopted by the new tab, so TerminalPane must skip process-close cleanup.
args.managerOptions.onPaneClosed?.(args.paneId, {
paneId: args.paneId,
leafId: closedLeafId,
reason: 'detach'
})
args.managerOptions.onLayoutChanged?.()
// adopted by the new tab, so the 'detach' reason tells TerminalPane to skip
// process-close cleanup.
teardownManagedPane(args, 'detach')
return true
}
@@ -166,11 +166,9 @@ export async function readRuntimeFileContent({
{ timeoutMs: 15_000 }
)
} catch (err) {
// Why: files.read (readMobileFile) rejects binary paths — PDFs/images/etc —
// with the exact runtime error code 'binary_file'. Fetch the previewable
// base64 payload via files.readPreview so the editor renders them like the
// local/SSH paths do, keeping the server as the single binary-detection
// authority. Match the typed RPC error so an unrelated failure can't spoof it.
// Why: files.read rejects binary paths with the typed 'binary_file' error; fall
// back to the base64 preview RPC so PDFs/images render like local/SSH paths.
// Match the exact typed error so an unrelated failure can't spoof the fallback.
if (err instanceof RuntimeRpcCallError && err.message === 'binary_file') {
return callRuntimeRpc<RuntimeFilePreviewResult>(
target,
+11 -55
View File
@@ -1699,61 +1699,17 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const existingPreviewIdx = s.openFiles.findIndex((f) => f.id === replaceablePreviewId)
if (existingPreviewIdx !== -1) {
const replacedPreview = s.openFiles[existingPreviewIdx]
const nextEditorDrafts =
replacedPreview.id === id
? s.editorDrafts
: Object.fromEntries(
Object.entries(s.editorDrafts).filter(([fileId]) => fileId !== replacedPreview.id)
)
const nextMarkdownViewMode =
replacedPreview.id === id
? s.markdownViewMode
: Object.fromEntries(
Object.entries(s.markdownViewMode).filter(
([fileId]) => fileId !== replacedPreview.id
)
)
const nextEditorViewMode =
replacedPreview.id === id
? s.editorViewMode
: Object.fromEntries(
Object.entries(s.editorViewMode).filter(
([fileId]) => fileId !== replacedPreview.id
)
)
const markdownVisibilityKeys = new Set([replacedPreview.id])
if (replacedPreview.markdownPreviewSourceFileId) {
markdownVisibilityKeys.add(replacedPreview.markdownPreviewSourceFileId)
}
const visibilityKeysToRemove = [...markdownVisibilityKeys].filter(
(key) =>
!s.openFiles.some(
(file, index) =>
index !== existingPreviewIdx &&
(file.id === key || file.markdownPreviewSourceFileId === key)
)
)
const nextMarkdownFrontmatterVisible =
replacedPreview.id === id || visibilityKeysToRemove.length === 0
? s.markdownFrontmatterVisible
: removeMarkdownVisibilityKeys(s.markdownFrontmatterVisible, visibilityKeysToRemove)
const nextMarkdownTableOfContentsVisible =
replacedPreview.id === id || visibilityKeysToRemove.length === 0
? s.markdownTableOfContentsVisible
: removeMarkdownVisibilityKeys(
s.markdownTableOfContentsVisible,
visibilityKeysToRemove
)
// Why: editorCursorLine entries accumulate per file; clean up the
// evicted preview's entry so it does not leak across tab replacements.
const nextEditorCursorLine =
replacedPreview.id === id
? s.editorCursorLine
: Object.fromEntries(
Object.entries(s.editorCursorLine).filter(
([fileId]) => fileId !== replacedPreview.id
)
)
// Why: reuse the shared eviction helper (as the four other preview-
// replacement paths do) so per-file cursor/draft/visibility cleanup stays
// defined in one place instead of a hand-rolled copy that drifts.
const {
editorDrafts: nextEditorDrafts,
editorCursorLine: nextEditorCursorLine,
markdownViewMode: nextMarkdownViewMode,
editorViewMode: nextEditorViewMode,
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible
} = removeEditorStateForReplacedPreview(s, replacedPreview, id)
// Replace in-place to preserve tab position
newFiles = s.openFiles.map((f, i) =>
i === existingPreviewIdx
+4 -4
View File
@@ -1038,9 +1038,9 @@ function buildPRRefreshCandidate(
true
)
const cachedFallbackPRNumber = cachedPR?.number ?? null
// Why: a merged PR stays a valid fallback when the worktree sits on its head
// or on a commit main confirmed as part of the PR; anything else means the
// branch moved on and the number must not resurrect the old merged PR.
// Why: a merged PR stays a valid fallback when the worktree sits on its head or
// on a commit confirmed to be part of the PR; anything else means the branch
// moved on and the number must not resurrect the old merged PR.
const cachedMergedPRMovedPastHead =
worktree.linkedPR == null &&
cachedPR?.state === 'merged' &&
@@ -1272,7 +1272,7 @@ function shouldPreserveExistingPRForFallbackMiss(args: {
const worktreeHead = worktree?.head
// Why: merged branch PRs are only safe to keep when cached PR metadata still
// matches the commit this stored worktree is actually on — exactly, or via a
// head main confirmed to be part of the merged PR.
// head confirmed to be part of the merged PR.
const preservesMergedPRForCurrentHead =
args.nextPR === null &&
args.linkedPRNumber == null &&
-2
View File
@@ -117,8 +117,6 @@ export function resolveComposerBranchNameOverrideForCreate(args: {
createBranchFromWorkspaceName?: boolean
}): string | undefined {
if (!args.branchNameOverride) {
// Why: branch mode keeps slash-containing git branch names while the
// workspace folder name may still be sanitized separately.
return args.createBranchFromWorkspaceName && args.workspaceName.includes('/')
? args.workspaceName
: undefined