diff --git a/config/scripts/check-reliability-gates.mjs b/config/scripts/check-reliability-gates.mjs index 79ca2e27d8b..8cf61c5cb2b 100644 --- a/config/scripts/check-reliability-gates.mjs +++ b/config/scripts/check-reliability-gates.mjs @@ -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`) } } diff --git a/package.json b/package.json index b90d2e2c6b9..fbf46f4735a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/emulator/backends/ios-emulator-backend.ts b/src/main/emulator/backends/ios-emulator-backend.ts index 8d3c31604e4..a0828c75898 100644 --- a/src/main/emulator/backends/ios-emulator-backend.ts +++ b/src/main/emulator/backends/ios-emulator-backend.ts @@ -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 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' } diff --git a/src/main/index.ts b/src/main/index.ts index 1263f6e817b..836ada978cb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -211,10 +211,11 @@ let watcherShutdownPromise: Promise | 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 = 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 { diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index 283f8b867c2..806b6669f68 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -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 …` 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=` 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=` 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, diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 419f7a1d200..f2000489275 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -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) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 54db5280238..6931cf21465 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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. diff --git a/src/renderer/src/components/native-chat/NativeChatSessionGate.tsx b/src/renderer/src/components/native-chat/NativeChatSessionGate.tsx new file mode 100644 index 00000000000..b10bb2fe576 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionGate.tsx @@ -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 + } + return <>{children(resolution)} +} diff --git a/src/renderer/src/components/native-chat/NativeChatView.test.tsx b/src/renderer/src/components/native-chat/NativeChatView.test.tsx new file mode 100644 index 00000000000..0aedcc811bf --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatView.test.tsx @@ -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 & Pick) { + return { + state: 'working' as const, + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + ...overrides + } +} + +function renderResolution( + props: Omit, 'children'> +): void { + render( + + {(resolution) => ( +
+ {resolution.agent}:{resolution.sessionId ?? 'no-session'}:{resolution.paneKey} +
+ )} +
+ ) +} + +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() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index ba8f877c43a..fa190c37879 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -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 = { 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 @@ -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 - } - + // 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 ( - + + {(resolution) => ( + + )} + ) } diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index fb02253b252..fe2e3d6de8f 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -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({ diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index ef12785b0e0..6c5413eb4fc 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -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) } diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts index 9e82b38e1f5..73c291bb4e2 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts @@ -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)', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts index 64cbc110dff..181895a4878 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts @@ -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 { diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts new file mode 100644 index 00000000000..bc89a09539f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 09578d8ed25..cb5a1f7a33e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -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 +}): 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' }) ) { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 9239c7a2b82..dbbd73a1901 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -64,6 +64,23 @@ function entriesToTreeNodes( }) } +async function readWorktreeDirectory( + activeWorktreeId: string | null | undefined, + worktreePath: string | null, + dirPath: string +): Promise { + 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]) diff --git a/src/renderer/src/components/settings/repository-icon-github.ts b/src/renderer/src/components/settings/repository-icon-github.ts index 78c3e4ba85d..02f962fb670 100644 --- a/src/renderer/src/components/settings/repository-icon-github.ts +++ b/src/renderer/src/components/settings/repository-icon-github.ts @@ -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 ): Promise { return runtimeTarget.kind === 'environment' - ? await callRuntimeRpc( + ? callRuntimeRpc( 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 { - return runtimeTarget.kind === 'environment' - ? await callRuntimeRpc( - 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 { + 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 } } diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 7f1c2f3bc38..679460541a0 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -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'}` diff --git a/src/renderer/src/components/sidebar/comment-markdown-github-attachment-media.tsx b/src/renderer/src/components/sidebar/comment-markdown-github-attachment-media.tsx index 7f94e907ed8..be577385163 100644 --- a/src/renderer/src/components/sidebar/comment-markdown-github-attachment-media.tsx +++ b/src/renderer/src/components/sidebar/comment-markdown-github-attachment-media.tsx @@ -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 ( + e.stopPropagation()} + > + {children} + + ) +} + export function GitHubUserAttachmentVideo({ href, children @@ -38,17 +60,7 @@ export function GitHubUserAttachmentVideo({ const [failed, setFailed] = React.useState(false) if (failed) { - return ( - e.stopPropagation()} - > - {children} - - ) + return {children} } 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 ( - e.stopPropagation()} - > - {label} - - ) + return {label} } return ( diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index 64fd7a46075..e74a79e2e10 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -6,36 +6,30 @@ const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] const EMPTY_REPOS: AppState['repos'] = [] const EMPTY_WORKTREES: ReturnType = [] -function shouldReadPopoverSlices(open: boolean): boolean { - return open -} - export function getResourceUsageTabsByWorktree( state: Pick, 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, 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, open: boolean ): AppState['repos'] { - return shouldReadPopoverSlices(open) ? state.repos : EMPTY_REPOS + return open ? state.repos : EMPTY_REPOS } export function getResourceUsageAllWorktrees( state: Pick, open: boolean ): ReturnType { - return shouldReadPopoverSlices(open) ? getAllWorktreesFromState(state) : EMPTY_WORKTREES + return open ? getAllWorktreesFromState(state) : EMPTY_WORKTREES } diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index 389280cad53..0810ea9c2b2 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -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 ( <> diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 5044f893525..60771da63fd 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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), diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 06c85c002c5..b72cc28a82a 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -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() } + const restoredViewportBlankingPanesRef = { current: new Set() } + + restoreScrollbackBuffers( + manager as unknown as Parameters[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 // --------------------------------------------------------------------------- diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index b2ab8d1e2ab..78f774ca439 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -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 | undefined, restoredPaneByLeafId: Map, - 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. diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts new file mode 100644 index 00000000000..accbbaa1c80 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts new file mode 100644 index 00000000000..e3527bf11ac --- /dev/null +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts @@ -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> + 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 ?? '') +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 6cfa8bca0f5..344c49bab75 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -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> paneLastThemeModeRef: React.RefObject> replayingPanesRef: ReplayingPanesRef + restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5a80f277eb4..ca510bfa2e2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index dcdeb7ee180..aaa525320f8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 } +type FreshSpawnOptions = { + forceBlankRestoredViewport?: boolean +} + type ColdRestoreAgentResumeStartup = PendingStartupCommand & { agent: ResumableTuiAgent launchConfig: NonNullable>['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. diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts index e85da903224..41def2095c1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -1,6 +1,7 @@ +import { isMacPlatform } from './terminal-link-open-hints' + export function isTerminalLinkActivation( event: Pick | undefined ): boolean { - const isMac = navigator.userAgent.includes('Mac') - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) + return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) } diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts new file mode 100644 index 00000000000..02517dc6735 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts @@ -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 { + 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() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts new file mode 100644 index 00000000000..6aff5b34d9a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts @@ -0,0 +1,10 @@ +import type { RefObject } from 'react' + +export type RestoredViewportBlankingPanesRef = RefObject> + +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` +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 3726ea46c42..e9ab0f8e940 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -570,6 +570,7 @@ export function useTerminalPaneLifecycle({ const imeCompositionDisposablesRef = useRef(new Map()) const imeNativeTextForwarderDisposablesRef = useRef(new Map()) const queuedInitialCwdRef = useRef(undefined) + const restoredViewportBlankingPanesRef = useRef(new Set()) 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 diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index b7d8356e0a5..4bc6018372e 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -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 } diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index 338ea9a73c6..e9161f754c1 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -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( target, diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 5ce2b4ef84a..805a2f801f4 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1699,61 +1699,17 @@ export const createEditorSlice: StateCreator = (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 diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index da5f0a5defa..8478c519877 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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 && diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index de2750ad6c4..0ee66f0a28e 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -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