From 1e63cfef06ee30ce5e9ba7b1a58e97338284865c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:30:50 -0700 Subject: [PATCH 01/28] Revert "fix(mobile): present pending Relay fallback accurately (#14922)" (#14976) This reverts commit 381188141058ad22fe278a40d35e272bc8798fa4. --- config/reliability-gates.jsonc | 34 +++++--------- mobile/app/h/[hostId]/index.tsx | 5 +-- .../app/h/[hostId]/session/[worktreeId].tsx | 5 +-- mobile/app/h/[hostId]/tasks.tsx | 5 +-- mobile/app/index.tsx | 7 +-- mobile/src/components/MobileHostCard.test.tsx | 11 +++-- mobile/src/components/MobileHostCard.tsx | 7 ++- .../client-context-connection-metrics.ts | 5 --- mobile/src/transport/client-context.test.ts | 40 ----------------- mobile/src/transport/client-context.tsx | 20 +++++---- .../src/transport/connection-health.test.ts | 34 +------------- mobile/src/transport/connection-health.ts | 14 ------ .../transport/host-client-context-state.ts | 9 +--- mobile/src/transport/host-entry-opener.ts | 13 +----- .../logical-client-connection-path.ts | 45 ------------------- .../mobile-endpoint-supervisor-test-fakes.ts | 8 +--- .../mobile-endpoint-supervisor.test.ts | 18 -------- .../transport/mobile-endpoint-supervisor.ts | 11 +---- .../mobile-relay-runtime-failover.test.ts | 10 ----- .../transport/rpc-client-context-contract.ts | 1 - .../stable-logical-rpc-client.test.ts | 26 +---------- .../transport/stable-logical-rpc-client.ts | 19 +++----- mobile/src/transport/use-all-host-clients.ts | 11 +---- 23 files changed, 51 insertions(+), 307 deletions(-) delete mode 100644 mobile/src/transport/logical-client-connection-path.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cba44dc05cc..c9ec16d7d9a 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1049,14 +1049,14 @@ "providers": ["lan", "tailscale", "cloud-relay"], "coveredPlatforms": ["macos"], "coveredProviders": ["lan", "tailscale", "cloud-relay"], - "coverageNotes": "Deterministic React/provider tests cover catalog, missing-host, construction, cancellation, release, forget, refresh, backoff, stale-generation outcomes, and path-aware recovery presentation. Direct sessions cover idle probing and three fair misses. Relay sessions cover zero idle polling, rate-limited foreground probes, two fair misses, authenticated activity before semantic decoding, delivery ambiguity, scheduler stalls, and write failure. Physical iOS/Android sleep, carrier/Tailscale loss, and production relay paths remain live-test gaps.", + "coverageNotes": "Deterministic React/provider tests cover catalog, missing-host, construction, cancellation, release, forget, refresh, backoff, and stale-generation outcomes. Direct sessions cover idle probing and three fair misses. Relay sessions cover zero idle polling, rate-limited foreground probes, two fair misses, authenticated activity before semantic decoding, delivery ambiguity, scheduler stalls, and write failure. Physical iOS/Android sleep, carrier/Tailscale loss, and production relay paths remain live-test gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-4153/mobile-app-disconnects-frequently" ], - "invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions emit no periodic idle or demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death. Relay recovery must notify presentation independently of transport-state changes, must not inherit a failed direct path's Tailscale hint, and must retain long-outage escalation.", - "oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay zero idle and demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay. Start and clear Relay recovery without changing transport state and require observers to rerender. Project five failed Tailscale attempts into a neutral Relay-specific verdict, then require twelve attempts to produce a Relay-specific unreachable verdict with no Tailscale hint.", + "invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions emit no periodic idle or demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death.", + "oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay zero idle and demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay.", "commands": [ - "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts" + "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts" ], "testFiles": [ "mobile/src/transport/client-context.test.ts", @@ -1068,16 +1068,12 @@ "mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts", "mobile/src/transport/mobile-endpoint-nudge-router.test.ts", "mobile/src/transport/mobile-relay-e2ee-link.test.ts", - "mobile/src/transport/stable-logical-rpc-client.test.ts", - "mobile/src/transport/connection-health.test.ts" + "mobile/src/transport/stable-logical-rpc-client.test.ts" ], "assertionRefs": [ { "file": "mobile/src/transport/client-context.test.ts", - "assertions": [ - "a retired close-on-release owner closes an ownerless manual reconnect", - "a Relay recovery path change rerenders observers without a transport-state change" - ] + "assertions": ["a retired close-on-release owner closes an ownerless manual reconnect"] }, { "file": "mobile/src/transport/host-open-recovery.test.tsx", @@ -1128,28 +1124,18 @@ }, { "file": "mobile/src/transport/stable-logical-rpc-client.test.ts", - "assertions": [ - "a delivery-unknown mutation is not replayed onto a replacement Relay", - "Relay recovery path changes publish and remain pending between failed dials" - ] - }, - { - "file": "mobile/src/transport/connection-health.test.ts", - "assertions": [ - "a Relay fallback pending over five failed Tailscale attempts remains neutral and omits the Tailscale hint", - "a prolonged Relay recovery escalates without reviving the Tailscale hint" - ] + "assertions": ["a delivery-unknown mutation is not replayed onto a replacement Relay"] } ], "evidenceRuns": [ { - "date": "2026-08-16", + "date": "2026-08-14", "runner": "local", "platform": "macos", - "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts", "result": "passed", "durationSeconds": 0.74, - "summary": "Eleven focused files passed 84 deterministic provider lifecycle, path presentation, direct/Relay liveness, traffic, and delivery-ambiguity tests." + "summary": "Ten focused files passed 66 deterministic provider lifecycle, direct/Relay liveness, traffic, and delivery-ambiguity tests." } ], "runtimeBudget": { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index dd4fd09516a..9cf10c94356 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -32,7 +32,6 @@ import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh' import { useLastConnectedAt, - usePendingConnectionPath, useReconnectAttempt } from '../../../src/transport/client-context-connection-metrics' import { @@ -139,7 +138,6 @@ export function HostScreen({ const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) - const pendingConnectionPath = usePendingConnectionPath(hostId) const clientRef = useRef(null) const fetchWorktreesInFlightRef = useRef(false) // Why: useRef, not useMemo — React may discard memoized values, which would silently @@ -819,8 +817,7 @@ export function HostScreen({ const headerVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt, - pendingPath: pendingConnectionPath + lastConnectedAt }) return ( <> diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 811211f07c3..8bef45a6b9f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -61,7 +61,6 @@ import { import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' import { useLastConnectedAt, - usePendingConnectionPath, useReconnectAttempt } from '../../../../src/transport/client-context-connection-metrics' import { @@ -737,7 +736,6 @@ export default function SessionScreen() { const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) - const pendingConnectionPath = usePendingConnectionPath(hostId) const forceReconnectHost = useForceReconnect() const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({ client, @@ -4151,8 +4149,7 @@ export default function SessionScreen() { state: connState, reconnectAttempts, lastConnectedAt, - endpoint: hostEndpoint, - pendingPath: pendingConnectionPath + endpoint: hostEndpoint }) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 7f96f2db903..717e4640516 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -37,7 +37,6 @@ import type { RpcSuccess } from '../../../src/transport/types' import { useHostClient } from '../../../src/transport/client-context' import { useLastConnectedAt, - usePendingConnectionPath, useReconnectAttempt } from '../../../src/transport/client-context-connection-metrics' import { classifyConnection } from '../../../src/transport/connection-health' @@ -2074,7 +2073,6 @@ export default function MobileTasksScreen() { const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) - const pendingConnectionPath = usePendingConnectionPath(hostId) const clientRef = useRef(null) const loadGenerationRef = useRef(0) const taskResumeRef = useRef({}) @@ -8675,8 +8673,7 @@ export default function MobileTasksScreen() { const headerVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt, - pendingPath: pendingConnectionPath + lastConnectedAt }) const emptyLabel = connState !== 'connected' diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 6a2ce2ac1d0..66b4ebf4b62 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -259,10 +259,6 @@ export default function HomeScreen() { () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), [allClients] ) - const hostPendingPaths = useMemo( - () => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])), - [allClients] - ) const disconnectHostClient = useDisconnectHostClient() const forgetHostClient = useForgetHostClient() const forceReconnectHost = useForceReconnect() @@ -769,8 +765,7 @@ export default function HomeScreen() { state, reconnectAttempts: attempts, lastConnectedAt, - endpoint: item.endpoint, - pendingPath: hostPendingPaths[item.id] ?? null + endpoint: item.endpoint }) return ( { it('names the relay while the dial is still in flight', async () => { const lines = await renderCard(undefined, { state: 'connecting', - verdict: { kind: 'normal', label: 'Connecting via Relay…' }, + verdict: { kind: 'normal', label: 'Connecting…' }, path: 'relay' }) - expect(lines).toContain('Connecting via Relay…') - expect(lines).not.toContain(' · Orca Relay') + expect(lines).toContain('Connecting…') + expect(lines).toContain(' · Orca Relay') }) it('names the relay while a failed direct dial is still retrying', async () => { const lines = await renderCard(undefined, { state: 'reconnecting', - verdict: { kind: 'normal', label: 'Connecting via Relay…' }, + verdict: { kind: 'normal', label: 'Reconnecting…' }, path: 'relay' }) - expect(lines).toContain('Connecting via Relay…') - expect(lines).not.toContain(' · Orca Relay') + expect(lines).toContain(' · Orca Relay') }) it('leaves an idle disconnected host unlabelled', async () => { diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index c2637ca1855..a09ae0f3cf7 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -25,6 +25,11 @@ export function MobileHostCard(props: { const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable' const credentialMissing = props.credentialStatus === 'missing' const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing + // Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the + // path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is + // named — 'lan' doubles as the unknown-path default, so it would be a guess before connect. + const dialingPath = + ['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay' const isError = credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) const statusLabel = credentialMissing @@ -39,7 +44,7 @@ export function MobileHostCard(props: { : props.verdict const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) const connectionPathLabel = - !credentialMissing && !credentialUnavailable && connected + !credentialMissing && !credentialUnavailable && (connected || dialingPath) ? mobileConnectionPathLabel(props.path) : null const discoveryHint = diff --git a/mobile/src/transport/client-context-connection-metrics.ts b/mobile/src/transport/client-context-connection-metrics.ts index 9fb0df4913d..391c0cb0568 100644 --- a/mobile/src/transport/client-context-connection-metrics.ts +++ b/mobile/src/transport/client-context-connection-metrics.ts @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react' import { useRpcClientContext, type RpcClientContextValue } from './client-context' -import type { MobileConnectionPath } from './stable-logical-rpc-client' export function useReconnectAttempt(hostId: string | undefined): number { return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0) @@ -10,10 +9,6 @@ export function useLastConnectedAt(hostId: string | undefined): number | null { return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null) } -export function usePendingConnectionPath(hostId: string | undefined): MobileConnectionPath | null { - return useHostMetric(hostId, (context, id) => context.getPendingPath(id), null) -} - function useHostMetric( hostId: string | undefined, read: (context: RpcClientContextValue, hostId: string) => T, diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 8b109c49207..69f531ef45f 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionState } from './types' import type { RpcClient } from './rpc-client' -import type { MobileConnectionPath } from './stable-logical-rpc-client' const connectMock = vi.fn() const loadHostsMock = vi.fn() @@ -32,15 +31,12 @@ import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void - emitPendingPath: (path: MobileConnectionPath | null) => void closeMock: ReturnType } function makeFakeClient(initialState: ConnectionState): FakeClient { let state = initialState - let pendingPath: MobileConnectionPath | null = null const listeners = new Set<(state: ConnectionState) => void>() - const pathListeners = new Set<() => void>() const closeMock = vi.fn() return { sendRequest: vi.fn(), @@ -49,12 +45,6 @@ function makeFakeClient(initialState: ConnectionState): FakeClient { getState: () => state, getReconnectAttempt: () => 0, getLastConnectedAt: () => null, - getActivePath: () => 'tailscale', - getPendingPath: () => pendingPath, - onConnectionPathChange: (listener: () => void) => { - pathListeners.add(listener) - return () => pathListeners.delete(listener) - }, onStateChange: (listener) => { listeners.add(listener) return () => listeners.delete(listener) @@ -67,12 +57,6 @@ function makeFakeClient(initialState: ConnectionState): FakeClient { for (const listener of listeners) { listener(next) } - }, - emitPendingPath: (next) => { - pendingPath = next - for (const listener of pathListeners) { - listener() - } } } as FakeClient } @@ -409,30 +393,6 @@ describe('useHostClient', () => { }) describe('useAllHostClients', () => { - it('rerenders when Relay becomes pending without a transport-state change', async () => { - const client = makeFakeClient('reconnecting') - connectMock.mockReturnValue(client) - loadHostsMock.mockResolvedValue([HOST]) - let pendingPath: MobileConnectionPath | null | undefined - let renderer!: ReturnType - - function Probe(): null { - pendingPath = useAllHostClients([HOST.id])[0]?.pendingPath - return null - } - - await act(async () => { - renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) - await Promise.resolve() - }) - expect(pendingPath).toBeNull() - - act(() => client.emitPendingPath('relay')) - expect(pendingPath).toBe('relay') - - act(() => renderer.unmount()) - }) - it('only opens the requested startup subset', async () => { const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } connectMock.mockReturnValue(makeFakeClient('connected')) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 6e4d9014ad1..3e241b71270 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -76,7 +76,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { primedHostsRef.current.delete(hostId) } entry?.unsubState() - entry?.unsubConnectionPath() storeRef.current.delete(hostId) entry?.client.close() notifyHostState(hostId, 'disconnected') @@ -243,7 +242,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { manualDemandRef.current.add(hostId) if (entry) { entry.unsubState() - entry.unsubConnectionPath() entry.client.close() storeRef.current.delete(hostId) } @@ -262,10 +260,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { [openEntry] ) - const selectors = useMemo( - () => createHostClientSelectors(storeRef.current, pendingOpensRef.current), - [] - ) + const { getKnownState, getState, getReconnectAttempt, getLastConnectedAt, getActivePath } = + useMemo(() => createHostClientSelectors(storeRef.current, pendingOpensRef.current), []) const subscribeHostState = useCallback( (hostId: string, listener: (state: ConnectionState) => void) => @@ -322,7 +318,11 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { refreshHostClient, forgetHostClient, disconnectHostClient, - ...selectors, + getState, + getKnownState, + getReconnectAttempt, + getLastConnectedAt, + getActivePath, subscribeHostState, getAllClients, subscribeAllHosts, @@ -337,7 +337,11 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { refreshHostClient, forgetHostClient, disconnectHostClient, - selectors, + getState, + getKnownState, + getReconnectAttempt, + getLastConnectedAt, + getActivePath, subscribeHostState, getAllClients, subscribeAllHosts, diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts index ac135ab2bbb..5ac61c06a93 100644 --- a/mobile/src/transport/connection-health.test.ts +++ b/mobile/src/transport/connection-health.test.ts @@ -25,8 +25,7 @@ describe('classifyConnection Tailscale hint', () => { const verdict = classifyConnection({ ...base, reconnectAttempts: 3, - endpoint: 'ws://100.65.9.106:6768', - pendingPath: null + endpoint: 'ws://100.65.9.106:6768' }) expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' }) }) @@ -70,37 +69,6 @@ describe('classifyConnection Tailscale hint', () => { }) expect(verdict).toEqual({ kind: 'normal', label: 'Connected' }) }) - - it('presents an in-flight relay fallback instead of the failed Tailscale path', () => { - const incident = { - ...base, - reconnectAttempts: 5, - endpoint: 'ws://100.88.90.25:6768', - pendingPath: 'relay' as const - } - - for (const state of ['connecting', 'handshaking', 'reconnecting', 'disconnected'] as const) { - const verdict = classifyConnection({ ...incident, state }) - expect(verdict).toEqual({ kind: 'normal', label: 'Connecting via Relay…' }) - expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale') - } - }) - - it('escalates a prolonged relay recovery without reviving the Tailscale hint', () => { - const verdict = classifyConnection({ - ...base, - reconnectAttempts: 12, - endpoint: 'ws://100.88.90.25:6768', - pendingPath: 'relay' - }) - - expect(verdict).toEqual({ - kind: 'unreachable', - label: "Can't connect via Relay", - reason: 'never-connected' - }) - expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale') - }) }) // Issue #10119: every redial re-enters 'connecting', which used to revert an diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 2afa880e8d4..6774b111ff5 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -1,5 +1,4 @@ import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' -import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState } from './types' // Why: thresholds for escalating connection UX from neutral @@ -50,7 +49,6 @@ export function classifyConnection(args: { // Optional pinned host endpoint — enables the Tailscale hint on // warning/unreachable verdicts. Callers without it get plain labels. endpoint?: string | null - pendingPath?: MobileConnectionPath | null nowMs?: number }): ConnectionVerdict { const { state, reconnectAttempts, lastConnectedAt } = args @@ -67,18 +65,6 @@ export function classifyConnection(args: { return { kind: 'normal', label: 'Connected' } } - if (args.pendingPath === 'relay') { - if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) { - if (lastConnectedAt == null) { - return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'never-connected' } - } - if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { - return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'stale' } - } - } - return { kind: 'normal', label: 'Connecting via Relay…' } - } - if (state === 'disconnected') { return { kind: 'normal', label: 'Disconnected' } } diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts index 9b7b4cc9022..2aa7ca36400 100644 --- a/mobile/src/transport/host-client-context-state.ts +++ b/mobile/src/transport/host-client-context-state.ts @@ -84,9 +84,7 @@ export function createHostClientSelectors( getLastConnectedAt: (hostId: string): number | null => entries.get(hostId)?.client.getLastConnectedAt() ?? null, getActivePath: (hostId: string): MobileConnectionPath => - clientActivePath(entries.get(hostId)?.client), - getPendingPath: (hostId: string): MobileConnectionPath | null => - clientPendingPath(entries.get(hostId)?.client) + clientActivePath(entries.get(hostId)?.client) } } @@ -98,8 +96,3 @@ export function clientActivePath(client: RpcClient | undefined): MobileConnectio // Why: during migration the pending path is what the user is waiting on. return logical.getPendingPath?.() ?? logical.getActivePath() } - -export function clientPendingPath(client: RpcClient | undefined): MobileConnectionPath | null { - const logical = client as Partial | undefined - return logical?.getPendingPath?.() ?? null -} diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 092d205bb75..220b63abc64 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -4,7 +4,6 @@ import { openHostLogicalClient } from './host-logical-client' import type { HostClientOpenRegistry } from './host-client-open-registry' import type { HostOpenRetryScheduler } from './host-open-retry-scheduler' import type { RpcClient } from './rpc-client' -import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState, HostProfile } from './types' export type HostClientStoreEntry = { @@ -12,7 +11,6 @@ export type HostClientStoreEntry = { state: ConnectionState refCount: number unsubState: () => void - unsubConnectionPath: () => void } type HostEntryOpenerState = { @@ -115,20 +113,11 @@ export async function openHostClientEntry( current.state = next state.notifyHostState(hostId, next) }) - const logical = client as Partial - const unsubConnectionPath = - logical.onConnectionPathChange?.(() => { - const current = state.store.get(hostId) - if (current) { - state.notifyHostState(hostId, current.state) - } - }) ?? (() => {}) const entry: HostClientStoreEntry = { client, state: client.getState(), refCount: state.pendingAcquisitions.get(hostId) ?? 0, - unsubState, - unsubConnectionPath + unsubState } state.pendingAcquisitions.delete(hostId) state.store.set(hostId, entry) diff --git a/mobile/src/transport/logical-client-connection-path.ts b/mobile/src/transport/logical-client-connection-path.ts deleted file mode 100644 index eb6cd4a06a6..00000000000 --- a/mobile/src/transport/logical-client-connection-path.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { MobileConnectionPath } from './stable-logical-rpc-client' - -export class LogicalClientConnectionPath { - private migration: MobileConnectionPath | null = null - private recovery: MobileConnectionPath | null = null - private readonly listeners = new Set<() => void>() - - constructor(private readonly isConnected: () => boolean) {} - - pending(): MobileConnectionPath | null { - return this.isConnected() ? null : (this.migration ?? this.recovery) - } - - setMigration(path: MobileConnectionPath | null): void { - this.update(() => { - this.migration = path - }) - } - - clearMigrationAfterConnected(): void { - this.migration = null - } - - setRecovery(path: MobileConnectionPath | null): void { - this.update(() => { - this.recovery = path - }) - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - private update(apply: () => void): void { - const previous = this.pending() - apply() - if (previous === this.pending()) { - return - } - for (const listener of this.listeners) { - listener() - } - } -} diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 82ae186987f..2ef13c0b590 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -65,7 +65,6 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { private path: MobileConnectionPath - private recoveryPath: MobileConnectionPath | null = null private generation = 1 constructor(state: ConnectionState, path: MobileConnectionPath) { @@ -96,11 +95,8 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl ) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) getActivePath = () => this.path - getPendingPath = () => this.recoveryPath - setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { - this.recoveryPath = path - }) - onConnectionPathChange = vi.fn(() => () => {}) + // This fake migrates instantly, so no dial is ever in flight to name. + getPendingPath = () => null getGeneration = () => this.generation } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 1f5686f2617..bddc09795be 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -91,24 +91,6 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) - it('keeps Relay pending through a failed dial cooldown and clears it on stop', async () => { - const logical = new FakeLogicalClient('reconnecting', 'lan') - const deps = dependencies({ - openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))), - randomBytes: () => new Uint8Array([128, 0]) - }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) - - await supervisor.start() - - expect(logical.getPendingPath()).toBe('relay') - await vi.advanceTimersByTimeAsync(249) - expect(logical.getPendingPath()).toBe('relay') - - supervisor.stop() - expect(logical.getPendingPath()).toBeNull() - }) - it('does not spend a queued relay retry while direct authentication is progressing', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 5f737282972..84a3526d022 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -146,7 +146,6 @@ export class MobileEndpointSupervisor { } this.unsubscribeState = this.logical.onStateChange((state) => { if (state === 'connected') { - this.logical.setRecoveryPath(null) this.directGrace.clear() if (this.logical.getActivePath() !== 'relay') { void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) @@ -155,16 +154,12 @@ export class MobileEndpointSupervisor { } else { // Why: the direct client enters reconnecting after its first failed // dial and may never publish disconnected while its retry loop lives. - if (this.relayReconnect.needsRecovery(state)) { - this.logical.setRecoveryPath('relay') - } this.relayReconnect.handleStateFailure(this.logical, state) } }) if (this.relayReconnect.needsRecovery(this.logical.getState())) { // Why: the first direct dial can fail while encrypted relay credentials // are still loading, before the supervisor subscribes to state changes. - this.logical.setRecoveryPath('relay') await this.recoverRelay() } else { this.directProbe.schedule() @@ -176,9 +171,6 @@ export class MobileEndpointSupervisor { const wasForeground = this.foreground this.foreground = foreground if (foreground) { - if (this.relayReconnect.needsRecovery(this.logical.getState())) { - this.logical.setRecoveryPath('relay') - } this.relayReconnect.handleForeground(this.logical, wasForeground) this.directProbe.schedule(0) this.directGrace.arm() @@ -189,7 +181,6 @@ export class MobileEndpointSupervisor { this.relayReconnect.clear() this.leaseRotation.clear() this.directGrace.clear() - this.logical.setRecoveryPath(null) } } @@ -200,11 +191,11 @@ export class MobileEndpointSupervisor { stop(): void { this.stopped = true this.unsubscribeState?.() + this.unsubscribeState = null this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() this.directGrace.clear() - this.logical.setRecoveryPath(null) } // forceReplacement: dial past the "direct still looks live" guard — a lease diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 56a3540153b..dcb8ba194e2 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -90,8 +90,6 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { private path: MobileConnectionPath - private recoveryPath: MobileConnectionPath | null = null - private generation = 1 constructor(state: ConnectionState, path: MobileConnectionPath) { super(state) @@ -104,18 +102,10 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { throw new Error(`replacement session ${session.getState()}`) } this.path = path - this.recoveryPath = null - this.generation += 1 this.publishState('connected') }) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) getActivePath = () => this.path - getPendingPath = () => this.recoveryPath - setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { - this.recoveryPath = path - }) - onConnectionPathChange = vi.fn(() => () => {}) - getGeneration = () => this.generation } const relay = { diff --git a/mobile/src/transport/rpc-client-context-contract.ts b/mobile/src/transport/rpc-client-context-contract.ts index cf78c6f3546..77ef745eef0 100644 --- a/mobile/src/transport/rpc-client-context-contract.ts +++ b/mobile/src/transport/rpc-client-context-contract.ts @@ -21,7 +21,6 @@ export type RpcClientContextValue = { getReconnectAttempt: (hostId: string) => number getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath - getPendingPath: (hostId: string) => MobileConnectionPath | null subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void getAllClients: () => { hostId: string; client: RpcClient }[] subscribeAllHosts: (listener: () => void) => () => void diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 40829e17c6e..ea5dfef717c 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -4,8 +4,7 @@ import type { RpcClient } from './rpc-client' import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { createStableLogicalRpcClient, - LogicalClientCutoverError, - type MobileConnectionPath + LogicalClientCutoverError } from './stable-logical-rpc-client' class FakeSession implements RpcClient { @@ -260,13 +259,10 @@ describe('stable logical RPC client', () => { const client = createStableLogicalRpcClient(direct, 'lan') direct.setState('reconnecting') const states: ConnectionState[] = [] - const paths: (MobileConnectionPath | null)[] = [] client.onStateChange((next) => states.push(next)) - client.onConnectionPathChange(() => paths.push(client.getPendingPath())) const migrating = client.migrateTo(replacement, 'relay') expect(client.getPendingPath()).toBe('relay') - expect(paths).toEqual(['relay']) replacement.setState('connecting') replacement.setState('handshaking') @@ -282,26 +278,6 @@ describe('stable logical RPC client', () => { expect(client.getActivePath()).toBe('relay') }) - it('publishes recovery-path changes and keeps Relay pending between failed dials', async () => { - const direct = new FakeSession('reconnecting') - const replacement = new FakeSession('connecting') - const client = createStableLogicalRpcClient(direct, 'tailscale') - const paths: (MobileConnectionPath | null)[] = [] - client.onConnectionPathChange(() => paths.push(client.getPendingPath())) - - client.setRecoveryPath('relay') - const migrating = client.migrateTo(replacement, 'relay') - replacement.setState('disconnected') - await expect(migrating).rejects.toThrow(/disconnected/) - - expect(client.getPendingPath()).toBe('relay') - expect(paths).toEqual(['relay']) - - client.setRecoveryPath(null) - expect(client.getPendingPath()).toBeNull() - expect(paths).toEqual(['relay', null]) - }) - it('drops the pending path when the previous session recovers mid-dial', async () => { const direct = new FakeSession('connected') const replacement = new FakeSession('connecting') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 5e3571704b3..1ee47fcda5a 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -6,7 +6,6 @@ import { } from './migration-dial-state-forwarder' import { waitForAuthenticated } from './replacement-session-authentication' import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection' -import { LogicalClientConnectionPath } from './logical-client-connection-path' export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' @@ -48,10 +47,9 @@ export type StableLogicalRpcClient = RpcClient & { ): Promise suspendActiveSession(): void getActivePath(): MobileConnectionPath - // The path the user is waiting on while migration or scheduled recovery is active. + // Non-null only while a migration dial is publishing its own phases — the path the + // user is waiting on, which the still-bound active path can't name. getPendingPath(): MobileConnectionPath | null - setRecoveryPath(path: MobileConnectionPath | null): void - onConnectionPathChange(listener: () => void): () => void getGeneration(): number } @@ -61,6 +59,7 @@ export function createStableLogicalRpcClient( ): StableLogicalRpcClient { let activeSession = initialSession let activePath = initialPath + let pendingPath: MobileConnectionPath | null = null let generation = 1 let closed = false let suspended = false @@ -70,7 +69,6 @@ export function createStableLogicalRpcClient( const pendingRequests = new Set() const stateListeners = new Set<(state: ConnectionState) => void>() let state = initialSession.getState() - const connectionPath = new LogicalClientConnectionPath(() => state === 'connected') bindActiveState(initialSession, generation) @@ -209,7 +207,7 @@ export function createStableLogicalRpcClient( // (direct dial fails) sits in 'reconnecting' — already amber, so forwarding adds // nothing, but the user still has no idea relay is what's being tried. if (suspended || state !== 'connected') { - connectionPath.setMigration(path) + pendingPath = path } const forwarder = forwardMigrationDialState({ session: nextSession, @@ -261,7 +259,6 @@ export function createStableLogicalRpcClient( } pendingRequests.clear() state = nextSession.getState() - connectionPath.clearMigrationAfterConnected() for (const listener of stateListeners) { listener(state) } @@ -271,9 +268,7 @@ export function createStableLogicalRpcClient( getActivePath: () => activePath, // Why: a previous session that recovers mid-dial makes the pending path a lie — // once we're connected the user is no longer waiting on anything. - getPendingPath: () => connectionPath.pending(), - setRecoveryPath: (path) => connectionPath.setRecovery(path), - onConnectionPathChange: (listener) => connectionPath.subscribe(listener), + getPendingPath: () => (state === 'connected' ? null : pendingPath), getGeneration: () => generation } @@ -281,9 +276,7 @@ export function createStableLogicalRpcClient( function endDialForwarding(forwarder: MigrationDialStateForwarder, failed: boolean): void { forwarder.stop() - if (failed) { - connectionPath.setMigration(null) - } + pendingPath = null // Why: only walk back phases we published ourselves — a 'connected' here came from // the still-live previous session and outranks the dead dial. if (failed && forwarder.forwarded() && state !== 'connected') { diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index 197f270f194..4f5d036c877 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -136,19 +136,10 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients client: RpcClient state: ConnectionState path: MobileConnectionPath - pendingPath: MobileConnectionPath | null }>((hostId) => { const client = clientsByHostId.get(hostId) return client - ? [ - { - hostId, - client, - state: ctx.getState(hostId), - path: ctx.getActivePath(hostId), - pendingPath: ctx.getPendingPath(hostId) - } - ] + ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] : [] }) }, [ctx, hostIds, tick]) From 9c4627d1c6d6c8971850e35fc6f1c72ba9a50867 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:44:48 -0700 Subject: [PATCH 02/28] Refactor: split GitHubItemDialog into lifecycle-organized modules (#14931) * refactor: split GitHubItemDialog.tsx under 400 lines No intentional behavior change. * refactor: group github-item-dialog into lifecycle folders Reorganize the 50 flat files under src/renderer/src/components/ github-item-dialog/ into six lifecycle folders: load-item-details/ shared types, both caches, fetch/settle, state badge open-dialog/ dialog shell, headers, body, tabs, link copy discuss-item/ conversation tab, comments, composer, timeline edit-item-fields/ GH edit section, labels, assignees, status inspect-pull-request/ combined diff viewer, checks tab land-pull-request/ PR actions, merge menu, reviewers No intentional behavior change. All 50 files moved verbatim; the only edits are relative-import specifiers (sibling paths plus a depth bump for ../../../../shared) and the hardcoded module paths in the two source-boundary tests. Import graph stays acyclic: zero mutual folder pairs, no file importing 4+ sibling folders, no dest file importing the public barrel, and no per-folder index barrels. * refactor: split item references and improve diff-viewer remount logic - Break down full `GitHubWorkItem` props into discrete `itemId`, `itemNumber`, and `itemRepoId` in mutation and action functions to prevent over-memoization of callbacks and improve dependency clarity. - Extract `getPRFilesCombinedDiffSignature()` and use it as a component key to safely remount the diff viewer when the PR revision changes, replacing generationRef tracking. - Add `getKeyedCheckAnnotations()` and `getKeyedCheckJobs()` to generate stable, collision-resistant keys for check arrays that may contain duplicates. - Consolidate interpreter timeouts into a single `SPAWNED_INTERPRETER_TIMEOUT_MS` constant and apply it via describe options rather than per-test values. * refactor: improve github-item-dialog repo context and i18n coverage - Add repoId prop to ConversationTab for explicit repo context override - Internationalize UI strings in diff viewer and PR action components - Improve error handling with cache rollback and guard cleanup on sync failure - Enhance cache key validation for cross-window invalidation by repoPath - Add repository access validation before rendering diff viewer - Fix cross-platform issues: skip symlink test on Windows, normalize CRLF in test assertions * Refactor check button i18n key and update text - Replace hash-based key with semantic name for maintainability - Change button label to "Open in browser" for broader context --- config/max-lines-baseline.txt | 1 - .../ssh-relay-upload-stage-commands.test.ts | 268 +- .../src/components/GitHubItemDialog.tsx | 6378 +---------------- ...github-item-dialog-source-boundary.test.ts | 118 +- .../discuss-item/comment-reply-form.tsx | 82 + .../conversation-tab-activity.tsx | 141 + .../conversation-tab-comment-card.tsx | 214 + .../conversation-tab-comment-group.tsx | 129 + .../conversation-tab-description.tsx | 140 + .../conversation-tab-pr-sidebar.tsx | 85 + .../conversation-tab-timeline.tsx | 172 + .../discuss-item/conversation-tab.tsx | 354 + .../discuss-item/gh-comment-composer.tsx | 140 + .../issue-conversation-entries.ts | 68 + .../gh-edit-section-assignees.tsx | 207 + .../gh-edit-section-horizontal.tsx | 190 + .../gh-edit-section-labels.tsx | 195 + .../gh-edit-section-mutations.ts | 310 + .../gh-edit-section-status-popover.tsx | 234 + .../gh-edit-section-top-columns.tsx | 139 + .../edit-item-fields/gh-edit-section.tsx | 391 + .../github-labels-settings-link.tsx | 37 + .../edit-item-fields/repository-labels-url.ts | 19 + .../checks-tab-actions.ts | 263 + .../checks-tab-check-details.tsx | 239 + .../checks-tab-check-row.tsx | 87 + .../checks-tab-header.tsx | 203 + .../checks-tab-request-details.ts | 81 + .../checks-tab-summary.ts | 32 + .../inspect-pull-request/checks-tab.tsx | 400 ++ .../pr-files-combined-diff-body.tsx | 169 + .../pr-files-combined-diff-load.ts | 288 + .../pr-files-combined-diff-signature.ts | 35 + .../pr-files-combined-diff-toolbar.tsx | 77 + .../pr-files-combined-diff-viewer.tsx | 382 + .../pr-actions-merge-menu.tsx | 96 + .../land-pull-request/pr-actions-panel.tsx | 380 + .../land-pull-request/pr-reviewers-panel.tsx | 397 + .../pr-reviewers-picker-row.tsx | 177 + .../pr-reviewers-request-actions.ts | 279 + .../pr-reviewers-requested-list.tsx | 90 + .../github-item-dialog-types.ts | 28 + .../pr-file-content-cache.ts | 153 + .../pr-file-viewed-change.ts | 94 + .../use-github-item-dialog-details.ts | 321 + .../work-item-details-cache.ts | 210 + .../work-item-details-fetch-settle.ts | 58 + .../work-item-state-badge.tsx | 44 + .../github-item-dialog-issue-body.tsx | 141 + .../github-item-dialog-issue-header.tsx | 254 + .../github-item-dialog-pr-header.tsx | 170 + .../github-item-dialog-pr-tabs.tsx | 231 + .../open-dialog/github-item-dialog.tsx | 259 + .../use-github-item-dialog-link-copy.ts | 72 + .../work-item-issue-source-indicator.tsx | 44 + .../github/pr-check-presentation.ts | 47 +- .../task-drawer-source-boundary.test.ts | 15 +- src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 62 files changed, 9278 insertions(+), 6570 deletions(-) create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/comment-reply-form.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-activity.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-card.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-group.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-description.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-pr-sidebar.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/gh-comment-composer.tsx create mode 100644 src/renderer/src/components/github-item-dialog/discuss-item/issue-conversation-entries.ts create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-assignees.tsx create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-labels.tsx create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/github-labels-settings-link.tsx create mode 100644 src/renderer/src/components/github-item-dialog/edit-item-fields/repository-labels-url.ts create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-details.tsx create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-row.tsx create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-header.tsx create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-request-details.ts create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-summary.ts create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-load.ts create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-signature.ts create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-toolbar.tsx create mode 100644 src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx create mode 100644 src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-merge-menu.tsx create mode 100644 src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx create mode 100644 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx create mode 100644 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-picker-row.tsx create mode 100644 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts create mode 100644 src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-requested-list.tsx create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/pr-file-content-cache.ts create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/pr-file-viewed-change.ts create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-fetch-settle.ts create mode 100644 src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx create mode 100644 src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-body.tsx create mode 100644 src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-header.tsx create mode 100644 src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-header.tsx create mode 100644 src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-tabs.tsx create mode 100644 src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx create mode 100644 src/renderer/src/components/github-item-dialog/open-dialog/use-github-item-dialog-link-copy.ts create mode 100644 src/renderer/src/components/github-item-dialog/open-dialog/work-item-issue-source-indicator.tsx diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index aafe497a1c3..516bbeafcbb 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -88,7 +88,6 @@ inline src/relay/git-handler.ts inline src/relay/pty-handler.ts inline src/relay/relay.ts inline src/relay/workspace-space-scan.ts -inline src/renderer/src/components/GitHubItemDialog.tsx inline src/renderer/src/components/GitLabItemDialog.tsx inline src/renderer/src/components/JiraIssueWorkspace.tsx inline src/renderer/src/components/LinearIssueWorkspace.tsx diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts index 04f96c2ad81..8afb89228d0 100644 --- a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts +++ b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts @@ -108,16 +108,23 @@ afterEach(() => { } }) +// Why: every case spawns a real interpreter per command, and on non-Windows hosts the PowerShell +// path also forks `/usr/bin/stat` per file-identity lookup — the 8-entry reservation alone measured +// ~50s idle, nearly all of it process-spawn sys time, and the full suite multiplies that under CPU +// contention. Sized for spawn count, not the assertions, which run in microseconds. +const SPAWNED_INTERPRETER_TIMEOUT_MS = 240_000 + describe.each([ ['POSIX', posix] as const, ...((powerShellExecutable ? [['PowerShell', windows] as const] : []) as (readonly [ string, RemoteHostPlatform ])[]) -])('%s relay upload stage commands', (_label, host) => { - it.each([0, 1, 7, 8, 9])( - 'bounds reservation with %i occupied entries', - (count) => { +])( + '%s relay upload stage commands', + { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, + (_label, host) => { + it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { const pool = createPool() for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) { createStage(host, pool, index) @@ -136,91 +143,94 @@ describe.each([ expect(result.status).not.toBe(0) expect(result.stderr).toContain('staging quota is full') } - }, - 120_000 - ) + }) - it('promotes only the post-claim owned payload and removes its fixed stage', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(host, pool, 0) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `noise\n__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0\n` - ) + it('promotes only the post-claim owned payload and removes its fixed stage', () => { + const pool = createPool() + const destination = join(pool, 'destination') + mkdirSync(destination) + createStage(host, pool, 0) + const stage = parseReservedRelayUploadStage( + host, + pool, + owner, + `noise\n__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0\n` + ) - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) + const result = runCommand( + host, + promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) + ) - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(true) - expect(readFileSync(join(destination, 'relay.js'), 'utf8')).toBe('relay-0') - expect(existsSync(join(pool, 'slot-0'))).toBe(false) - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-0'))).toBe(false) - }) - - it('rejects a payload reparse point without copying or deleting it', () => { - const pool = createPool() - const destination = join(pool, 'destination') - const foreign = join(pool, 'foreign.js') - mkdirSync(destination) - const stagePath = createStage(host, pool, 0) - rmSync(join(stagePath, 'payload', 'relay.js')) - writeFileSync(foreign, 'foreign') - symlinkSync(foreign, join(stagePath, 'payload', 'relay.js')) - const stage = parseReservedRelayUploadStage( - host, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - - const result = runCommand( - host, - promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) - ) - - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - expect(lstatSync(join(pool, 'slot-0', 'payload', 'relay.js')).isSymbolicLink()).toBe(true) - expect(readFileSync(foreign, 'utf8')).toBe('foreign') - }) - - it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { - const pool = createPool() - createStage(host, pool, 0, owner, false) - createStage(host, pool, 1, '.foreign-owner', true) - createStage(host, pool, 2, owner, true) - - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) - - expect(result.status, result.stderr).toBe(0) - expect(existsSync(join(pool, 'slot-0'))).toBe(true) - expect(existsSync(join(pool, 'slot-1'))).toBe(true) - expect(existsSync(join(pool, 'slot-2'))).toBe(false) - }, 120_000) - - it('drains fixed stale claim and delete states across repeated deployments', () => { - const pool = createPool() - createStage(host, pool, 0, owner, true, 'claim') - createStage(host, pool, 1, owner, true, 'delete') - - for (let attempt = 0; attempt < 2; attempt += 1) { - const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) expect(result.status, result.stderr).toBe(0) - } + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(true) + expect(readFileSync(join(destination, 'relay.js'), 'utf8')).toBe('relay-0') + expect(existsSync(join(pool, 'slot-0'))).toBe(false) + expect(existsSync(join(pool, 'claim-0'))).toBe(false) + expect(existsSync(join(pool, 'delete-0'))).toBe(false) + }) - expect(existsSync(join(pool, 'claim-0'))).toBe(false) - expect(existsSync(join(pool, 'delete-1'))).toBe(false) - }) -}) + // Why: symlink creation in the fixture needs privileges Windows CI doesn't grant. + it.skipIf(process.platform === 'win32')( + 'rejects a payload reparse point without copying or deleting it', + () => { + const pool = createPool() + const destination = join(pool, 'destination') + const foreign = join(pool, 'foreign.js') + mkdirSync(destination) + const stagePath = createStage(host, pool, 0) + rmSync(join(stagePath, 'payload', 'relay.js')) + writeFileSync(foreign, 'foreign') + symlinkSync(foreign, join(stagePath, 'payload', 'relay.js')) + const stage = parseReservedRelayUploadStage( + host, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + + const result = runCommand( + host, + promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) + ) + + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) + expect(existsSync(join(destination, 'relay.js'))).toBe(false) + expect(lstatSync(join(pool, 'slot-0', 'payload', 'relay.js')).isSymbolicLink()).toBe(true) + expect(readFileSync(foreign, 'utf8')).toBe('foreign') + } + ) + + it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { + const pool = createPool() + createStage(host, pool, 0, owner, false) + createStage(host, pool, 1, '.foreign-owner', true) + createStage(host, pool, 2, owner, true) + + const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(pool, 'slot-0'))).toBe(true) + expect(existsSync(join(pool, 'slot-1'))).toBe(true) + expect(existsSync(join(pool, 'slot-2'))).toBe(false) + }) + + it('drains fixed stale claim and delete states across repeated deployments', () => { + const pool = createPool() + createStage(host, pool, 0, owner, true, 'claim') + createStage(host, pool, 1, owner, true, 'delete') + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) + expect(result.status, result.stderr).toBe(0) + } + + expect(existsSync(join(pool, 'claim-0'))).toBe(false) + expect(existsSync(join(pool, 'delete-1'))).toBe(false) + }) + } +) describe('POSIX ownership race fencing', () => { it('restores a replacement directory and preserves the original moved aside before claim', () => { @@ -293,48 +303,52 @@ describe('POSIX ownership race fencing', () => { }) }) -describe.runIf(powerShellExecutable)('PowerShell ownership race fencing', () => { - it('restores an old same-owner replacement whose persisted file ID does not match', () => { - const pool = createPool() - const destination = join(pool, 'destination') - mkdirSync(destination) - createStage(windows, pool, 0) - const stage = parseReservedRelayUploadStage( - windows, - pool, - owner, - `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` - ) - const prefix = [ - '$script:raced = $false', - 'function Move-Item {', - 'param($LiteralPath, $Destination, $ErrorAction)', - 'if (-not $script:raced) {', - '$script:raced = $true', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination ($LiteralPath + ".original") -ErrorAction Stop', - '$null = New-Item -ItemType Directory -Path $LiteralPath', - '$null = New-Item -ItemType Directory -Path (Join-Path $LiteralPath "payload")', - '$newPath = (Get-Item -LiteralPath $LiteralPath).FullName', - '$originalPath = (Get-Item -LiteralPath ($LiteralPath + ".original")).FullName', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-owner"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-owner")))', - '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-identity"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-identity")))', - '(Get-Item -LiteralPath (Join-Path $newPath ".orca-upload-owner") -Force).LastWriteTimeUtc = [DateTime]::UtcNow.AddHours(-2)', - '[System.IO.File]::WriteAllText((Join-Path $newPath "foreign"), "foreign")', - '}', - 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination $Destination -ErrorAction $ErrorAction', - '}' - ].join('\n') +describe.runIf(powerShellExecutable)( + 'PowerShell ownership race fencing', + { timeout: SPAWNED_INTERPRETER_TIMEOUT_MS }, + () => { + it('restores an old same-owner replacement whose persisted file ID does not match', () => { + const pool = createPool() + const destination = join(pool, 'destination') + mkdirSync(destination) + createStage(windows, pool, 0) + const stage = parseReservedRelayUploadStage( + windows, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + const prefix = [ + '$script:raced = $false', + 'function Move-Item {', + 'param($LiteralPath, $Destination, $ErrorAction)', + 'if (-not $script:raced) {', + '$script:raced = $true', + 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination ($LiteralPath + ".original") -ErrorAction Stop', + '$null = New-Item -ItemType Directory -Path $LiteralPath', + '$null = New-Item -ItemType Directory -Path (Join-Path $LiteralPath "payload")', + '$newPath = (Get-Item -LiteralPath $LiteralPath).FullName', + '$originalPath = (Get-Item -LiteralPath ($LiteralPath + ".original")).FullName', + '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-owner"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-owner")))', + '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-identity"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-identity")))', + '(Get-Item -LiteralPath (Join-Path $newPath ".orca-upload-owner") -Force).LastWriteTimeUtc = [DateTime]::UtcNow.AddHours(-2)', + '[System.IO.File]::WriteAllText((Join-Path $newPath "foreign"), "foreign")', + '}', + 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination $Destination -ErrorAction $ErrorAction', + '}' + ].join('\n') - const result = runCommand( - windows, - promoteOwnedRelayUploadStageCommand(windows, stage, owner, destination), - prefix - ) + const result = runCommand( + windows, + promoteOwnedRelayUploadStageCommand(windows, stage, owner, destination), + prefix + ) - expect(result.status, result.stderr).toBe(0) - expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) - expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) - expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) - expect(existsSync(join(destination, 'relay.js'))).toBe(false) - }) -}) + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) + expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) + expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) + expect(existsSync(join(destination, 'relay.js'))).toBe(false) + }) + } +) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 5ed25ee81c3..fdea4edf9ed 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1,6375 +1,7 @@ -/* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useSyncExternalStore -} from 'react' -import { useVirtualizer } from '@tanstack/react-virtual' -import { useShallow } from 'zustand/react/shallow' -import type { editor as monacoEditor } from 'monaco-editor' -import { - ArrowRight, - Ban, - Check, - CheckCircle2, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleDashed, - CircleDot, - Copy, - ExternalLink, - FileText, - FolderKanban, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - ListChecks, - Link2, - LoaderCircle, - MessageSquare, - MessageSquarePlus, - MoveRight, - PanelLeftOpen, - Pencil, - Plus, - RefreshCw, - Search, - Send, - Settings, - UserMinus, - UserPlus, - Wrench, - X -} from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { Input } from '@/components/ui/input' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useConfirmationDialog } from '@/components/confirmation-dialog-context' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger -} from '@/components/ui/accordion' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { cn } from '@/lib/utils' -import { DiffSectionItem } from '@/components/editor/DiffSectionItem' -import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' -import { - CombinedDiffFileTree, - createCombinedDiffSectionIndexMap, - handleCombinedDiffFileTreeNavigation -} from '@/components/editor/CombinedDiffFileTree' -import { - getDiffSectionEstimatedHeight, - isIntrinsicHeightImageDiff -} from '@/components/editor/diff-section-layout' -import type { DiffSection } from '@/components/editor/diff-section-types' -import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { - getCombinedDiffBranchEntriesInTreeOrder, - type CombinedDiffFileTreeEntry -} from '@/components/editor/combined-diff-file-tree-model' -import { - getStoredTextDiffContent, - getStoredTextDiffResult -} from '@/components/editor/large-diff-section-content' -import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' -import { - beginGitHubChecksTabDetails, - createGitHubChecksTabState, - resetGitHubChecksTabForSource, - resolveGitHubChecksTabState, - settleGitHubChecksTabDetails, - toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabLocalChecks, - type CheckDetailsLoadState -} from '@/components/github-checks-tab-state' -import { - clearGitHubLinkCopied, - createGitHubLinkCopyState, - markGitHubLinkCopied, - resolveGitHubLinkCopyState -} from '@/components/github-link-copy-state' -import { - resolveGitHubBodyDraft, - shouldSyncGitHubBodyDraft -} from '@/components/github-body-draft-state' -import { - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - type PRCommentAudienceFilter -} from '../../../shared/pr-comment-audience' -import { - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters -} from '@/lib/pr-comment-audience-labels' -import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' -import { - getPRCommentGroupCount, - getPRCommentGroupId, - getPRCommentGroupRoot, - groupPRComments, - isResolvedPRCommentGroup, - type PRCommentGroup -} from '../../../shared/pr-comment-groups' -import { - PR_COMMENT_OPEN_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS -} from '@/lib/pr-comment-resolution-classes' -import { - getCommentReplyTargetCandidates, - resolveCommentReplyTarget -} from '@/components/comment-reply-target-state' -import { - attachPRReviewReplyParent, - canPostPRReviewThreadReply -} from '@/components/right-sidebar/pr-comments-ai-launch-ack' -import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' -import { useAppStore } from '@/store' -import { useAllWorktrees } from '@/store/selectors' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' -import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' -import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' -import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' -import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' -import { - canUseGitHubRepoContext, - getGitHubMutationRoutingSettings, - getGitHubRuntimeRepoId, - getGitHubSourceRuntimeHost -} from '@/lib/github-source-runtime-context' -import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' -import { - getGitHubPRReviewerRows, - normalizeGitHubReviewerLogins, - parseGitHubReviewerInputLogins -} from '@/components/github-pr-reviewer-display' -import { - filterGitHubPRReviewerCandidates, - getGitHubPRReviewerQueryState -} from '@/components/github/github-pr-reviewer-candidate-filter' -import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import { - GITHUB_PR_MERGE_METHOD_LABELS, - resolveGitHubPRMergeMethods -} from '../../../shared/github/pull-request-merge-methods' -import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' -import { - findGithubIssueWorkspaceAttachment, - getGithubWorkItemWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' -import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt' -import type { GitBranchChangeEntry, GitDiffResult } from '../../../shared/git-diff-compare-types' -import type { PRCheckDetail } from '../../../shared/github/check-types' -import type { - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - PRComment -} from '../../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubOwnerRepo, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState, - GitHubPRMergeMethod -} from '../../../shared/github/pull-request-types' -import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../shared/task-source-context' -import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' -import { translate } from '@/i18n/i18n' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { - buildTaskPageGitHubCloseUpdate, - getTaskPageGitHubDuplicateCandidates, - getTaskPageGitHubDuplicateTargetErrorMessage, - validateTaskPageGitHubDuplicateTarget, - type TaskPageGitHubCloseAction -} from '@/components/task-page-github-status-actions' -import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' -import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' -import { - getCheckConclusion, - getCheckCountChips, - getCheckCounts, - getChecksSummaryLabel -} from '@/components/pr-check-counts' -import { - normalizeItemDialogTab, - parseOwnerRepoFromItemUrl, - resolvePullRequestRepo, - type GitHubWorkItemProjectOrigin, - type ItemDialogTab -} from '@/components/github/github-work-item-identity' -import { - addIssueCommentForRepo, - addPRReviewCommentForRepo, - addPRReviewCommentReplyForRepo, - notifyWorkItemDetailsMutation, - setPRFileViewedForRepo -} from '@/components/github/github-work-item-comment-mutations' -import { - runIssueUpdate, - runPullRequestStateUpdate, - runWorkItemBodyUpdate -} from '@/components/github/github-work-item-edit-mutations' -import { - PR_FILE_CONTENT_CACHE_MAX_BYTES, - getRetainedPRFileContentsByteCount, - isPRFileViewed -} from '@/components/github/pr-file-content-size' -import { - PR_DIFF_OVERSCAN, - getPRFileContentsRenderLimit, - getPRFileDiffResult, - getPRFileSectionKey, - gitHubPRFileToBranchEntry, - type PRFilesCombinedDiffViewerProps -} from '@/components/github/pr-file-diff-mapping' -import { - buildRequestedReviewUsers, - formatRelativeTime, - getStateLabel, - mergeReviewerSuggestions, - ReviewerAvatar -} from '@/components/github/work-item-state-presentation' -import { - formatCheckTimestamp, - getCheckDetailsKey, - getCheckStatusLabel -} from '@/components/github/pr-check-presentation' -import { CommentCodeContext } from '@/components/github/CommentCodeContext' -import { CommentReactions } from '@/components/github/CommentReactions' -import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' -import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' +import GitHubItemDialog from './github-item-dialog/open-dialog/github-item-dialog' -export type { ItemDialogTab } +export type { ItemDialogTab } from './github/github-work-item-identity' +export type { GitHubItemDialogProjectOrigin } from './github-item-dialog/load-item-details/github-item-dialog-types' +export { invalidateWorkItemDetailsCacheForKey } from './github-item-dialog/load-item-details/work-item-details-cache' -/** Re-exported so Project-view callers keep a stable import path. */ -export type GitHubItemDialogProjectOrigin = GitHubWorkItemProjectOrigin - -function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { - try { - const parsed = new URL(itemUrl) - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - return null - } - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments.length < 2) { - return null - } - // Why: preserve the origin so GitHub Enterprise URLs keep working while re-pathing to the repo-scoped labels page. - parsed.pathname = `/${segments[0]}/${segments[1]}/labels` - parsed.search = '' - parsed.hash = '' - return parsed.toString() - } catch { - return null - } -} - -type GitHubItemDialogProps = { - workItem: GitHubWorkItem | null - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - initialTab?: ItemDialogTab - backLabel?: string - /** Called when the user clicks the primary CTA to start work from this item. */ - onUse: (item: GitHubWorkItem) => void - onReviewRequestsChange?: ( - itemKey: { id: string; repoId: string }, - reviewRequests: GitHubAssignableUser[] - ) => void - onClose: () => void - /** Optional Project-origin context; when set, edits route via slug-addressed IPCs against the row's repo (slug routing wins for writes). */ - projectOrigin?: GitHubItemDialogProjectOrigin -} - -function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - // Why: keep closed issues neutral (may be completed/resolved); red is reserved for PR closed-without-merge. - return 'border-ring/50 bg-primary/10 text-foreground' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' -} - -function WorkItemStateBadge({ - item, - className -}: { - item: GitHubWorkItem - className?: string -}): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) -} - -function PRReviewersPanel({ - item, - loading, - repoPath, - sourceContext, - projectOrigin, - onReviewersRequested -}: { - item: GitHubWorkItem - loading: boolean - repoPath: string | null - sourceContext?: TaskSourceContext | null - projectOrigin?: GitHubItemDialogProjectOrigin - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const [open, setOpen] = useState(false) - const [reviewerInput, setReviewerInput] = useState('') - const [activeReviewerCursor, setActiveReviewerCursor] = useState({ - resetKey: '', - index: 0 - }) - const [submitting, setSubmitting] = useState(false) - const [localReviewRequests, setLocalReviewRequests] = useState( - () => item.reviewRequests ?? [] - ) - const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - })) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const reviewerInputRef = useRef(null) - const reviewerInputFocusFrameRef = useRef(null) - const reviewerPanelMountedRef = useRef(true) - - const cancelReviewerInputFocusFrame = useCallback((): void => { - if (reviewerInputFocusFrameRef.current !== null) { - cancelAnimationFrame(reviewerInputFocusFrameRef.current) - reviewerInputFocusFrameRef.current = null - } - }, []) - - const scheduleReviewerInputFocus = useCallback((): void => { - if (!reviewerPanelMountedRef.current) { - return - } - cancelReviewerInputFocusFrame() - reviewerInputFocusFrameRef.current = requestAnimationFrame(() => { - reviewerInputFocusFrameRef.current = null - reviewerInputRef.current?.focus() - }) - }, [cancelReviewerInputFocusFrame]) - - useEffect(() => { - reviewerPanelMountedRef.current = true - return () => { - reviewerPanelMountedRef.current = false - cancelReviewerInputFocusFrame() - } - }, [cancelReviewerInputFocusFrame]) - - // Why: clear stale optimistic reviewer requests on item switch/refetch before paint; a passive Effect would leave one stale render. - if ( - reviewRequestsSource.itemId !== item.id || - reviewRequestsSource.repoId !== item.repoId || - reviewRequestsSource.reviewRequests !== item.reviewRequests - ) { - setReviewRequestsSource({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - }) - setLocalReviewRequests(item.reviewRequests ?? []) - } - - const reviewerSeedUsers = useMemo(() => { - const byLogin = new Map() - const add = (user: GitHubAssignableUser): void => { - if (!user.login) { - return - } - byLogin.set(user.login.toLowerCase(), user) - } - for (const user of localReviewRequests) { - add(user) - } - for (const review of item.latestReviews ?? []) { - add({ - login: review.login, - name: null, - avatarUrl: review.avatarUrl ?? '' - }) - } - if (item.author) { - add({ login: item.author, name: null, avatarUrl: '' }) - } - return Array.from(byLogin.values()) - }, [item.author, item.latestReviews, localReviewRequests]) - - const reviewRepo = useMemo( - () => resolvePullRequestRepo(item, projectOrigin), - [item, projectOrigin] - ) - const reviewerMetadataBySlug = useRepoAssigneesBySlug( - open && reviewRepo ? reviewRepo.owner : null, - open && reviewRepo ? reviewRepo.repo : null, - reviewerSeedUsers.map((user) => user.login), - sourceSettings, - reviewRepo?.host - ) - const reviewerMetadataByPath = useRepoAssignees( - open && !reviewRepo ? repoPath : null, - open && !reviewRepo ? item.repoId : null, - sourceSettings - ) - const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath - const displayItem = { ...item, reviewRequests: localReviewRequests } - const reviewers = getGitHubPRReviewerRows(displayItem) - const authorLogin = item.author?.toLowerCase() ?? null - const reviewerCandidates = useMemo( - () => - mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( - (user) => user.login.toLowerCase() !== authorLogin - ), - [authorLogin, reviewerMetadata.data, reviewerSeedUsers] - ) - const reviewerCandidatesByLogin = useMemo( - () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), - [reviewerCandidates] - ) - const selectedReviewerLogins = useMemo( - () => - new Set( - localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) - ), - [localReviewRequests] - ) - const reviewerQueryState = useMemo( - () => getGitHubPRReviewerQueryState(reviewerInput), - [reviewerInput] - ) - const reviewerQuery = reviewerQueryState.query - const filteredReviewerCandidates = useMemo( - () => - filterGitHubPRReviewerCandidates({ - candidates: reviewerCandidates, - queryState: reviewerQueryState - }), - [reviewerCandidates, reviewerQueryState] - ) - const suggestedReviewerRows = useMemo( - () => - reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge - ? reviewerSeedUsers - .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) - .filter((user) => user.login.toLowerCase() !== authorLogin) - .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) - .slice(0, 1) - : [], - [ - authorLogin, - reviewerCandidatesByLogin, - reviewerQuery.length, - reviewerQueryState.isTooLarge, - reviewerSeedUsers, - selectedReviewerLogins - ] - ) - const everyoneElseReviewerRows = useMemo(() => { - const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) - return filteredReviewerCandidates.filter( - (user) => !suggestedLogins.has(user.login.toLowerCase()) - ) - }, [filteredReviewerCandidates, suggestedReviewerRows]) - const actionableReviewerRows = useMemo( - () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], - [everyoneElseReviewerRows, suggestedReviewerRows] - ) - - const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}` - if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) { - setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 }) - } - const activeReviewerIndex = - activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0 - const setActiveReviewerIndex = useCallback( - (nextIndex: number | ((current: number) => number)): void => { - setActiveReviewerCursor((current) => { - const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0 - return { - resetKey: reviewerCursorResetKey, - index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex - } - }) - }, - [reviewerCursorResetKey] - ) - - const hasReviewerMetadata = - item.reviewDecision !== undefined || - localReviewRequests.length > 0 || - item.reviewRequests !== undefined || - item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' - - const handleRequestReview = async (requestedLogins?: string[]): Promise => { - if (submitting) { - return - } - const logins = normalizeGitHubReviewerLogins( - requestedLogins ?? parseGitHubReviewerInputLogins(reviewerInput), - selectedReviewerLogins - ) - if (logins.length === 0) { - toast.error(translate('auto.components.GitHubItemDialog.94ab23a9f9', 'Enter a reviewer')) - return - } - if (localReviewRequests.length + logins.length > 15) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.12e761610e', - 'You can request up to 15 reviewers' - ) - ) - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.b4af16bf43', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.requestPRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.requestPRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.c42d942b75', 'Failed to request reviewer') - ) - return - } - const nextReviewRequests = buildRequestedReviewUsers( - logins, - reviewerCandidates, - localReviewRequests - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - logins.length === 1 - ? translate('auto.components.GitHubItemDialog.ea985e657f', 'Reviewer requested') - : translate('auto.components.GitHubItemDialog.c016e4bac3', 'Reviewers requested') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.GitHubItemDialog.c42d942b75', 'Failed to request reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { - if (submitting) { - return - } - const selected = new Set(localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())) - const logins = reviewersToRemove - .map((reviewer) => reviewer.trim().replace(/^@/, '')) - .filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase())) - if (logins.length === 0) { - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.b4af16bf43', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.removePRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.removePRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.73487fb975', 'Failed to remove reviewer') - ) - return - } - const removed = new Set(logins.map((login) => login.toLowerCase())) - const nextReviewRequests = localReviewRequests.filter( - (reviewer) => !removed.has(reviewer.login.toLowerCase()) - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - logins.length === 1 - ? translate('auto.components.GitHubItemDialog.69515bff81', 'Reviewer removed') - : translate('auto.components.GitHubItemDialog.2e69540652', 'Reviewers removed') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.GitHubItemDialog.73487fb975', 'Failed to remove reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { - await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) - ? handleRemoveReviewers([reviewer.login]) - : handleRequestReview([reviewer.login])) - scheduleReviewerInputFocus() - } - - const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - scheduleReviewerInputFocus() - return - } - setReviewerInput('') - } - - const renderReviewerPickerRow = ( - reviewer: GitHubAssignableUser, - options: { suggested: boolean; activeIndex: number } - ): React.JSX.Element => { - const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login - return ( - - ) - } - - return ( -
-
- {translate('auto.components.GitHubItemDialog.dc8a092c57', 'Reviewers')} - - - - - { - event.preventDefault() - }} - > -
- setReviewerInput(event.target.value)} - disabled={submitting || !canRequestReview} - placeholder={translate( - 'auto.components.GitHubItemDialog.bb42774171', - 'Type or choose a user' - )} - aria-label={translate('auto.components.GitHubItemDialog.934add88b6', 'Reviewer')} - aria-expanded={open} - aria-haspopup="listbox" - className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs" - onKeyDown={(event) => { - if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => (current + 1) % actionableReviewerRows.length - ) - return - } - if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => - (current - 1 + actionableReviewerRows.length) % - actionableReviewerRows.length - ) - return - } - if (event.key === 'Enter') { - event.preventDefault() - const activeReviewer = actionableReviewerRows[activeReviewerIndex] - if (activeReviewer) { - void requestReviewer(activeReviewer) - return - } - void handleRequestReview() - return - } - if (event.key === 'Escape') { - event.preventDefault() - handleReviewerPickerOpenChange(false) - } - }} - /> -
-
- {reviewerMetadata.loading ? ( -
- {translate('auto.components.GitHubItemDialog.a98433e73d', 'Loading...')} -
- ) : filteredReviewerCandidates.length > 0 ? ( - <> - {suggestedReviewerRows.length > 0 ? ( - <> -
- {translate('auto.components.GitHubItemDialog.c2b21818e1', 'Suggestions')} -
- {suggestedReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: true, - activeIndex: index - }) - )} - - ) : null} -
- {translate('auto.components.GitHubItemDialog.1ffce94a8b', 'Everyone else')} -
- {everyoneElseReviewerRows.length > 0 ? ( - everyoneElseReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: false, - activeIndex: suggestedReviewerRows.length + index - }) - ) - ) : ( -
- {translate( - 'auto.components.GitHubItemDialog.70e84e3d0b', - 'No matching reviewers.' - )} -
- )} - - ) : ( -
- {reviewerMetadata.error ?? - (hasReviewerMetadata - ? translate( - 'auto.components.GitHubItemDialog.70e84e3d0b', - 'No matching reviewers.' - ) - : translate( - 'auto.components.GitHubItemDialog.3f79ffc8b7', - 'Open the PR details to view current reviewers.' - ))} -
- )} -
-
-
-
- {loading && !hasReviewerMetadata ? ( -
- - {translate('auto.components.GitHubItemDialog.6a45771d47', 'Loading reviewers')} -
- ) : reviewers.length > 0 ? ( -
- {reviewers.map((reviewer) => { - const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - return ( -
- -
-
- {reviewer.login} -
- {reviewer.name ? ( -
- {reviewer.name} -
- ) : null} -
- - {reviewer.stateLabel} - - {canRemoveReviewer ? ( - - - - - - {translate('auto.components.GitHubItemDialog.5c1c973855', 'Remove reviewer')} - - - ) : null} -
- ) - })} -
- ) : ( -
- {translate('auto.components.GitHubItemDialog.36f9ac4a47', 'No reviewers requested.')} -
- )} -
- ) -} - -// Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md. -const WORK_ITEM_DETAILS_CACHE_MAX = 50 -const WORK_ITEM_DETAILS_FRESH_MS = 30_000 -const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' -type WorkItemDetailsCacheEntry = { - details: GitHubWorkItemDetails | null - fetchedAt: number - pending?: Promise - error?: string -} -const workItemDetailsCache = new Map() - -// Why: drawers subscribe via useSyncExternalStore so a cached item paints synchronously; snapshot stability relies on every write replacing entry identity (delete+set). -const workItemDetailsCacheListeners = new Set<() => void>() -function subscribeWorkItemDetailsCache(listener: () => void): () => void { - workItemDetailsCacheListeners.add(listener) - return () => { - workItemDetailsCacheListeners.delete(listener) - } -} -function notifyWorkItemDetailsCache(): void { - for (const listener of workItemDetailsCacheListeners) { - listener() - } -} - -function getWorkItemDetailsCacheKey(args: { - repoPath: string - repoId: string - issueSourcePreference: string | undefined - sourceCacheScope?: string | null - type: 'issue' | 'pr' - number: number -}): string { - // Why: key on every axis that changes which (repo, item) the IPC resolves to; `\0` separator avoids ambiguity with fields containing `:` or `/`. - const keyParts = args.sourceCacheScope - ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] - : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] - return [...keyParts, args.number].join('\0') -} - -function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { - // Why: re-insert to move to MRU position; Map insertion order keeps the oldest key first for eviction. - workItemDetailsCache.delete(key) - workItemDetailsCache.set(key, entry) - while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { - const oldest = workItemDetailsCache.keys().next().value - if (oldest === undefined) { - break - } - workItemDetailsCache.delete(oldest) - } - notifyWorkItemDetailsCache() -} - -// Why: exposed so mutation handlers can drop a stale entry after a local mutation; cross-window invalidation arrives via the gh:workItemMutated listener below. -export function invalidateWorkItemDetailsCacheForKey(key: string): void { - // Why: bump generation so a fetch launched before this invalidation won't write its stale result back. - workItemDetailsCacheGeneration += 1 - const existed = workItemDetailsCache.delete(key) - if (existed) { - notifyWorkItemDetailsCache() - } -} - -// Why: bumped on every invalidation so an in-flight refetch started before a mutation can detect its result is stale and skip writing it back. -let workItemDetailsCacheGeneration = 0 - -// Why: without the exact key (e.g. a cross-window event carries only repoPath+number+type), drop every entry matching that tuple regardless of source preference. -function invalidateWorkItemDetailsCacheByMatch(args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number -}): void { - const suffix = `\0${args.type}\0${args.number}` - const prefix = `${args.repoId ?? args.repoPath}\0` - let removed = false - for (const key of Array.from(workItemDetailsCache.keys())) { - if (key.startsWith(prefix) && key.endsWith(suffix)) { - workItemDetailsCache.delete(key) - removed = true - } - } - if (removed) { - workItemDetailsCacheGeneration += 1 - notifyWorkItemDetailsCache() - } -} - -function patchCachedPRFileViewedState( - cacheKey: string, - path: string, - viewerViewedState: GitHubPRFileViewedState -): GitHubPRFileViewedState | undefined { - const prev = workItemDetailsCache.get(cacheKey) - const files = prev?.details?.files - if (!prev?.details || !files) { - return undefined - } - let previousState: GitHubPRFileViewedState | undefined - const nextFiles = files.map((file) => { - if (file.path !== path) { - return file - } - previousState = file.viewerViewedState ?? 'UNVIEWED' - return { ...file, viewerViewedState } - }) - if (previousState === undefined || previousState === viewerViewedState) { - return previousState - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, files: nextFiles }, - error: undefined - }) - return previousState -} - -function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, checks }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedPRReviewRequests( - cacheKey: string, - reviewRequests: GitHubAssignableUser[] -): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { - ...prev.details, - item: { ...prev.details.item, reviewRequests } - }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedWorkItemBody(cacheKey: string, body: string): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, body }, - fetchedAt: Date.now(), - error: undefined - }) -} - -// Why: install once — all dialogs share the cache; track unsubscribe so Vite HMR doesn't accumulate listeners across dev reloads. -let workItemMutatedUnsub: (() => void) | undefined -let workItemDetailsCacheEventUnsub: (() => void) | undefined -if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { - workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => { - invalidateWorkItemDetailsCacheByMatch({ - repoPath: payload.repoPath, - repoId: payload.repoId, - type: payload.type, - number: payload.number - }) - }) - workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => { - invalidateWorkItemDetailsCacheByMatch(payload) - }) -} -if (import.meta !== undefined && import.meta.hot) { - import.meta.hot.dispose(() => { - workItemMutatedUnsub?.() - workItemDetailsCacheEventUnsub?.() - }) -} - -// Why: bounded LRU so opening many PRs with many files doesn't grow this module-level map unboundedly until reload. -const PR_FILE_CONTENT_CACHE_MAX = 64 -type PRFileContentCacheEntry = { - value: Promise | GitHubPRFileContents - byteCount: number -} -const prFileContentCache = new Map() -let prFileContentCacheBytes = 0 - -function touchPRFileContentCache( - key: string, - value: Promise | GitHubPRFileContents -): void { - const retainedByteCount = value instanceof Promise ? 0 : getRetainedPRFileContentsByteCount(value) - if (retainedByteCount === null) { - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - prFileContentCache.delete(key) - return - } - - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - // Why: re-insert to move to MRU position; Map insertion order keeps the oldest key first for eviction. - prFileContentCache.delete(key) - const byteCount = retainedByteCount - prFileContentCache.set(key, { value, byteCount }) - prFileContentCacheBytes += byteCount - while ( - prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX || - prFileContentCacheBytes > PR_FILE_CONTENT_CACHE_MAX_BYTES - ) { - const oldest = prFileContentCache.keys().next().value - if (oldest === undefined) { - break - } - const evicted = prFileContentCache.get(oldest) - prFileContentCacheBytes -= evicted?.byteCount ?? 0 - prFileContentCache.delete(oldest) - } -} - -function getPRFileContentCacheKey(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): string { - const repositoryKey = args.repoId ? `repo:${args.repoId}` : `path:${args.repoPath}` - const sourceKey = - args.sourceContext?.provider === 'github' - ? `source:${getTaskSourceCacheScope(args.sourceContext)}` - : 'source:local' - return [ - repositoryKey, - sourceKey, - args.prNumber, - args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', - args.file.path, - args.file.oldPath ?? '', - args.file.status, - args.headSha, - args.baseSha - ].join('\0') -} - -function loadPRFileContents(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): Promise { - const cacheKey = getPRFileContentCacheKey(args) - const cached = prFileContentCache.get(cacheKey) - if (cached) { - touchPRFileContentCache(cacheKey, cached.value) - return Promise.resolve(cached.value) - } - let request: Promise - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - request = ( - runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prFileContents', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prFileContents({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - ) - .then((contents) => { - if (prFileContentCache.get(cacheKey)?.value === request) { - touchPRFileContentCache(cacheKey, contents) - } - return contents - }) - .catch((err) => { - const cachedRequest = prFileContentCache.get(cacheKey) - if (cachedRequest?.value === request) { - prFileContentCacheBytes -= cachedRequest.byteCount - prFileContentCache.delete(cacheKey) - } - throw err - }) - touchPRFileContentCache(cacheKey, request) - return request -} - -function PRFilesCombinedDiffViewer({ - files, - comments, - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - prUrl, - headSha, - baseSha, - pendingViewedPaths, - onCommentAdded, - onViewedChange -}: PRFilesCombinedDiffViewerProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const entriesCacheRef = useRef<{ - signature: string - entries: GitBranchChangeEntry[] - } | null>(null) - const diffEntrySignature = useMemo( - () => - JSON.stringify( - files.map((file) => ({ - path: file.path, - oldPath: file.oldPath ?? null, - status: file.status, - additions: file.additions, - deletions: file.deletions, - isBinary: file.isBinary - })) - ), - [files] - ) - const entries = useMemo(() => { - if (entriesCacheRef.current?.signature === diffEntrySignature) { - return entriesCacheRef.current.entries - } - const nextEntries = getCombinedDiffBranchEntriesInTreeOrder( - 'commit', - files.map(gitHubPRFileToBranchEntry) - ) - entriesCacheRef.current = { - signature: diffEntrySignature, - entries: nextEntries - } - return nextEntries - }, [diffEntrySignature, files]) - const fileByPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files]) - const inlineReviewComments = useMemo( - () => - comments.flatMap((comment): DecoratedDiffComment[] => { - // Why: outdated threads' line number can attach the comment to unrelated current code, so skip them inline. - if (comment.isOutdated || !comment.path || typeof comment.line !== 'number') { - return [] - } - const createdAtMs = new Date(comment.createdAt).getTime() - return [ - { - id: `github-pr-comment:${comment.id}`, - worktreeId: `github-pr:${repoId}:${prNumber}`, - filePath: comment.path, - source: 'diff', - startLine: comment.startLine, - lineNumber: comment.line, - body: comment.body, - createdAt: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(), - side: 'modified', - author: comment.author, - authorAvatarUrl: comment.authorAvatarUrl, - createdAtLabel: formatRelativeTime(comment.createdAt), - url: comment.url, - canDelete: false, - canEdit: false - } - ] - }), - [comments, prNumber, repoId] - ) - const entrySignature = useMemo( - () => - JSON.stringify({ - repoId, - prNumber, - prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, - headSha: headSha ?? null, - baseSha: baseSha ?? null, - files: diffEntrySignature - }), - [baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId] - ) - const [sections, setSections] = useState([]) - const [sideBySide, setSideBySide] = useState(false) - const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false) - const [sectionHeights, setSectionHeights] = useState>({}) - const [activeTreeSectionKey, setActiveTreeSectionKey] = useState(null) - const scrollContainerRef = useRef(null) - const loadedIndicesRef = useRef>(new Set()) - const loadingIndicesRef = useRef>(new Set()) - const sectionsRef = useRef([]) - const generationRef = useRef(0) - const modifiedEditorsRef = useRef>(new Map()) - const handleSectionSaveRef = useRef<(index: number) => Promise>(async () => {}) - sectionsRef.current = sections - - useEffect(() => { - generationRef.current += 1 - loadedIndicesRef.current.clear() - loadingIndicesRef.current.clear() - setSectionHeights({}) - setActiveTreeSectionKey(null) - setSections( - entries.map((entry) => ({ - key: getPRFileSectionKey(entry.path), - path: entry.path, - oldPath: entry.oldPath, - status: entry.status, - added: entry.added, - removed: entry.removed, - originalContent: '', - modifiedContent: '', - collapsed: false, - loading: true, - error: undefined, - dirty: false, - diffResult: null, - largeDiffRenderLimit: null - })) - ) - }, [entries, entrySignature]) - - const loadSection = useCallback( - (index: number) => { - const section = sectionsRef.current[index] - if (!section || section.collapsed) { - return - } - if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { - return - } - const file = fileByPath.get(section.path) - if (!file) { - return - } - const generation = generationRef.current - loadingIndicesRef.current.add(index) - - const load = async (): Promise<{ - result: GitDiffResult - resultContents?: GitHubPRFileContents - error?: string - }> => { - if (file.isBinary) { - return { - result: { - kind: 'binary', - originalContent: '', - modifiedContent: '', - originalIsBinary: true, - modifiedIsBinary: true - } - } - } - if (!headSha || !baseSha) { - return { - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - }, - error: translate( - 'auto.components.GitHubItemDialog.829674460a', - 'Diff unavailable because the PR commit SHAs are missing.' - ) - } - } - const contents = await loadPRFileContents({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - file, - headSha, - baseSha - }) - return { result: getPRFileDiffResult(contents), resultContents: contents } - } - - load() - .catch((error) => ({ - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } as GitDiffResult, - resultContents: undefined, - error: error instanceof Error ? error.message : 'Failed to load diff.' - })) - .then(({ result, resultContents, error }) => { - loadingIndicesRef.current.delete(index) - if (generationRef.current !== generation) { - return - } - const largeDiffRenderLimit = - !error && result.kind === 'text' && resultContents - ? getPRFileContentsRenderLimit(resultContents) - : null - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - setSections((prev) => - prev.map((current, currentIndex) => - currentIndex === index - ? { - ...current, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit - } - : current - ) - ) - }) - }, - [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const retrySection = useCallback( - (index: number) => { - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - diffResult: null, - originalContent: '', - modifiedContent: '', - loading: true, - error: undefined, - largeDiffRenderLimit: null - } - : section - ) - ) - loadSection(index) - }, - [loadSection] - ) - - const toggleSection = useCallback( - (index: number) => { - const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section - ) - ) - if (shouldLoadAfterExpand) { - window.requestAnimationFrame(() => loadSection(index)) - } - }, - [loadSection] - ) - - const setAllSectionsCollapsed = useCallback( - (collapsed: boolean) => { - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - if (!collapsed) { - window.requestAnimationFrame(() => { - sectionsRef.current.forEach((_, index) => loadSection(index)) - }) - } - }, - [loadSection] - ) - - const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) - const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) - const viewedSectionKeys = useMemo( - () => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))), - [files] - ) - - const virtualizer = useVirtualizer({ - count: sections.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => { - const section = sections[index] - if (!section) { - return 88 - } - return getDiffSectionEstimatedHeight({ - collapsed: section.collapsed, - measuredContentHeight: sectionHeights[index], - originalContent: section.originalContent, - modifiedContent: section.modifiedContent, - changedLineCount: - section.added === undefined && section.removed === undefined - ? undefined - : (section.added ?? 0) + (section.removed ?? 0), - useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult), - isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true, - lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined - }) - }, - overscan: PR_DIFF_OVERSCAN, - getItemKey: (index) => { - const section = sections[index] - return section - ? `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${entrySignature}` - : `${index}:${entrySignature}` - } - }) - - useLayoutEffect(() => { - virtualizer.measure() - }, [sideBySide, virtualizer]) - - const handleTreeNavigate = useCallback( - (entry: CombinedDiffFileTreeEntry) => { - const navigatedIndex = handleCombinedDiffFileTreeNavigation({ - mode: 'commit', - entry, - sections: sectionsRef.current, - sectionIndexByKey, - toggleSection, - scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' }) - }) - if (navigatedIndex !== null) { - setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null) - } - }, - [sectionIndexByKey, toggleSection, virtualizer] - ) - - const openFilesOnGitHub = useCallback(() => { - void window.api.shell.openUrl(`${prUrl.replace(/\/$/, '')}/files`) - }, [prUrl]) - - const handleAddLineComment = useCallback( - async ( - section: DiffSection, - { - lineNumber, - startLine, - body - }: { - lineNumber: number - startLine?: number - body: string - } - ) => { - if (!headSha) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.d1fa2cf888', - 'Unable to comment without the PR head SHA.' - ) - ) - return false - } - const result = await addPRReviewCommentForRepo({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - commitId: headSha, - path: section.path, - line: lineNumber, - startLine, - body - }) - if (!result.ok) { - toast.error( - result.error || - translate( - 'auto.components.GitHubItemDialog.b0b09778c8', - 'Failed to add review comment.' - ) - ) - return false - } - onCommentAdded(result.comment) - toast.success( - translate('auto.components.GitHubItemDialog.a341343303', 'Review comment added.') - ) - return true - }, - [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const renderViewedCheckbox = useCallback( - (section: DiffSection) => { - const file = fileByPath.get(section.path) - if (!file) { - return null - } - const viewed = isPRFileViewed(file) - const pending = pendingViewedPaths.has(file.path) - return ( - { - if (!pending) { - void onViewedChange(file.path, !viewed) - } - }} - /> - ) - }, - [fileByPath, onViewedChange, pendingViewedPaths] - ) - - return ( -
-
-
- {fileTreeCollapsed && ( - - - - - - {translate('auto.components.GitHubItemDialog.1257d1435d', 'Show file tree')} - - - )} - - {files.filter(isPRFileViewed).length} / {files.length}{' '} - {translate('auto.components.GitHubItemDialog.f2d02cdf8c', 'files viewed')} - -
-
- - -
-
-
- -
-
- {virtualizer.getVirtualItems().map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } - return ( -
- - fileByPath.get(section.path)?.reviewCommentLineNumbers - } - setSectionHeights={setSectionHeights} - setSections={setSections} - modifiedEditorsRef={modifiedEditorsRef} - handleSectionSaveRef={handleSectionSaveRef} - /> -
- ) - })} -
-
-
-
- ) -} - -type IssueConversationEntry = - | { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number } - | { - kind: 'activity' - id: string - createdAt: string - activity: GitHubIssueTimelineItem - index: number - } - -const EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS: GitHubIssueTimelineItem[] = [] - -function getTimelineSortValue(createdAt: string): number { - const value = new Date(createdAt).getTime() - return Number.isFinite(value) ? value : 0 -} - -function getIssueConversationEntries( - comments: PRComment[], - timelineItems: GitHubIssueTimelineItem[] -): IssueConversationEntry[] { - return [ - ...comments.map( - (comment, index): IssueConversationEntry => ({ - kind: 'comment', - id: `comment:${comment.id}`, - createdAt: comment.createdAt, - comment, - index - }) - ), - ...timelineItems.map( - (activity, index): IssueConversationEntry => ({ - kind: 'activity', - id: `activity:${activity.id}`, - createdAt: activity.createdAt, - activity, - index: comments.length + index - }) - ) - ].sort((a, b) => { - const diff = getTimelineSortValue(a.createdAt) - getTimelineSortValue(b.createdAt) - return diff === 0 ? a.index - b.index : diff - }) -} - -function getTimelineTargetLabel(target: GitHubIssueTimelineTarget): string { - const prefix = target.type === 'pr' ? 'PR' : 'issue' - const title = target.title ? ` ${target.title}` : '' - return `${prefix} #${target.number}${title}` -} - -function getTimelineStateReasonLabel(reason: string | null | undefined): string | null { - if (reason === 'completed') { - return translate('auto.components.GitHubItemDialog.timeline.completed', 'as completed') - } - if (reason === 'not_planned') { - return translate('auto.components.GitHubItemDialog.timeline.notPlanned', 'as not planned') - } - return null -} - -function ConversationTab({ - item, - repoPath, - sourceContext, - body, - comments, - timelineItems, - files, - headSha, - baseSha, - loading, - detailsLoaded, - checks, - localState, - onStateChange, - projectOrigin, - onMutated, - onChecksUpdated, - onBodyUpdated, - onCommentAdded, - onReviewersRequested -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - body: string - comments: PRComment[] - timelineItems?: GitHubIssueTimelineItem[] - files: GitHubPRFile[] - headSha: string | undefined - baseSha: string | undefined - loading: boolean - detailsLoaded: boolean - checks: GitHubWorkItemDetails['checks'] - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - projectOrigin: GitHubItemDialogProjectOrigin | undefined - onMutated: () => void - onChecksUpdated: (checks: PRCheckDetail[]) => void - onBodyUpdated: (body: string) => void - onCommentAdded: (comment: PRComment) => void - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const authorLabel = item.author ?? 'unknown' - const [replyingTo, setReplyingTo] = useState(null) - const [commentFilter, setCommentFilter] = useState('all') - const [bodyDraft, setBodyDraft] = useState(body) - const [bodyEditing, setBodyEditing] = useState(false) - const [bodySaving, setBodySaving] = useState(false) - const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext) - const botAuthorOverrides = usePRBotAuthorOverrides() - const commentCounts = useMemo( - () => getPRCommentAudienceCounts(comments, botAuthorOverrides), - [botAuthorOverrides, comments] - ) - const visibleComments = useMemo( - () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides), - [botAuthorOverrides, commentFilter, comments] - ) - const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) - const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS - const issueConversationEntries = useMemo( - () => getIssueConversationEntries(comments, resolvedTimelineItems), - [comments, resolvedTimelineItems] - ) - const replyTargetComments = getCommentReplyTargetCandidates(item.type, comments, visibleComments) - const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, replyTargetComments) - - if (resolvedReplyingTo !== replyingTo) { - // Why: filters/refetches can hide the active reply target; clear before paint so a stale composer doesn't flash for the wrong comment set. - setReplyingTo(resolvedReplyingTo) - } - - const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing) - if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) { - // Why: a background refresh can change the body while the editor is closed; reconcile before paint so reopening never sees a stale draft. - setBodyDraft(resolvedBodyDraft) - } - - const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) - const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) - const markdownGitHubRepo = useMemo( - () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), - [bodySlug, projectOrigin] - ) - const canEditBody = - item.type === 'pr' - ? Boolean(projectOrigin || bodySlug) - : Boolean(projectOrigin || canUseRepoMutationContext) - const bodyChanged = resolvedBodyDraft !== body - - const handleSaveBody = useCallback(async (): Promise => { - if (bodySaving || !bodyChanged) { - setBodyEditing(false) - return - } - setBodySaving(true) - try { - await runWorkItemBodyUpdate({ - item, - repoPath, - sourceContext, - projectOrigin, - body: resolvedBodyDraft, - parsedSlug: bodySlug - }) - onBodyUpdated(resolvedBodyDraft) - setBodyEditing(false) - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - translate('auto.components.GitHubItemDialog.5221548274', 'Description updated.') - ) - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.components.GitHubItemDialog.58c73cb0d8', - 'Failed to update description.' - ) - ) - } finally { - setBodySaving(false) - } - }, [ - bodyChanged, - resolvedBodyDraft, - bodySaving, - bodySlug, - item, - onBodyUpdated, - projectOrigin, - repoPath, - sourceContext - ]) - - const handleReply = useCallback( - async (comment: PRComment, replyBody: string): Promise => { - if (!canUseRepoMutationContext) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.745c9089ec', - 'Unable to reply without a repository path.' - ) - ) - return false - } - // Why: nest under review threads (path/threadId/discussion_r); never post a - // separate top-level conversation comment for those. - const isReviewThreadReply = item.type === 'pr' && canPostPRReviewThreadReply(comment) - const result = isReviewThreadReply - ? await addPRReviewCommentReplyForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - prRepo, - commentId: comment.id, - body: replyBody, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }) - : await addIssueCommentForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - number: item.number, - // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. - body: buildPRCommentConversationReplyBody(comment.author, replyBody), - type: item.type, - prRepo - }) - - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.GitHubItemDialog.283699bc82', 'Failed to post reply.') - ) - return false - } - onCommentAdded( - isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment - ) - setReplyingTo(null) - toast.success(translate('auto.components.GitHubItemDialog.10f4ff5be8', 'Reply posted.')) - return true - }, - [ - canUseRepoMutationContext, - item.number, - item.repoId, - item.type, - onCommentAdded, - prRepo, - repoPath, - sourceContext - ] - ) - - const rightPanel = - item.type === 'pr' ? ( -
- - - - -
- ) : null - - const renderCommentCard = (comment: PRComment, isReply = false): React.JSX.Element => ( -
-
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} - - {comment.author} - - - · {formatRelativeTime(comment.createdAt)} - - {comment.path && ( - - {comment.path.split('/').pop()} - {comment.line - ? translate('auto.components.GitHubItemDialog.136542c9ba', ':L{{value0}}', { - value0: comment.line - }) - : ''} - - )} - {comment.isResolved && ( - - {translate('auto.components.GitHubItemDialog.68cb993d61', 'resolved')} - - )} -
- - - - - - {translate('auto.components.GitHubItemDialog.bca8eb39ac', 'Reply to comment')} - - - {comment.url && ( - - - - - - {translate('auto.components.GitHubItemDialog.a154ec5224', 'Open comment on GitHub')} - - - )} -
-
-
- - - - {resolvedReplyingTo === comment.id && ( - setReplyingTo(null)} - onSubmit={(replyBody) => handleReply(comment, replyBody)} - /> - )} -
-
- ) - - const renderCommentGroup = (group: PRCommentGroup): React.JSX.Element => { - const cards = - group.kind === 'thread' - ? [ - renderCommentCard(group.root), - ...group.replies.map((reply) => renderCommentCard(reply, true)) - ] - : [renderCommentCard(group.comment)] - - if (!isResolvedPRCommentGroup(group)) { - return ( -
- {cards} -
- ) - } - - const root = getPRCommentGroupRoot(group) - const count = getPRCommentGroupCount(group) - return ( - - - - - {translate('auto.components.GitHubItemDialog.228e2f59d3', 'Resolved')}{' '} - {group.kind === 'thread' - ? translate('auto.components.GitHubItemDialog.28d0d3374f', 'thread') - : translate('auto.components.GitHubItemDialog.e2bf3e41a9', 'comment')}{' '} - {translate('auto.components.GitHubItemDialog.0ae387d8ca', 'by')} {root.author} - {count > 1 ? ` (${count})` : ''} - - - - {cards} - - - - ) - } - - const renderTimelineTarget = (target: GitHubIssueTimelineTarget | undefined): React.ReactNode => { - if (!target) { - return null - } - return ( - - ) - } - - const renderTimelineActivityMessage = (activity: GitHubIssueTimelineItem): React.ReactNode => { - const assignee = - activity.assignee ?? translate('auto.components.GitHubItemDialog.timeline.someone', 'someone') - if (activity.event === 'assigned') { - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.assigned', 'assigned')}{' '} - {assignee} - - ) - } - if (activity.event === 'unassigned') { - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '} - {assignee} - - ) - } - if (activity.event === 'mentioned' || activity.event === 'cross-referenced') { - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.mentioned', 'mentioned this')} - {activity.source ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} - {renderTimelineTarget(activity.source)} - - ) : null} - - ) - } - if (activity.event === 'closed') { - const stateReason = getTimelineStateReasonLabel(activity.stateReason) - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.closed', 'closed this')} - {stateReason ? ` ${stateReason}` : ''} - {activity.closer ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} - {renderTimelineTarget(activity.closer)} - - ) : null} - - ) - } - if (activity.event === 'reopened') { - return translate('auto.components.GitHubItemDialog.timeline.reopened', 'reopened this') - } - const hasFrom = Boolean(activity.previousColumnName) - const hasTo = Boolean(activity.columnName) - return ( - <> - {translate('auto.components.GitHubItemDialog.timeline.moved', 'moved this')} - {hasFrom ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.from', 'from')}{' '} - {activity.previousColumnName} - - ) : null} - {hasTo ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '} - {activity.columnName} - - ) : null} - {activity.projectName ? ( - <> - {' '} - {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} - {activity.projectName} - - ) : null} - - ) - } - - const renderTimelineActivity = (activity: GitHubIssueTimelineItem): React.JSX.Element => { - const Icon = - activity.event === 'assigned' - ? UserPlus - : activity.event === 'unassigned' - ? UserMinus - : activity.event === 'closed' - ? CheckCircle2 - : activity.event === 'reopened' - ? CircleDot - : activity.event === 'moved_columns_in_project' - ? MoveRight - : Link2 - return ( -
- - - - {activity.actorAvatarUrl ? ( - - ) : null} -
-
- {activity.actor} - {renderTimelineActivityMessage(activity)} - - {formatRelativeTime(activity.createdAt)} - -
-
-
- ) - } - - const renderIssueConversationEntry = (entry: IssueConversationEntry): React.JSX.Element => - entry.kind === 'comment' - ? renderCommentCard(entry.comment) - : renderTimelineActivity(entry.activity) - - return ( -
-
-
-
- {authorLabel} - - {translate('auto.components.GitHubItemDialog.8223320f8d', 'updated')}{' '} - {formatRelativeTime(item.updatedAt)} - - {canEditBody && !loading && detailsLoaded ? ( - bodyEditing ? ( -
- - -
- ) : ( - - - - - - {translate('auto.components.GitHubItemDialog.4d555d3796', 'Edit description')} - - - ) - ) : null} -
-
- {loading && !detailsLoaded ? ( -
- -
- ) : bodyEditing ? ( - void handleSaveBody()} - /> - ) : body.trim() ? ( - - ) : ( - - {translate( - 'auto.components.GitHubItemDialog.9b9cb55994', - 'No description provided.' - )} - - )} -
-
- - {detailsLoaded ? ( - <> -
- {item.type === 'issue' ? ( - - ) : ( - - )} - - {item.type === 'issue' - ? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity') - : translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} - - {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0) > 0 && ( - - {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0)} - - )} -
- - {item.type === 'pr' && comments.length > 0 && ( -
- {getPrCommentAudienceFilters().map((filter) => { - const isActive = commentFilter === filter.value - return ( - - ) - })} -
- )} - - {item.type === 'issue' ? ( - issueConversationEntries.length === 0 ? ( -
- {translate( - 'auto.components.GitHubItemDialog.timeline.noActivity', - 'No activity yet.' - )} -
- ) : ( -
- {issueConversationEntries.map(renderIssueConversationEntry)} -
- ) - ) : comments.length === 0 ? ( -
- {translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')} -
- ) : visibleComments.length === 0 ? ( -
- {getPRCommentAudienceEmptyLabel(commentFilter)} -
- ) : ( -
- {visibleCommentGroups.map(renderCommentGroup)} -
- )} - - ) : null} - - {detailsLoaded && canUseRepoMutationContext && ( - - )} -
- - {rightPanel} -
- ) -} - -function PRActionsPanel({ - item, - repoPath, - repoId, - sourceContext, - projectOrigin, - localState, - onStateChange, - onMutated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - onMutated: () => void -}): React.JSX.Element { - const [statePending, setStatePending] = useState(false) - const [mergePending, setMergePending] = useState(false) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const confirm = useConfirmationDialog() - const actionItem = { ...item, state: localState } - const mergePresentation = presentGitHubPRMergeState(actionItem) - const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = useAppStore( - useShallow((s) => - getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) - ) - ) - const mergeTarget = getActiveRuntimeTarget(sourceSettings) - const prRepo = resolvePullRequestRepo(item, projectOrigin) - const canMutateWithRepoContext = - !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' - const canMutateState = localState !== 'merged' && canMutateWithRepoContext - const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' - const canMergeWithRepoContext = !!repoPath || mergeTarget.kind === 'environment' - const mergeDisabled = - !canMergeWithRepoContext || mergePending || !mergePresentation.directMergeAvailable - - const patchProjectRowIfNeeded = useCallback( - (state: GitHubWorkItem['state']) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) - }, - [patchProjectRowContent, projectOrigin] - ) - - const applyStatePatch = useCallback( - (state: GitHubWorkItem['state']) => { - onStateChange(state) - patchWorkItem(item.id, { state }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(state) - }, - [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext] - ) - - const handleStateChange = async (): Promise => { - if (!canMutateState || statePending) { - return - } - const label = nextState === 'closed' ? 'Close' : 'Reopen' - const confirmed = await confirm({ - title: translate( - 'auto.components.GitHubItemDialog.03d7216d62', - '{{value0}} PR #{{value1}}?', - { value0: label, value1: item.number } - ), - description: - nextState === 'closed' - ? translate( - 'auto.components.GitHubItemDialog.de45fedf7b', - 'This will close the pull request on GitHub.' - ) - : translate( - 'auto.components.GitHubItemDialog.b6f1b7adbd', - 'This will reopen the pull request on GitHub.' - ), - confirmLabel: label, - confirmVariant: nextState === 'closed' ? 'destructive' : 'default' - }) - if (!confirmed) { - return - } - const previousState = localState - setStatePending(true) - // Why: without registry authority a search-lagged Tasks refetch silently - // reverts this row to its pre-mutation state (STA-3343). - const authority = assertTaskPageGitHubDialogStateAuthority({ - repoId: item.repoId, - itemId: item.id, - state: nextState, - sourceContext - }) - applyStatePatch(nextState) - try { - await runPullRequestStateUpdate({ - repoPath, - repoId, - sourceContext, - projectOrigin, - number: item.number, - prRepo, - updates: { state: nextState } - }) - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - nextState === 'closed' - ? translate('auto.components.GitHubItemDialog.9f88657c4e', 'Pull request closed') - : translate('auto.components.GitHubItemDialog.bd3b4492a0', 'Pull request reopened') - ) - onMutated() - } catch (err) { - if (authority.revert()) { - applyStatePatch(previousState) - } - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.e9b7cb7d17', 'Failed to {{value0}} PR', { - value0: label.toLowerCase() - }) - ) - } finally { - setStatePending(false) - } - } - - const handleMerge = async (method: GitHubPRMergeMethod): Promise => { - if (mergeDisabled) { - return - } - const label = GITHUB_PR_MERGE_METHOD_LABELS[method] - const confirmed = await confirm({ - title: translate( - 'auto.components.GitHubItemDialog.03d7216d62', - '{{value0}} PR #{{value1}}?', - { value0: label, value1: item.number } - ), - description: translate( - 'auto.components.GitHubItemDialog.a27ee5ca1a', - 'This will update the pull request on GitHub.' - ), - confirmLabel: label - }) - if (!confirmed) { - return - } - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.mergePR', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - method, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.mergePR({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - method, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - // Why: merge is confirmed here; hold 'merged' against search-lagged refetches. - assertTaskPageGitHubDialogStateAuthority({ - repoId: item.repoId, - itemId: item.id, - state: 'merged', - sourceContext - }) - applyStatePatch('merged') - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success(translate('auto.components.GitHubItemDialog.dbe5e2448e', 'Pull request merged')) - onMutated() - } catch { - toast.error( - translate('auto.components.GitHubItemDialog.aba792c8b3', 'Failed to merge pull request') - ) - } finally { - setMergePending(false) - } - } - - const handleAutoMerge = async (): Promise => { - if (!canMergeWithRepoContext || !mergePresentation.autoMergeAction) { - return - } - const enabled = mergePresentation.autoMergeAction.kind === 'enable' - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.setPRAutoMerge', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRAutoMerge({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - useAppStore.getState().recordFeatureInteraction('github-tasks') - toast.success( - enabled - ? translate('auto.components.GitHubItemDialog.a35ea5a0f6', 'Auto-merge enabled') - : translate('auto.components.GitHubItemDialog.4b390bd50d', 'Auto-merge disabled') - ) - onMutated() - } catch { - toast.error( - enabled - ? translate('auto.components.GitHubItemDialog.825a8fb8cd', 'Failed to enable auto-merge') - : translate('auto.components.GitHubItemDialog.ce360fc318', 'Failed to disable auto-merge') - ) - } finally { - setMergePending(false) - } - } - - return ( - - ) -} - -function CommentReplyForm({ - className, - placeholder, - onCancel, - onSubmit -}: { - className?: string - placeholder: string - onCancel: () => void - onSubmit: (body: string) => Promise -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const mountedRef = useMountedRef() - - const submit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty' || submitting) { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.GitHubItemDialog.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const ok = await onSubmit(bodyState.body) - if (!mountedRef.current) { - return - } - if (ok) { - setBody('') - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, mountedRef, onSubmit, submitting]) - const canSubmitReply = hasBoundedCommentBodyText(body) - - return ( -
- void submit()} - /> -
- - -
-
- ) -} - -function ChecksTab({ - item, - repoPath, - repoId, - sourceContext, - headSha, - checks, - loading, - variant = 'compact', - onChecksUpdated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - headSha: string | undefined - checks: GitHubWorkItemDetails['checks'] - loading: boolean - variant?: 'compact' | 'page' - onChecksUpdated: (checks: PRCheckDetail[]) => void -}): React.JSX.Element { - const [fixingChecks, setFixingChecks] = useState(false) - const mountedRef = useMountedRef() - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) - const nextCheckDetailsRequestIdRef = useRef(0) - const checkDetailsContextKey = [ - sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', - repoId ?? item.repoId ?? '', - repoPath ?? '', - prRepo ? githubRepoIdentityKey(prRepo) : '', - item.id, - item.number, - headSha ?? '' - ].join('\0') - const [checksState, setChecksState] = useState(() => - createGitHubChecksTabState(checks, checkDetailsContextKey) - ) - const resolvedChecksState = resolveGitHubChecksTabState( - checksState, - checks, - checkDetailsContextKey - ) - const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) - const nextChecksRefreshRequestIdRef = useRef(0) - const activeChecksRefreshRequestIdRef = useRef(null) - const [refreshingOwner, setRefreshingOwner] = useState<{ - contextOwner: object - requestId: number - } | null>(null) - const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner - const [rerunningOwner, setRerunningOwner] = useState(null) - const rerunning = rerunningOwner === resolvedChecksState.contextOwner - useLayoutEffect(() => { - committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner - }, [resolvedChecksState.contextOwner]) - if (resolvedChecksState !== checksState) { - // Why: a parent check refresh replaces the source list; reset local state before stale rows/details can paint. - setChecksState(resolvedChecksState) - } - const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState - const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) - const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) - const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = sortChecksBySeverity(list) - const failedChecks = getBrokenChecks(list) - const counts = getCheckCounts(list) - const summaryLabel = getChecksSummaryLabel(list) - // Why: keying the green tick off `list.length` painted an all-neutral PR green above the words - // "0 of N checks passing"; nothing passed, so it reads unresolved like the checks pill does. - const SummaryIcon = - counts.failing > 0 - ? CHECK_ICON.failure - : counts.needsAction > 0 - ? CHECK_ICON.action_required - : counts.pending > 0 - ? CHECK_ICON.pending - : counts.passing > 0 - ? CHECK_ICON.success - : CircleDashed - const summaryColor = - counts.failing > 0 - ? CHECK_COLOR.failure - : counts.needsAction > 0 - ? CHECK_COLOR.action_required - : counts.pending > 0 - ? CHECK_COLOR.pending - : counts.passing > 0 - ? CHECK_COLOR.success - : 'text-muted-foreground' - const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - - const handleRefresh = useCallback( - async (expectedContextOwner?: object): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.e7007aa1d8', - 'Unable to refresh checks without a repository path.' - ) - ) - return null - } - const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current - if (committedChecksContextOwnerRef.current !== refreshContextOwner) { - return null - } - const refreshRequestId = ++nextChecksRefreshRequestIdRef.current - activeChecksRefreshRequestIdRef.current = refreshRequestId - setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - prRepo, - noCache: true - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - if ( - !mountedRef.current || - committedChecksContextOwnerRef.current !== refreshContextOwner || - activeChecksRefreshRequestIdRef.current !== refreshRequestId - ) { - return null - } - setChecksState((current) => - current.contextOwner === refreshContextOwner - ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) - : current - ) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - if ( - mountedRef.current && - committedChecksContextOwnerRef.current === refreshContextOwner && - activeChecksRefreshRequestIdRef.current === refreshRequestId - ) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.0bbdc673c1', 'Failed to refresh checks') - ) - } - return null - } finally { - if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { - activeChecksRefreshRequestIdRef.current = null - } - if (mountedRef.current) { - setRefreshingOwner((current) => - current?.requestId === refreshRequestId ? null : current - ) - } - } - }, - [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - mountedRef, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleRerun = useCallback( - async (failedOnly: boolean): Promise => { - if (!canUseChecksRepoContext || rerunning) { - return - } - const rerunContextOwner = committedChecksContextOwnerRef.current - setRerunningOwner(rerunContextOwner) - try { - const result = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.rerunPRChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - failedOnly, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.rerunPRChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - failedOnly, - prRepo - }) - if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { - return - } - if (!result.ok) { - toast.error(result.error) - return - } - toast.success( - result.count === 1 - ? translate('auto.components.GitHubItemDialog.ddafe851e1', 'Check rerun requested') - : translate('auto.components.GitHubItemDialog.e463ec935f', 'Check reruns requested') - ) - await handleRefresh(rerunContextOwner) - } catch (err) { - if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.9e7c221b8d', 'Failed to rerun checks') - ) - } - } finally { - if (mountedRef.current) { - setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) - } - } - }, - [ - canUseChecksRepoContext, - handleRefresh, - headSha, - item.number, - item.repoId, - mountedRef, - prRepo, - runtimeHost, - rerunning, - repoId, - repoPath, - sourceContext - ] - ) - - const handleFixBrokenChecks = useCallback(async (): Promise => { - const targetRepoId = repoId ?? item.repoId - if (!targetRepoId || fixingChecks) { - return - } - if (failedChecks.length === 0) { - toast.message( - translate('auto.components.GitHubItemDialog.1690fd7f4a', 'No broken checks to fix.') - ) - return - } - - const basePrompt = buildFixBrokenChecksPrompt({ - reviewKind: 'PR', - reviewNumber: item.number, - reviewTitle: item.title, - reviewUrl: item.url, - checks: list - }) - setFixingChecks(true) - try { - const started = await startFixChecksAgent({ - item, - repoId: targetRepoId, - basePrompt, - launchSource: 'task_page', - telemetrySource: 'sidebar', - openModalFallback: () => { - toast.error( - translate( - 'auto.components.GitHubItemDialog.06482d6190', - 'Unable to create a fix workspace automatically.' - ) - ) - } - }) - if (started) { - toast.success( - translate( - 'auto.components.GitHubItemDialog.28986b3747', - 'Started an AI agent for the broken checks.' - ) - ) - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.error('Failed to start fix checks agent', err) - toast.error( - translate( - 'auto.components.GitHubItemDialog.03e542fcfe', - 'Failed to start an AI agent for the broken checks: {{value0}}', - { value0: message } - ) - ) - } finally { - setFixingChecks(false) - } - }, [failedChecks.length, fixingChecks, item, list, repoId]) - - const requestCheckDetails = useCallback( - (check: PRCheckDetail, key: string): void => { - if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { - return - } - const requestId = ++nextCheckDetailsRequestIdRef.current - const commit = (next: Omit): void => { - if (!mountedRef.current) { - return - } - setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) - } - setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) - const detailsRequest = withGitHubCheckDetailsTimeout((signal) => - runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }, - { timeoutMs: 30_000, signal } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) - ) - void detailsRequest - .then((details) => { - commit({ - loading: false, - details, - error: details - ? null - : translate( - 'auto.components.GitHubItemDialog.e15a8b77ef', - 'No inline details are available for this check.' - ) - }) - }) - .catch((err) => { - commit({ - loading: false, - details: null, - error: - err instanceof Error - ? err.message - : translate( - 'auto.components.GitHubItemDialog.e45324fbed', - 'Failed to load check details.' - ) - }) - }) - }, - [ - canUseChecksRepoContext, - item.repoId, - mountedRef, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if (detailsByCheckKey[key]) { - return - } - requestCheckDetails(check, key) - }, - [detailsByCheckKey, requestCheckDetails] - ) - - const refreshAction = ( - - - - - - {translate('auto.components.GitHubItemDialog.9a1004fc76', 'Refresh checks')} - - - ) - const fixBrokenChecksAction = - failedChecks.length > 0 || fixingChecks ? ( - - - - - - {translate( - 'auto.components.GitHubItemDialog.f4b1292569', - 'Start the default AI agent on these checks' - )} - - - ) : null - const rerunAction = - list.length > 0 || rerunning ? ( - - - - - - void handleRerun(true)} - > - - {translate('auto.components.GitHubItemDialog.e31651a224', 'Rerun failed checks')} - - void handleRerun(false)}> - - {translate('auto.components.GitHubItemDialog.71c11aff84', 'Rerun all checks')} - - - - ) : null - const secondaryActions = - variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction || - rerunAction ? ( -
- {fixBrokenChecksAction} - {variant === 'page' ? rerunAction : null} -
- ) : null - const actions = ( -
- {refreshAction} - {fixBrokenChecksAction} - {rerunAction} -
- ) - const compactHeader = ( -
-
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
-
- {translate('auto.components.GitHubItemDialog.4bd1f5b055', 'Checks')} -
- {list.length > 0 && ( -
- {summaryLabel} -
- )} -
-
-
- {refreshAction} - {list.length > 0 && ( -
- {rerunAction} -
- )} -
-
- {secondaryActions ? ( -
{secondaryActions}
- ) : null} -
- ) - - const renderCheckRow = (check: PRCheckDetail): React.JSX.Element => { - const conclusion = getCheckConclusion(check) - const Icon = CHECK_ICON[conclusion] ?? CircleDashed - const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' - const statusLabel = getCheckStatusLabel(check) - const key = getCheckDetailsKey(check) - const expanded = expandedCheckKey === key - const detailsState = detailsByCheckKey[key] - return ( -
- - {expanded && renderCheckDetails(check, detailsState)} -
- ) - } - - const renderCheckDetails = ( - check: PRCheckDetail, - state: CheckDetailsLoadState | undefined - ): React.JSX.Element => { - const details = state?.details - const openUrl = details?.detailsUrl ?? details?.url ?? check.url - const startedAt = formatCheckTimestamp(details?.startedAt) - const completedAt = formatCheckTimestamp(details?.completedAt) - const detailsStatusCheck: PRCheckDetail = { - ...check, - status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status, - conclusion: - (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion - } - const hasOutput = Boolean(details?.title || details?.summary || details?.text) - const hasAnnotations = (details?.annotations.length ?? 0) > 0 - const hasJobs = (details?.jobs.length ?? 0) > 0 - - return ( -
- {state?.loading && !state.error ? ( -
- - {translate('auto.components.GitHubItemDialog.934d87ab96', 'Loading check details…')} -
- ) : ( -
-
- - {translate('auto.components.GitHubItemDialog.9c3ba11a05', 'Status:')}{' '} - {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} - - {startedAt && ( - - {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} {startedAt} - - )} - {completedAt && ( - - {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')}{' '} - {completedAt} - - )} - {check.checkRunId && ( - - {translate('auto.components.GitHubItemDialog.485609c4f2', 'check #')} - {check.checkRunId} - - )} -
- - {state?.error && ( -
- - {state.error} - - -
- )} - - {hasOutput && ( -
- {details?.title && ( -
- {details.title} -
- )} - {details?.summary && ( - - )} - {details?.text && ( - - )} -
- )} - - {hasAnnotations && ( -
-
- {translate('auto.components.GitHubItemDialog.96d8f36798', 'Annotations')} -
-
- {details!.annotations.map((annotation, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {annotation.path ?? - translate('auto.components.GitHubItemDialog.7d42606f66', 'Annotation')} - {annotation.startLine ? `:${annotation.startLine}` : ''} - - {annotation.annotationLevel && ( - - {annotation.annotationLevel} - - )} -
- {annotation.title && ( -
- {annotation.title} -
- )} -
- {annotation.message} -
- {annotation.rawDetails && ( -
-                          {annotation.rawDetails}
-                        
- )} -
- ))} -
-
- )} - - {hasJobs && ( -
-
- {translate('auto.components.GitHubItemDialog.08d072664d', 'Jobs')} -
-
- {details!.jobs.map((job, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {job.name} - - - {job.conclusion ?? - job.status ?? - translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} - -
- {job.steps.length > 0 && ( -
- {job.steps.map((step) => ( -
- {step.name} - {step.conclusion ?? step.status} -
- ))} -
- )} -
- ))} -
-
- )} - - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( -
- {getCheckConclusion(check) === 'action_required' - ? translate( - 'auto.components.GitHubItemDialog.checkActionRequiredHint', - 'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.' - ) - : translate( - 'auto.components.GitHubItemDialog.744197c84d', - 'No inline output is available for this check.' - )} -
- )} - - {openUrl && ( -
- -
- )} -
- )} -
- ) - } - - if (loading && list.length === 0) { - return ( - <> - {variant === 'compact' ? compactHeader : null} -
- -
- - ) - } - if (list.length === 0) { - if (variant === 'page') { - return ( -
-
- -
- - {translate('auto.components.GitHubItemDialog.ecffebc251', 'No checks found')} - - - {translate( - 'auto.components.GitHubItemDialog.90020cc1f3', - 'This pull request has no reported checks yet.' - )} - -
- {actions} -
-
- ) - } - return ( - <> - {compactHeader} -
- -
- {translate('auto.components.GitHubItemDialog.e52bed9264', 'No checks reported yet')} -
-
- - ) - } - if (variant === 'page') { - const countChips = getCheckCountChips(counts) - return ( -
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
- {summaryLabel} - {countChips.length > 1 && ( - - {countChips.map((chip, i) => ( - - {i > 0 && ·} - {chip.label} - - ))} - - )} -
- {actions} -
-
- {sorted.map((check, index) => ( -
0 && 'border-t border-border/40')} - > - {renderCheckRow(check)} -
- ))} -
-
- ) - } - return ( - <> - {compactHeader} -
- {sorted.map(renderCheckRow)} -
- - ) -} - -function GitHubLabelsSettingsLink({ - url, - separated, - onOpen -}: { - url: string | null - separated?: boolean - onOpen?: () => void -}): React.JSX.Element | null { - if (!url) { - return null - } - - return ( -
- -
- ) -} - -function GHEditSection({ - item, - repoPath, - repoId, - sourceContext, - projectOrigin, - localState, - localLabels, - onStateChange, - onLabelsChange, - onMutated, - assignees, - onUse, - onOpenOrUse, - attachedWorkspaceLabel, - layout = 'horizontal' -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - localState: GitHubWorkItem['state'] - localLabels: string[] - onStateChange: (state: GitHubWorkItem['state']) => void - onLabelsChange: (labels: string[]) => void - /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */ - onMutated: () => void - assignees: string[] - onUse: (item: GitHubWorkItem) => void - onOpenOrUse?: (item: GitHubWorkItem) => void - attachedWorkspaceLabel?: string | null - /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */ - layout?: 'horizontal' | 'top-columns' -}): React.JSX.Element | null { - const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) - const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) - const [statusPopoverOpen, setStatusPopoverOpen] = useState(false) - const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) - const [duplicateSearch, setDuplicateSearch] = useState('') - const [duplicateError, setDuplicateError] = useState(null) - const [localAssignees, setLocalAssignees] = useState(assignees) - const editedAssigneesItemKeyRef = useRef(null) - const assigneesItemKey = `${item.repoId}\0${item.id}` - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const duplicateIssueCandidates = useAppStore( - useShallow((s) => { - if (!duplicatePickerOpen) { - return [] - } - const deduped = new Map() - for (const entry of Object.values(s.workItemsCache)) { - for (const candidate of entry.data ?? []) { - if ( - candidate.type === 'issue' && - candidate.repoId === item.repoId && - candidate.number !== item.number && - !deduped.has(candidate.number) - ) { - deduped.set(candidate.number, candidate) - } - } - } - return Array.from(deduped.values()).sort((a, b) => b.number - a.number) - }) - ) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const { isPending, run } = useImmediateMutation() - // Why: from a Project view, keep projectViewCache in sync too — patchWorkItem only walks workItemsCache, so the table would render stale without this. See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. - const patchProjectRowIfNeeded = useCallback( - (patch: Parameters[2]) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch) - }, - [projectOrigin, patchProjectRowContent] - ) - - // Why: with projectOrigin set, read labels/assignees from the row's repo, not the workspace path, or popovers list a different repo than writes target. - const slugOwner = projectOrigin?.owner ?? null - const slugRepo = projectOrigin?.repo ?? null - const repoLabelsByPath = useRepoLabels( - projectOrigin ? null : repoPath, - projectOrigin ? null : repoId, - sourceSettings - ) - const repoLabelsBySlug = useRepoLabelsBySlug( - slugOwner, - slugRepo, - sourceSettings, - projectOrigin?.host - ) - const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath - const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) - const repoAssigneesByPath = useRepoAssignees( - projectOrigin ? null : repoPath, - projectOrigin ? null : repoId, - sourceSettings - ) - const repoAssigneesBySlug = useRepoAssigneesBySlug( - slugOwner, - slugRepo, - assignees, - sourceSettings, - projectOrigin?.host - ) - const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath - const hasAttachedWorkspace = - attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined - const filteredDuplicateCandidates = useMemo( - () => - getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), - [duplicateIssueCandidates, duplicateSearch, item.number] - ) - const directDuplicateTarget = useMemo(() => { - const trimmed = duplicateSearch.trim() - const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) - if (!trimmed || !validation.ok) { - return null - } - if ( - filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) - ) { - return null - } - return validation.duplicateOf - }, [duplicateSearch, filteredDuplicateCandidates, item.number]) - const duplicatePickerTitle = useMemo(() => { - if (projectOrigin) { - return `${projectOrigin.owner}/${projectOrigin.repo}` - } - const parsed = parseOwnerRepoFromItemUrl(item.url) - return parsed - ? `${parsed.owner}/${parsed.repo}` - : translate('auto.components.TaskPage.repository', 'Repository') - }, [item.url, projectOrigin]) - const handleOpenOrUseWorkspace = useCallback((): void => { - if (onOpenOrUse) { - onOpenOrUse(item) - return - } - onUse(item) - }, [item, onOpenOrUse, onUse]) - - // Why: sync local assignees on item change / detail resolve, but skip if the user made an optimistic edit so we don't clobber in-flight changes. - useEffect(() => { - if (editedAssigneesItemKeyRef.current === assigneesItemKey) { - return - } - setLocalAssignees(assignees) - }, [assigneesItemKey, assignees]) - - const handleStateChange = useCallback( - (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { - if (newState === localState) { - return - } - const prevState = localState - // Why: without registry authority a search-lagged Tasks refetch silently - // reverts this row to its pre-mutation state (STA-3343). - let authority: { revert: () => boolean } | null = null - run('state', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: - newState === 'closed' && closeAction - ? buildTaskPageGitHubCloseUpdate(closeAction) - : { state: newState } - }), - onOptimistic: () => { - authority = assertTaskPageGitHubDialogStateAuthority({ - repoId: item.repoId, - itemId: item.id, - state: newState, - sourceContext - }) - onStateChange(newState) - patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: newState }) - }, - onRevert: () => { - if (authority?.revert()) { - onStateChange(prevState) - patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: prevState }) - } - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: newState }) - onMutated() - }, - onError: (err) => toast.error(err) - }) - }, - [ - item.id, - item.number, - item.repoId, - localState, - repoPath, - sourceContext, - projectOrigin, - patchWorkItem, - patchProjectRowIfNeeded, - run, - onStateChange, - onMutated - ] - ) - - const closeAsDuplicate = useCallback( - (targetIssueNumber: number | string) => { - const validation = validateTaskPageGitHubDuplicateTarget( - String(targetIssueNumber), - item.number - ) - if (!validation.ok) { - setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) - return - } - setDuplicateError(null) - handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) - setStatusPopoverOpen(false) - setDuplicatePickerOpen(false) - }, - [handleStateChange, item.number] - ) - - const handleDuplicateSearchSubmit = useCallback(() => { - const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) - if (!validation.ok) { - setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) - return - } - closeAsDuplicate(validation.duplicateOf) - }, [closeAsDuplicate, duplicateSearch, item.number]) - - const handleStatusPopoverOpenChange = useCallback((nextOpen: boolean) => { - setStatusPopoverOpen(nextOpen) - if (!nextOpen) { - setDuplicatePickerOpen(false) - setDuplicateSearch('') - setDuplicateError(null) - } - }, []) - - const handleLabelToggle = useCallback( - (label: string) => { - const isAdding = !localLabels.includes(label) - const prevLabels = localLabels - const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label) - - if (isAdding) { - run('labels', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { addLabels: [label] } - }), - onOptimistic: () => { - onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: newLabels }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onRevert: () => { - onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: prevLabels }) - }, - onError: (err) => toast.error(err) - }) - } else { - run('labels', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { removeLabels: [label] } - }), - onOptimistic: () => { - onLabelsChange(newLabels) - patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: newLabels }) - }, - onRevert: () => { - onLabelsChange(prevLabels) - patchWorkItem(item.id, { labels: prevLabels }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: prevLabels }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onError: (err) => toast.error(err) - }) - } - }, - [ - item.id, - item.number, - item.repoId, - localLabels, - repoPath, - sourceContext, - projectOrigin, - patchWorkItem, - patchProjectRowIfNeeded, - run, - onLabelsChange, - onMutated - ] - ) - - const handleAssigneeToggle = useCallback( - (login: string) => { - const isAssigned = localAssignees.includes(login) - const prevAssignees = localAssignees - const newAssignees = isAssigned - ? prevAssignees.filter((l) => l !== login) - : [...prevAssignees, login] - - // Why: scope the optimistic guard to this repo item so switching items doesn't suppress the next item's assignee sync. - editedAssigneesItemKeyRef.current = assigneesItemKey - if (isAssigned) { - run('assignees', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { removeAssignees: [login] } - }), - onOptimistic: () => { - setLocalAssignees(newAssignees) - patchProjectRowIfNeeded({ assignees: newAssignees }) - }, - onRevert: () => { - setLocalAssignees(prevAssignees) - patchProjectRowIfNeeded({ assignees: prevAssignees }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onError: (err) => toast.error(err) - }) - } else { - run('assignees', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: { addAssignees: [login] } - }), - onOptimistic: () => { - setLocalAssignees(newAssignees) - patchProjectRowIfNeeded({ assignees: newAssignees }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onRevert: () => { - setLocalAssignees(prevAssignees) - patchProjectRowIfNeeded({ assignees: prevAssignees }) - }, - onError: (err) => toast.error(err) - }) - } - }, - [ - item.number, - item.repoId, - assigneesItemKey, - repoPath, - sourceContext, - projectOrigin, - localAssignees, - patchProjectRowIfNeeded, - run, - onMutated - ] - ) - - const renderIssueStatusPopover = (variant: 'sidebar' | 'pill'): React.JSX.Element => { - const isSidebar = variant === 'sidebar' - return ( - - - - - - {duplicatePickerOpen ? ( -
-
- - - {duplicatePickerTitle} - -
-
- - { - setDuplicateSearch(event.target.value) - setDuplicateError(null) - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - handleDuplicateSearchSubmit() - } - }} - placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} - className="h-9 pl-8 text-[12px]" - aria-invalid={duplicateError ? true : undefined} - /> -
- {duplicateError ? ( -

{duplicateError}

- ) : null} -
- {directDuplicateTarget ? ( - - ) : null} - {filteredDuplicateCandidates.map((candidate) => ( - - ))} - {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( -

- {translate( - 'auto.components.TaskPage.noMatchingIssuesLoaded', - 'No matching issues loaded.' - )} -

- ) : null} -
-
- ) : ( - <> - - - - - - )} -
-
- ) - } - - if (item.type === 'pr') { - return null - } - - const checkIcon = ( - - - - ) - - if (layout === 'top-columns') { - // Why: lay property fields as top columns so the description isn't squeezed by a right rail. - return ( - - ) - } - - return ( -
- {/* State */} - {renderIssueStatusPopover('pill')} - - {/* Labels */} - - - - - - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} - /> -
-
- - {/* Assignees */} - - - - - - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} -
-
- -
- {attachedWorkspaceLabel ? ( - - - {attachedWorkspaceLabel} - - ) : null} - {hasAttachedWorkspace ? ( - - - - - - - - - onUse(item)}> - - {translate('auto.components.GitHubItemDialog.36182aa57f', 'Start new workspace')} - - - - ) : ( - - )} -
-
- ) -} - -function GHCommentComposer({ - className, - repoPath, - repoId, - sourceContext, - issueNumber, - itemType, - prRepo, - onCommentAdded -}: { - className?: string - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - issueNumber: number - itemType: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - onCommentAdded: (comment: PRComment) => void -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const mountedRef = useMountedRef() - - const handleSubmit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty') { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.GitHubItemDialog.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const result = await addIssueCommentForRepo({ - repoPath, - repoId: repoId ?? undefined, - sourceContext, - number: issueNumber, - body: bodyState.body, - type: itemType, - prRepo - }) - if (!mountedRef.current) { - return - } - if (result.ok) { - setBody('') - // Why: use GitHub's returned comment so the optimistic row shows the real login/avatar immediately. - onCommentAdded(result.comment) - } else { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } catch (err) { - if (mountedRef.current) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [ - body, - mountedRef, - repoPath, - repoId, - sourceContext, - issueNumber, - itemType, - prRepo, - onCommentAdded - ]) - const canSubmitComment = hasBoundedCommentBodyText(body) - - return ( -
- void handleSubmit()} - /> - - - - - - {translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} - - -
- ) -} - -// Why: recover the PR-source slug the dialog lacks from workItemsCache, scoped to this repoPath so a sibling repo sharing the issue-source (e.g. two forks) can't mislabel the chip; hide when unknown rather than guess (design doc §1). -function WorkItemIssueSourceIndicator({ - url, - repoId, - repoPath -}: { - url: string - repoId: string | null - repoPath?: string | null -}): React.JSX.Element | null { - // Why: resolve repo sources via the primary cache entry, or any sibling entry if the Tasks view only populated a query-keyed slot (sources are repo-level, so any is safe). - const sources = useAppStore((s) => - s.getWorkItemsAnySourcesForRepo(repoId ?? '', PER_REPO_FETCH_LIMIT, repoPath ?? undefined) - ) - const issues = useMemo(() => { - const fromUrl = parseOwnerRepoFromItemUrl(url) - if (!fromUrl) { - return null - } - // Prefer the cache's resolved issue-source (canonicalized by main) over the best-effort URL parse when they match. - const cachedIssues = sources?.issues - if (cachedIssues && sameGitHubOwnerRepo(cachedIssues, fromUrl)) { - return cachedIssues - } - return fromUrl - }, [url, sources]) - const prs = sources?.prs ?? null - - if (!issues || !prs || sameGitHubOwnerRepo(issues, prs)) { - return null - } - return ( -
- -
- ) -} - -export default function GitHubItemDialog({ - workItem, - repoPath, - repoId, - sourceContext, - initialTab, - backLabel = 'Back', - projectOrigin, - onUse, - onReviewRequestsChange, - onClose -}: GitHubItemDialogProps): React.JSX.Element { - const workItemId = workItem?.id - const [tab, setTab] = useState(() => normalizeItemDialogTab(workItem, initialTab)) - const [localState, setLocalState] = useState(workItem?.state ?? 'open') - const [localLabels, setLocalLabels] = useState(workItem?.labels ?? []) - const [linkCopyState, setLinkCopyState] = useState(() => createGitHubLinkCopyState(workItemId)) - const resolvedLinkCopyState = resolveGitHubLinkCopyState(linkCopyState, workItemId) - if (resolvedLinkCopyState !== linkCopyState) { - // Why: switching items must not paint a stale "copied" indicator from the previous item. - setLinkCopyState(resolvedLinkCopyState) - } - const linkCopied = resolvedLinkCopyState.copied - const workItemState = workItem?.state - const workItemLabels = workItem?.labels - const effectiveRepoId = repoId ?? workItem?.repoId ?? null - const allWorktrees = useAllWorktrees() - const issueAttachedWorkspace = useMemo( - () => - workItem?.type === 'issue' - ? findGithubIssueWorkspaceAttachment(allWorktrees, effectiveRepoId, workItem.number) - : null, - [allWorktrees, effectiveRepoId, workItem] - ) - const issueAttachedWorkspaceLabel = issueAttachedWorkspace - ? getGithubWorkItemWorkspaceAttachmentLabel(issueAttachedWorkspace) - : null - - const handleOpenOrUseIssueWorkspace = useCallback( - (item: GitHubWorkItem): void => { - const currentAttached = findGithubIssueWorkspaceAttachment( - useAppStore.getState().allWorktrees(), - effectiveRepoId, - item.number - ) - if (!currentAttached) { - onUse(item) - return - } - - const result = activateAndRevealWorktree(currentAttached.id) - if (result === false) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.2ef631437e', - 'Unable to open the workspace attached to this issue.' - ) - ) - } - }, - [effectiveRepoId, onUse] - ) - - // Why: the cache key must include issue source preference so toggling origin/upstream for the same issue number doesn't read the wrong repo's details. - const issueSourcePreference = useAppStore((s) => { - if (!repoPath && !effectiveRepoId) { - return undefined - } - return s.repos.find((r) => (effectiveRepoId ? r.id === effectiveRepoId : r.path === repoPath)) - ?.issueSourcePreference - }) - const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const detailsCacheKey = useMemo(() => { - if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) { - return null - } - return getWorkItemDetailsCacheKey({ - repoPath: repoPath ?? '', - repoId: effectiveRepoId, - issueSourcePreference, - sourceCacheScope: - sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null, - type: workItem.type, - number: workItem.number - }) - }, [ - canUseDetailsRepoContext, - repoPath, - effectiveRepoId, - sourceContext, - workItem, - issueSourcePreference - ]) - - // Why: hold comments added before the detail fetch resolves so they merge into the result instead of being overwritten. - const optimisticCommentsRef = useRef([]) - // Why: distinguish "reopen same item" from "switch item" — reopen must keep optimistic comments since gh's 60s cache omits the just-posted one. - const prevItemIdRef = useRef(null) - - // Why: a just-closed Radix overlay can leave `pointer-events: none` on , killing header button clicks; poll a few frames to clear it. - useEffect(() => { - if (!workItem) { - return - } - let cancelled = false - let count = 0 - let frameId: number | null = null - const tick = (): void => { - frameId = null - if (cancelled) { - return - } - if (document.body.style.pointerEvents === 'none') { - document.body.style.pointerEvents = '' - } - if (count++ < 5) { - frameId = requestAnimationFrame(tick) - } - } - tick() - return () => { - cancelled = true - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - } - }, [workItem]) - - // Why: subscribe to the module-level cache so reopening a cached item paints synchronously on first render. - const cachedEntry = useSyncExternalStore( - subscribeWorkItemDetailsCache, - useCallback( - () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined), - [detailsCacheKey] - ) - ) - - // Why: bumped on cold open (no cached details yet) so the details memo re-runs and surfaces the optimistic comment before the fetch lands. - const [optimisticTick, setOptimisticTick] = useState(0) - - // Why: key off cachedEntry identity (stable), not the optimistic ref array (fresh each render), to avoid needless recompute. - const details = useMemo(() => { - const cachedDetails = cachedEntry?.details ?? null - const opt = optimisticCommentsRef.current - if (!cachedDetails) { - // Why: on cold open, details may still be loading — surface optimistic comments via a minimal shell so a pre-fetch comment isn't invisible. - if (opt.length > 0 && workItem) { - return { item: workItem, body: '', comments: [...opt] } - } - return null - } - if (opt.length === 0) { - return cachedDetails - } - const ids = new Set(cachedDetails.comments.map((c) => c.id)) - const missing = opt.filter((c) => !ids.has(c.id)) - if (missing.length === 0) { - return cachedDetails - } - return { - ...cachedDetails, - comments: [...cachedDetails.comments, ...missing] - } - // Why: optimisticTick forces this ref-reading memo to re-run on cold-open writes; lint can't see the dependency. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cachedEntry, workItem, optimisticTick]) - - const resolvedWorkItemState = details?.item.state ?? workItemState - - // Why: the opening list row can be stale; the detail payload has authoritative state, so refresh the local edit UI from it. - useEffect(() => { - if (resolvedWorkItemState) { - setLocalState(resolvedWorkItemState) - } - if (workItemLabels) { - setLocalLabels(workItemLabels) - } - }, [workItemId, resolvedWorkItemState, workItemLabels]) - - const loading = !!cachedEntry?.pending && !cachedEntry?.details - const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null - const detailsLoaded = Boolean(cachedEntry?.details) - - // Why: if a cross-window mutation invalidates the open drawer's entry (cachedEntry undefined, fetch deps unchanged), bump a tick to force the refetch. - const [refetchTick, setRefetchTick] = useState(0) - useEffect(() => { - if (workItem && detailsCacheKey && !cachedEntry) { - setRefetchTick((n) => n + 1) - } - }, [workItem, detailsCacheKey, cachedEntry]) - - useEffect(() => { - if (!workItem || !effectiveRepoId || !detailsCacheKey || !canUseDetailsRepoContext) { - return - } - // Why: clear optimistic comments only on item switch — on reopen, gh's 60s cache omits the just-posted comment, so keep the ref to re-merge. - if (workItem.id !== prevItemIdRef.current) { - optimisticCommentsRef.current = [] - } - prevItemIdRef.current = workItem.id - setTab(normalizeItemDialogTab(workItem, initialTab)) - - const cached = workItemDetailsCache.get(detailsCacheKey) - const now = Date.now() - const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS - - if (hasFreshData) { - return - } - - // Why: dedupe concurrent opens for the same key — share one in-flight promise instead of racing two `gh` subprocesses. - const inflight: Promise = - cached?.pending ?? - lookupGitHubWorkItemDetailsForSource({ - repoPath: repoPath ?? '', - repoId: effectiveRepoId, - sourceContext, - number: workItem.number, - type: workItem.type - }) - - // Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back. - const launchedAtGeneration = workItemDetailsCacheGeneration - - if (!cached?.pending) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: cached?.details ?? null, - fetchedAt: cached?.fetchedAt ?? 0, - pending: inflight, - error: cached?.error - }) - } - - inflight - .then((result) => { - const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration - const prev = workItemDetailsCache.get(detailsCacheKey) - if (invalidatedMidFlight && prev?.pending !== inflight) { - // Why: entry was deliberately dropped (or later repopulated) — don't recreate or touch it. - return - } - // Why: null means unavailable/not found, not loaded empty content. - if (result === null && prev?.details) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: prev.details, - fetchedAt: prev.fetchedAt, - error: undefined - }) - } else if (result === null) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: null, - fetchedAt: 0, - error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE - }) - } else { - touchWorkItemDetailsCache(detailsCacheKey, { - details: result, - fetchedAt: Date.now(), - error: undefined - }) - } - }) - .catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to load details' - const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration - const prev = workItemDetailsCache.get(detailsCacheKey) - if (invalidatedMidFlight && prev?.pending !== inflight) { - return - } - // Why: stale-on-error — keep cached data, drop the pending promise so next open retries; show the error only when nothing is cached. - touchWorkItemDetailsCache(detailsCacheKey, { - details: prev?.details ?? null, - fetchedAt: prev?.fetchedAt ?? 0, - error: message - }) - }) - }, [ - canUseDetailsRepoContext, - repoPath, - effectiveRepoId, - sourceContext, - workItem, - detailsCacheKey, - initialTab, - refetchTick - ]) - - const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot - const displayWorkItem = useMemo(() => { - if (!workItem) { - return null - } - if (!details?.item) { - return workItem - } - return { ...workItem, ...details.item, repoId: workItem.repoId } - }, [details?.item, workItem]) - - useEffect(() => { - if (!workItem || details?.item.reviewRequests === undefined) { - return - } - // Why: PR details can carry fresher reviewer metadata than the list row; push it back so the Tasks review chip isn't stale. - onReviewRequestsChange?.( - { id: workItem.id, repoId: workItem.repoId }, - details.item.reviewRequests - ) - }, [details?.item.reviewRequests, onReviewRequestsChange, workItem]) - - const body = details?.body ?? '' - const comments = details?.comments ?? [] - const timelineItems = details?.timelineItems ?? [] - const files = details?.files ?? [] - const filesUnavailable = details?.filesUnavailable ?? false - const checks = details?.checks ?? [] - const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set()) - // Why: clipboard IPC can resolve after unmount; skip copied-state feedback rather than start a reset timer on a stale surface. - const linkCopyMountedRef = useRef(false) - const linkCopiedResetTimerRef = useRef(null) - const clearLinkCopiedResetTimer = useCallback((): void => { - if (linkCopiedResetTimerRef.current === null) { - return - } - window.clearTimeout(linkCopiedResetTimerRef.current) - linkCopiedResetTimerRef.current = null - }, []) - const setLinkCopyButtonRef = useCallback( - (node: HTMLButtonElement | null) => { - linkCopyMountedRef.current = node !== null - if (node === null) { - // Why: the copied-state timer belongs to this control; clear it on detach without a passive cleanup Effect. - clearLinkCopiedResetTimer() - } - }, - [clearLinkCopiedResetTimer] - ) - - const handleCopyWorkItemLink = useCallback(async (): Promise => { - if (!workItem) { - return - } - try { - // Why: Electron clipboard IPC works even when browser clipboard APIs lose focus/activation in nested overlays. - await window.api.ui.writeClipboardText(workItem.url) - if (!linkCopyMountedRef.current) { - return - } - clearLinkCopiedResetTimer() - const copiedWorkItemId = workItem.id - setLinkCopyState(markGitHubLinkCopied(copiedWorkItemId)) - linkCopiedResetTimerRef.current = window.setTimeout(() => { - linkCopiedResetTimerRef.current = null - setLinkCopyState((current) => clearGitHubLinkCopied(current, copiedWorkItemId)) - }, 1500) - toast.success(translate('auto.components.GitHubItemDialog.2e77dc2053', 'GitHub link copied')) - } catch { - toast.error( - translate('auto.components.GitHubItemDialog.5fea151559', 'Failed to copy GitHub link') - ) - } - }, [clearLinkCopiedResetTimer, workItem]) - - const appendOptimisticComment = useCallback( - (comment: PRComment) => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - // Why: skip refreshDetails() — gh's 60s cache would overwrite the optimistic comment; next open picks up the server version. - optimisticCommentsRef.current.push(comment) - // Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields. - if (detailsCacheKey) { - const prev = workItemDetailsCache.get(detailsCacheKey) - if (prev?.details) { - const ids = new Set(prev.details.comments.map((c) => c.id)) - if (!ids.has(comment.id)) { - touchWorkItemDetailsCache(detailsCacheKey, { - details: { - ...prev.details, - comments: [...prev.details.comments, comment] - }, - fetchedAt: 0, - error: undefined - }) - return - } - } - } - // Why: no cache write fires while details are still loading; bump local state so the memo re-runs and shows the optimistic comment. - setOptimisticTick((n) => n + 1) - }, - [detailsCacheKey] - ) - - const invalidateCurrentDetailsCache = useCallback((): void => { - if (!workItem) { - return - } - // Why: local repos invalidate all source-pref variants; runtime-only entries need their exact source-scoped key (no local path). - if (repoPath) { - invalidateWorkItemDetailsCacheByMatch({ - repoPath, - repoId: effectiveRepoId ?? undefined, - type: workItem.type, - number: workItem.number - }) - return - } - if (detailsCacheKey) { - invalidateWorkItemDetailsCacheForKey(detailsCacheKey) - } - }, [detailsCacheKey, effectiveRepoId, repoPath, workItem]) - - const handlePRFileViewedChange = useCallback( - async (path: string, viewed: boolean): Promise => { - if ( - !canUseDetailsRepoContext || - !details?.pullRequestId || - !workItem || - workItem.type !== 'pr' - ) { - toast.error( - translate( - 'auto.components.GitHubItemDialog.c0253318d6', - 'Unable to sync viewed state for this pull request.' - ) - ) - return false - } - setPendingViewedPaths((prev) => new Set(prev).add(path)) - const nextState: GitHubPRFileViewedState = viewed ? 'VIEWED' : 'UNVIEWED' - const previousState = detailsCacheKey - ? patchCachedPRFileViewedState(detailsCacheKey, path, nextState) - : undefined - try { - const ok = await setPRFileViewedForRepo({ - repoId: workItem.repoId, - repoPath: repoPath ?? '', - sourceContext, - prNumber: workItem.number, - prRepo: resolvePullRequestRepo(workItem, projectOrigin), - pullRequestId: details.pullRequestId, - path, - viewed - }) - if (!ok) { - if (detailsCacheKey && previousState) { - patchCachedPRFileViewedState(detailsCacheKey, path, previousState) - } - toast.error( - translate( - 'auto.components.GitHubItemDialog.b7bf31b8de', - 'Failed to sync viewed state with GitHub.' - ) - ) - return false - } - return true - } finally { - setPendingViewedPaths((prev) => { - const next = new Set(prev) - next.delete(path) - return next - }) - } - }, - [ - canUseDetailsRepoContext, - details?.pullRequestId, - detailsCacheKey, - projectOrigin, - repoPath, - sourceContext, - workItem - ] - ) - - const isIssuePage = workItem?.type === 'issue' - const ownerRepo = workItem ? parseOwnerRepoFromItemUrl(workItem.url) : null - const issueStateBadgeTone = - localState === 'closed' ? 'bg-rose-600 text-white' : 'bg-emerald-600 text-white' - - const content = workItem ? ( -
- {isIssuePage ? ( - <> - {/* Row 1: breadcrumb-style strip mirroring GitHub's canvas-subtle header */} -
-
- - · - {ownerRepo ? ( - <> - - {ownerRepo.owner} - / - {ownerRepo.repo} - - · - - ) : null} - #{workItem.number} -
- - - - - - {linkCopied - ? translate('auto.components.GitHubItemDialog.038b3d39b1', 'Copied') - : translate( - 'auto.components.GitHubItemDialog.c43fe79ee0', - 'Copy GitHub link' - )} - - - - - - - - {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} - - -
-
-
- - {/* Row 2: large title block */} -
-
-

- {workItem.title} - #{workItem.number} -

-
- {/* Why: Orca's signature affordance — keep primary so it stands out against GitHub's familiar surface. */} - {issueAttachedWorkspace ? ( - - - - - - - - - onUse(workItem)}> - - {translate( - 'auto.components.GitHubItemDialog.36182aa57f', - 'Start new workspace' - )} - - window.api.shell.openUrl(workItem.url)}> - - {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} - - - - ) : ( - - )} -
-
-
- - {localState === 'closed' ? ( - - ) : ( - - )} - {localState === 'closed' - ? translate('auto.components.GitHubItemDialog.ab050dffec', 'Closed') - : translate('auto.components.GitHubItemDialog.dc1ca081a8', 'Open')} - - - - {workItem.author ?? - translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} - - - {translate('auto.components.GitHubItemDialog.55962099bc', 'opened this issue')} - - - {translate('auto.components.GitHubItemDialog.10ef1afb8e', '· updated')} - {formatRelativeTime(workItem.updatedAt)} - - - - {issueAttachedWorkspaceLabel ? ( - - - {issueAttachedWorkspaceLabel} - - ) : null} -
-
- - ) : ( -
-
- -
- -
-
-
- - #{workItem.number} - - {workItem.type === 'pr' - ? translate('auto.components.GitHubItemDialog.a2495e4784', 'Pull request') - : translate('auto.components.GitHubItemDialog.3e544d966d', 'Issue')} - -
-

- {workItem.title} -

-
- - {workItem.author ?? - translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} - - - {translate('auto.components.GitHubItemDialog.8223320f8d', 'updated')} - {formatRelativeTime(workItem.updatedAt)} - - {workItem.branchName && ( - - {workItem.branchName} - - )} - {issueAttachedWorkspaceLabel ? ( - - - {issueAttachedWorkspaceLabel} - - ) : null} -
- {workItem.type === 'issue' && ( - - )} -
-
- {workItem.type === 'pr' && ( - - )} - - - - - - {linkCopied - ? translate('auto.components.GitHubItemDialog.038b3d39b1', 'Copied') - : translate('auto.components.GitHubItemDialog.c43fe79ee0', 'Copy GitHub link')} - - - - - - - - {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} - - -
-
-
- )} - - {!isIssuePage && (canUseDetailsRepoContext || projectOrigin) && ( - - )} - -
- {error ? ( -
{error}
- ) : isIssuePage ? ( -
- {/* Why: full content width so the description isn't squeezed by a right rail; px-2 + ConversationTab px-4 = header px-6. */} -
- {(canUseDetailsRepoContext || projectOrigin) && ( -
- -
- )} -
- { - if (detailsCacheKey) { - patchCachedPRChecks(detailsCacheKey, nextChecks) - } - }} - onBodyUpdated={(nextBody) => { - if (detailsCacheKey) { - patchCachedWorkItemBody(detailsCacheKey, nextBody) - } - }} - onCommentAdded={appendOptimisticComment} - onReviewersRequested={(nextReviewRequests) => { - if (detailsCacheKey) { - patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests) - } - onReviewRequestsChange?.( - { id: workItem.id, repoId: workItem.repoId }, - nextReviewRequests - ) - }} - /> -
-
-
- ) : ( - setTab(value as ItemDialogTab)} - className="flex h-full min-h-0 flex-col gap-0" - > - - - - {translate('auto.components.GitHubItemDialog.e30a5470c9', 'Conversation')} - - {workItem.type === 'pr' && ( - <> - - - {translate('auto.components.GitHubItemDialog.4bd1f5b055', 'Checks')} - {checks.length > 0 && ( - - {checks.length} - - )} - - - - {translate('auto.components.GitHubItemDialog.999b5ad7d9', 'Files')} - {files.length > 0 && ( - {files.length} - )} - - - )} - - -
- - { - if (detailsCacheKey) { - patchCachedPRChecks(detailsCacheKey, nextChecks) - } - }} - onBodyUpdated={(nextBody) => { - if (detailsCacheKey) { - patchCachedWorkItemBody(detailsCacheKey, nextBody) - } - }} - onCommentAdded={appendOptimisticComment} - onReviewersRequested={(nextReviewRequests) => { - if (detailsCacheKey) { - patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests) - } - onReviewRequestsChange?.( - { id: workItem.id, repoId: workItem.repoId }, - nextReviewRequests - ) - }} - /> - - - {workItem.type === 'pr' && ( - <> - - { - if (detailsCacheKey) { - patchCachedPRChecks(detailsCacheKey, nextChecks) - } - }} - /> - - - - {loading && files.length === 0 ? ( -
- -
- ) : filesUnavailable && files.length === 0 ? ( - // Why: file fetch failed (rate limit, auth, unresolved remote); offer a retry instead of implying the PR is empty. -
-
- {translate( - 'auto.components.GitHubItemDialog.filesUnavailable', - "Couldn't load changed files." - )} -
- -
- ) : files.length === 0 ? ( -
- {translate( - 'auto.components.GitHubItemDialog.3cd5ae5b7b', - 'No files changed.' - )} -
- ) : ( - - )} -
- - )} -
-
- )} -
-
- ) : null - - return ( - // Why: rendered inline (not a Radix dialog), so e2e needs a stable hook to scope assertions to this detail surface. -
- {content} -
- ) -} +export default GitHubItemDialog diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index 6c487a5d140..630ae68a7a0 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -5,7 +5,12 @@ import { describe, expect, it } from 'vitest' const COMPONENT_ROOT = __dirname function componentSource(relativePath: string): string { - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') + // Why: CRLF checkouts would otherwise break every multi-line source assertion. + return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8').replace(/\r\n?/g, '\n') +} + +function destSources(...relativePaths: string[]): string { + return relativePaths.map((relativePath) => componentSource(relativePath)).join('\n') } function sourceBetween(source: string, startPattern: string, endPattern: string): string { @@ -18,7 +23,10 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('GitHubItemDialog source host boundaries', () => { it('does not keep the stale right-side sheet owner', () => { - const source = componentSource('GitHubItemDialog.tsx') + const source = destSources( + 'GitHubItemDialog.tsx', + 'github-item-dialog/open-dialog/github-item-dialog.tsx' + ) expect(source).not.toContain('@/components/ui/sheet') expect(source).not.toContain(' { }) it('routes reviewer metadata and reviewer mutations through the task source context', () => { - const source = componentSource('GitHubItemDialog.tsx') - const section = sourceBetween( - source, - 'function PRReviewersPanel', - 'const WORK_ITEM_DETAILS_CACHE_MAX' + const source = destSources( + 'github-item-dialog/land-pull-request/pr-reviewers-panel.tsx', + 'github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts' ) + const section = source expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') expect(section).toContain('useRepoAssigneesBySlug(') @@ -52,8 +59,11 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('routes edit metadata through the same task source as issue mutations', () => { - const source = componentSource('GitHubItemDialog.tsx') - const section = sourceBetween(source, 'function GHEditSection', 'const hasAttachedWorkspace') + const source = destSources( + 'github-item-dialog/edit-item-fields/gh-edit-section.tsx', + 'github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts' + ) + const section = source const helperSection = componentSource('github/github-work-item-edit-mutations.ts') expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') @@ -80,7 +90,10 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('uses source-aware details routing and cache identity', () => { - const source = componentSource('GitHubItemDialog.tsx') + const source = destSources( + 'github-item-dialog/load-item-details/work-item-details-cache.ts', + 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts' + ) const cacheKeySection = sourceBetween( source, 'function getWorkItemDetailsCacheKey', @@ -102,7 +115,10 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('treats null details as unavailable while preserving empty detail payloads', () => { - const source = componentSource('GitHubItemDialog.tsx') + const source = destSources( + 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts', + 'github-item-dialog/load-item-details/work-item-details-fetch-settle.ts' + ) const loadedSection = sourceBetween( source, 'const loading = !!cachedEntry?.pending && !cachedEntry?.details', @@ -118,13 +134,11 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('routes PR file viewed mutations through the task source context', () => { - const source = componentSource('GitHubItemDialog.tsx') - const helperSection = componentSource('github/github-work-item-comment-mutations.ts') - const changeSection = sourceBetween( - source, - 'const handlePRFileViewedChange = useCallback', - 'const isIssuePage = workItem?.type ===' + const changeSection = destSources( + 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts', + 'github-item-dialog/load-item-details/pr-file-viewed-change.ts' ) + const helperSection = componentSource('github/github-work-item-comment-mutations.ts') expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') expect(helperSection).toContain("'github.setPRFileViewed'") @@ -137,9 +151,9 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('routes comment mutations through runtime source context when needed', () => { - const source = componentSource('github/github-work-item-comment-mutations.ts') + const helperSource = componentSource('github/github-work-item-comment-mutations.ts') const helperSection = sourceBetween( - source, + helperSource, 'function addIssueCommentForRepo', 'function setPRFileViewedForRepo' ) @@ -155,12 +169,15 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('routes PR file contents and runtime viewed invalidations through the task source context', () => { - const source = componentSource('GitHubItemDialog.tsx') + const source = destSources( + 'github-item-dialog/load-item-details/work-item-details-cache.ts', + 'github-item-dialog/load-item-details/pr-file-content-cache.ts' + ) const commentMutations = componentSource('github/github-work-item-comment-mutations.ts') const fileContentsSection = sourceBetween( source, 'function loadPRFileContents', - 'function PRFilesCombinedDiffViewer' + 'touchPRFileContentCache(cacheKey, request)' ) const fileContentsCacheKeySection = sourceBetween( source, @@ -185,11 +202,8 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('routes merge actions through the repo owner host (#6957)', () => { - const source = componentSource('GitHubItemDialog.tsx') - const actionsSection = sourceBetween( - source, - 'function PRActionsPanel', - 'function CommentReplyForm' + const actionsSection = componentSource( + 'github-item-dialog/land-pull-request/pr-actions-panel.tsx' ) expect(actionsSection).toContain( @@ -212,11 +226,10 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('routes check actions through the task source context', () => { - const source = componentSource('GitHubItemDialog.tsx') - const checksSection = sourceBetween( - source, - 'function ChecksTab', - 'function GitHubLabelsSettingsLink' + const checksSection = destSources( + 'github-item-dialog/inspect-pull-request/checks-tab.tsx', + 'github-item-dialog/inspect-pull-request/checks-tab-actions.ts', + 'github-item-dialog/inspect-pull-request/checks-tab-request-details.ts' ) expect(checksSection).toContain('sourceContext?: TaskSourceContext | null') @@ -225,23 +238,23 @@ describe('GitHubItemDialog source host boundaries', () => { expect(checksSection).toContain("'github.rerunPRChecks'") expect(checksSection).toContain("'github.prCheckDetails'") expect(checksSection).toContain( - 'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)' + 'repo: getGitHubRuntimeRepoId(ctx.sourceContext, ctx.repoId ?? ctx.itemRepoId)' ) expect(checksSection).toContain('window.api.gh.prChecks({') expect(checksSection).toContain('window.api.gh.rerunPRChecks({') expect(checksSection).toContain('prCheckDetails({') expect(checksSection).toMatch( - /withGitHubCheckDetailsTimeout\(\(signal\) =>\s*runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/ + /withGitHubCheckDetailsTimeout\(\(signal\) =>\s*ctx\.runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/ ) expect(checksSection).toContain('{ timeoutMs: 30_000, signal }') }) it('makes failed check detail loads retryable and fences stale responses', () => { - const source = componentSource('GitHubItemDialog.tsx') - const checksSection = sourceBetween( - source, - 'function ChecksTab', - 'function GitHubLabelsSettingsLink' + const checksSection = destSources( + 'github-item-dialog/inspect-pull-request/checks-tab.tsx', + 'github-item-dialog/inspect-pull-request/checks-tab-actions.ts', + 'github-item-dialog/inspect-pull-request/checks-tab-request-details.ts', + 'github-item-dialog/inspect-pull-request/checks-tab-check-details.tsx' ) expect(checksSection).toContain('createGitHubChecksTabState(checks, checkDetailsContextKey)') @@ -253,15 +266,15 @@ describe('GitHubItemDialog source host boundaries', () => { expect(checksSection).toContain('activeChecksRefreshRequestIdRef.current !== refreshRequestId') expect(checksSection).toContain('current.contextOwner === refreshContextOwner') expect(checksSection).toContain( - 'const rerunContextOwner = committedChecksContextOwnerRef.current' + 'const rerunContextOwner = ctx.committedChecksContextOwnerRef.current' ) - expect(checksSection).toContain('committedChecksContextOwnerRef.current !== rerunContextOwner') - expect(checksSection).toContain('await handleRefresh(rerunContextOwner)') - expect(checksSection).toContain('!mountedRef.current ||') - expect(checksSection).toContain('settleGitHubChecksTabDetails(current, key, requestId, next)') expect(checksSection).toContain( - 'onClick={() => requestCheckDetails(check, getCheckDetailsKey(check))}' + 'ctx.committedChecksContextOwnerRef.current !== rerunContextOwner' ) + expect(checksSection).toContain('await refreshGitHubChecksTab(ctx, rerunContextOwner)') + expect(checksSection).toContain('!ctx.mountedRef.current ||') + expect(checksSection).toContain('settleGitHubChecksTabDetails(current, key, requestId, next)') + expect(checksSection).toContain('onClick={() => onRetry(check, getCheckDetailsKey(check))}') expect(checksSection).toContain('disabled={state.loading}') expect(checksSection).toContain('aria-busy={state.loading}') expect(checksSection).toContain("translate('githubChecks.retrying', 'Retrying…')") @@ -269,7 +282,7 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('uses hydrated work item details for the page checks tab', () => { - const source = componentSource('GitHubItemDialog.tsx') + const source = componentSource('github-item-dialog/open-dialog/github-item-dialog-pr-tabs.tsx') const checksTab = sourceBetween( source, ' { }) it('records state authority for dialog state mutations so stale list refetches cannot revert them (STA-3343)', () => { - const source = componentSource('GitHubItemDialog.tsx') - - // Issue close/reopen: assert on optimistic apply, revert on failure. - const editSection = sourceBetween(source, 'function GHEditSection', 'const closeAsDuplicate') + const editSection = destSources( + 'github-item-dialog/edit-item-fields/gh-edit-section.tsx', + 'github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts' + ) expect(editSection).toContain('assertTaskPageGitHubDialogStateAuthority({') expect(editSection).toContain('if (authority?.revert())') - // PR close/reopen + merge: same protection for the shared Tasks list rows. - const actionsSection = sourceBetween( - source, - 'function PRActionsPanel', - 'function CommentReplyForm' + const actionsSection = componentSource( + 'github-item-dialog/land-pull-request/pr-actions-panel.tsx' ) expect(actionsSection.match(/assertTaskPageGitHubDialogStateAuthority\(\{/g)).toHaveLength(2) expect(actionsSection).toContain('if (authority.revert())') diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/comment-reply-form.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/comment-reply-form.tsx new file mode 100644 index 00000000000..3b3b24e8971 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/comment-reply-form.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { useMountedRef } from '@/hooks/useMountedRef' +import { cn } from '@/lib/utils' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { + getCommentBodySubmitState, + hasBoundedCommentBodyText +} from '@/lib/comment-body-submit-state' +import { translate } from '@/i18n/i18n' + +export function CommentReplyForm({ + className, + placeholder, + onCancel, + onSubmit +}: { + className?: string + placeholder: string + onCancel: () => void + onSubmit: (body: string) => Promise +}): React.JSX.Element { + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const mountedRef = useMountedRef() + + const submit = useCallback(async () => { + const bodyState = getCommentBodySubmitState(body) + if (bodyState.status === 'empty' || submitting) { + return + } + if (bodyState.status === 'too-large-leading-whitespace') { + toast.error( + translate( + 'auto.components.GitHubItemDialog.commentTooLarge', + 'Comment is too large to submit safely.' + ) + ) + return + } + setSubmitting(true) + try { + const ok = await onSubmit(bodyState.body) + if (!mountedRef.current) { + return + } + if (ok) { + setBody('') + } + } finally { + if (mountedRef.current) { + setSubmitting(false) + } + } + }, [body, mountedRef, onSubmit, submitting]) + const canSubmitReply = hasBoundedCommentBodyText(body) + + return ( +
+ void submit()} + /> +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-activity.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-activity.tsx new file mode 100644 index 00000000000..091b125e786 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-activity.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import { FolderKanban, MessageSquare } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' +import type { PRCommentAudienceFilter } from '../../../../../shared/pr-comment-audience' +import type { PRCommentGroup } from '../../../../../shared/pr-comment-groups' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import type { IssueConversationEntry } from './issue-conversation-entries' +import { ConversationTabCommentCard } from './conversation-tab-comment-card' +import { ConversationTabCommentGroup } from './conversation-tab-comment-group' +import { ConversationTabTimelineActivity } from './conversation-tab-timeline' + +export function ConversationTabActivity({ + item, + comments, + commentFilter, + commentCounts, + visibleComments, + visibleCommentGroups, + resolvedTimelineItemsLength, + issueConversationEntries, + commentCardProps, + onCommentFilterChange +}: { + item: GitHubWorkItem + comments: PRComment[] + commentFilter: PRCommentAudienceFilter + commentCounts: Record + visibleComments: PRComment[] + visibleCommentGroups: PRCommentGroup[] + resolvedTimelineItemsLength: number + issueConversationEntries: IssueConversationEntry[] + commentCardProps: { + repoPath: string | null + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo: GitHubOwnerRepo | null + files: GitHubPRFile[] + headSha: string | undefined + baseSha: string | undefined + markdownGitHubRepo: GitHubOwnerRepo | null + resolvedReplyingTo: number | null + onToggleReply: (commentId: number) => void + onReply: (comment: PRComment, replyBody: string) => Promise + onCancelReply: () => void + } + onCommentFilterChange: (filter: PRCommentAudienceFilter) => void +}): React.JSX.Element { + return ( + <> +
+ {item.type === 'issue' ? ( + + ) : ( + + )} + + {item.type === 'issue' + ? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity') + : translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} + + {comments.length + (item.type === 'issue' ? resolvedTimelineItemsLength : 0) > 0 && ( + + {comments.length + (item.type === 'issue' ? resolvedTimelineItemsLength : 0)} + + )} +
+ + {item.type === 'pr' && comments.length > 0 && ( +
+ {getPrCommentAudienceFilters().map((filter) => { + const isActive = commentFilter === filter.value + return ( + + ) + })} +
+ )} + + {item.type === 'issue' ? ( + issueConversationEntries.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.timeline.noActivity', 'No activity yet.')} +
+ ) : ( +
+ {issueConversationEntries.map((entry) => + entry.kind === 'comment' ? ( + + ) : ( + + ) + )} +
+ ) + ) : comments.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')} +
+ ) : visibleComments.length === 0 ? ( +
+ {getPRCommentAudienceEmptyLabel(commentFilter)} +
+ ) : ( +
+ {visibleCommentGroups.map((group) => ( + + ))} +
+ )} + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-card.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-card.tsx new file mode 100644 index 00000000000..8b43865a40e --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-card.tsx @@ -0,0 +1,214 @@ +import React from 'react' +import { ExternalLink, MessageSquarePlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { cn } from '@/lib/utils' +import { + PR_COMMENT_OPEN_AUTHOR_CLASS, + PR_COMMENT_RESOLVED_AUTHOR_CLASS, + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' +import { formatRelativeTime } from '@/components/github/work-item-state-presentation' +import { CommentCodeContext } from '@/components/github/CommentCodeContext' +import { CommentReactions } from '@/components/github/CommentReactions' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import { loadPRFileContents } from '../load-item-details/pr-file-content-cache' +import { CommentReplyForm } from './comment-reply-form' + +export type ConversationCommentCardContext = { + repoPath: string | null + repoId: string | undefined + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo: GitHubOwnerRepo | null + files: GitHubPRFile[] + headSha: string | undefined + baseSha: string | undefined + markdownGitHubRepo: GitHubOwnerRepo | null + resolvedReplyingTo: number | null + setReplyingTo: React.Dispatch> + handleReply: (comment: PRComment, replyBody: string) => Promise +} + +export function renderCommentCard( + comment: PRComment, + isReply = false, + ctx: ConversationCommentCardContext +): React.JSX.Element { + return ( +
+
+ {comment.authorAvatarUrl ? ( + {comment.author} + ) : ( +
+ )} + + {comment.author} + + + · {formatRelativeTime(comment.createdAt)} + + {comment.path && ( + + {comment.path.split('/').pop()} + {comment.line + ? translate('auto.components.GitHubItemDialog.136542c9ba', ':L{{value0}}', { + value0: comment.line + }) + : ''} + + )} + {comment.isResolved && ( + + {translate('auto.components.GitHubItemDialog.68cb993d61', 'resolved')} + + )} +
+ + + + + + {translate('auto.components.GitHubItemDialog.bca8eb39ac', 'Reply to comment')} + + + {comment.url && ( + + + + + + {translate('auto.components.GitHubItemDialog.a154ec5224', 'Open comment on GitHub')} + + + )} +
+
+
+ + + + {ctx.resolvedReplyingTo === comment.id && ( + ctx.setReplyingTo(null)} + onSubmit={(replyBody) => ctx.handleReply(comment, replyBody)} + /> + )} +
+
+ ) +} + +export type ConversationTabCommentCardProps = { + comment: PRComment + isReply?: boolean + repoPath: string | null + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo: GitHubOwnerRepo | null + files: GitHubPRFile[] + headSha: string | undefined + baseSha: string | undefined + markdownGitHubRepo: GitHubOwnerRepo | null + resolvedReplyingTo: number | null + onToggleReply: (commentId: number) => void + onReply: (comment: PRComment, replyBody: string) => Promise + onCancelReply: () => void +} + +export function ConversationTabCommentCard({ + comment, + isReply = false, + onToggleReply, + onReply, + onCancelReply, + ...rest +}: ConversationTabCommentCardProps): React.JSX.Element { + return renderCommentCard(comment, isReply, { + ...rest, + handleReply: onReply, + setReplyingTo: (next) => { + if (typeof next === 'function') { + onToggleReply(comment.id) + return + } + if (next === null) { + onCancelReply() + } + } + }) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-group.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-group.tsx new file mode 100644 index 00000000000..5b607307ca9 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-comment-group.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger +} from '@/components/ui/accordion' +import { + getPRCommentGroupCount, + getPRCommentGroupId, + getPRCommentGroupRoot, + isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../../../shared/pr-comment-groups' +import { translate } from '@/i18n/i18n' +import { + ConversationTabCommentCard, + renderCommentCard, + type ConversationCommentCardContext, + type ConversationTabCommentCardProps +} from './conversation-tab-comment-card' + +export function renderCommentGroup( + group: PRCommentGroup, + ctx: ConversationCommentCardContext +): React.JSX.Element { + const cards = + group.kind === 'thread' + ? [ + renderCommentCard(group.root, false, ctx), + ...group.replies.map((reply) => renderCommentCard(reply, true, ctx)) + ] + : [renderCommentCard(group.comment, false, ctx)] + + if (!isResolvedPRCommentGroup(group)) { + return ( +
+ {cards} +
+ ) + } + + const root = getPRCommentGroupRoot(group) + const count = getPRCommentGroupCount(group) + return ( + + + + + {translate('auto.components.GitHubItemDialog.228e2f59d3', 'Resolved')}{' '} + {group.kind === 'thread' + ? translate('auto.components.GitHubItemDialog.28d0d3374f', 'thread') + : translate('auto.components.GitHubItemDialog.e2bf3e41a9', 'comment')}{' '} + {translate('auto.components.GitHubItemDialog.0ae387d8ca', 'by')} {root.author} + {count > 1 ? ` (${count})` : ''} + + + + {cards} + + + + ) +} + +export function ConversationTabCommentGroup({ + group, + ...commentCardProps +}: { group: PRCommentGroup } & Omit< + ConversationTabCommentCardProps, + 'comment' | 'isReply' +>): React.JSX.Element { + const cards = + group.kind === 'thread' + ? [ + , + ...group.replies.map((reply) => ( + + )) + ] + : [ + + ] + + if (!isResolvedPRCommentGroup(group)) { + return
{cards}
+ } + + const root = getPRCommentGroupRoot(group) + const count = getPRCommentGroupCount(group) + return ( + + + + + {translate('auto.components.GitHubItemDialog.228e2f59d3', 'Resolved')}{' '} + {group.kind === 'thread' + ? translate('auto.components.GitHubItemDialog.28d0d3374f', 'thread') + : translate('auto.components.GitHubItemDialog.e2bf3e41a9', 'comment')}{' '} + {translate('auto.components.GitHubItemDialog.0ae387d8ca', 'by')} {root.author} + {count > 1 ? ` (${count})` : ''} + + + + {cards} + + + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-description.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-description.tsx new file mode 100644 index 00000000000..24abcd7f1ac --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-description.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { Check, LoaderCircle, Pencil, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { formatRelativeTime } from '@/components/github/work-item-state-presentation' +import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import { translate } from '@/i18n/i18n' + +export function ConversationTabDescription({ + item, + authorLabel, + canEditBody, + loading, + detailsLoaded, + bodyEditing, + bodySaving, + bodyChanged, + body, + resolvedBodyDraft, + markdownGitHubRepo, + setBodyDraft, + setBodyEditing, + onSaveBody +}: { + item: GitHubWorkItem + authorLabel: string + canEditBody: boolean + loading: boolean + detailsLoaded: boolean + bodyEditing: boolean + bodySaving: boolean + bodyChanged: boolean + body: string + resolvedBodyDraft: string + markdownGitHubRepo: GitHubOwnerRepo | null + setBodyDraft: (value: string) => void + setBodyEditing: (value: boolean) => void + onSaveBody: () => void +}): React.JSX.Element { + return ( +
+
+ {authorLabel} + + {translate('auto.components.GitHubItemDialog.8223320f8d', 'updated')}{' '} + {formatRelativeTime(item.updatedAt)} + + {canEditBody && !loading && detailsLoaded ? ( + bodyEditing ? ( +
+ + +
+ ) : ( + + + + + + {translate('auto.components.GitHubItemDialog.4d555d3796', 'Edit description')} + + + ) + ) : null} +
+
+ {loading && !detailsLoaded ? ( +
+ +
+ ) : bodyEditing ? ( + void onSaveBody()} + /> + ) : body.trim() ? ( + + ) : ( + + {translate('auto.components.GitHubItemDialog.9b9cb55994', 'No description provided.')} + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-pr-sidebar.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-pr-sidebar.tsx new file mode 100644 index 00000000000..3cbb1535a16 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-pr-sidebar.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { + GitHubWorkItem, + GitHubWorkItemDetails +} from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { ChecksTab } from '../inspect-pull-request/checks-tab' +import { PRActionsPanel } from '../land-pull-request/pr-actions-panel' +import { PRReviewersPanel } from '../land-pull-request/pr-reviewers-panel' + +export function ConversationTabPRSidebar({ + item, + repoPath, + sourceContext, + projectOrigin, + localState, + onStateChange, + onMutated, + loading, + detailsLoaded, + headSha, + checks, + onChecksUpdated, + onReviewersRequested +}: { + item: GitHubWorkItem + repoPath: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + localState: GitHubWorkItem['state'] + onStateChange: (state: GitHubWorkItem['state']) => void + onMutated: () => void + loading: boolean + detailsLoaded: boolean + headSha: string | undefined + checks: GitHubWorkItemDetails['checks'] + onChecksUpdated: (checks: PRCheckDetail[]) => void + onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void +}): React.JSX.Element { + return ( +
+ + + + +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx new file mode 100644 index 00000000000..fb6508d336d --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab-timeline.tsx @@ -0,0 +1,172 @@ +import React from 'react' +import { CheckCircle2, CircleDot, Link2, MoveRight, UserMinus, UserPlus } from 'lucide-react' +import { formatRelativeTime } from '@/components/github/work-item-state-presentation' +import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget +} from '../../../../../shared/github/comment-types' +import { translate } from '@/i18n/i18n' +import { + getTimelineStateReasonLabel, + getTimelineTargetLabel, + type IssueConversationEntry +} from './issue-conversation-entries' +import { + renderCommentCard, + type ConversationCommentCardContext +} from './conversation-tab-comment-card' + +export function renderTimelineTarget( + target: GitHubIssueTimelineTarget | undefined +): React.ReactNode { + if (!target) { + return null + } + return ( + + ) +} + +export function renderTimelineActivityMessage(activity: GitHubIssueTimelineItem): React.ReactNode { + const assignee = + activity.assignee ?? translate('auto.components.GitHubItemDialog.timeline.someone', 'someone') + if (activity.event === 'assigned') { + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.assigned', 'assigned')}{' '} + {assignee} + + ) + } + if (activity.event === 'unassigned') { + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '} + {assignee} + + ) + } + if (activity.event === 'mentioned' || activity.event === 'cross-referenced') { + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.mentioned', 'mentioned this')} + {activity.source ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} + {renderTimelineTarget(activity.source)} + + ) : null} + + ) + } + if (activity.event === 'closed') { + const stateReason = getTimelineStateReasonLabel(activity.stateReason) + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.closed', 'closed this')} + {stateReason ? ` ${stateReason}` : ''} + {activity.closer ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} + {renderTimelineTarget(activity.closer)} + + ) : null} + + ) + } + if (activity.event === 'reopened') { + return translate('auto.components.GitHubItemDialog.timeline.reopened', 'reopened this') + } + const hasFrom = Boolean(activity.previousColumnName) + const hasTo = Boolean(activity.columnName) + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.moved', 'moved this')} + {hasFrom ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.from', 'from')}{' '} + {activity.previousColumnName} + + ) : null} + {hasTo ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '} + {activity.columnName} + + ) : null} + {activity.projectName ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} + {activity.projectName} + + ) : null} + + ) +} + +export function renderTimelineActivity(activity: GitHubIssueTimelineItem): React.JSX.Element { + const Icon = + activity.event === 'assigned' + ? UserPlus + : activity.event === 'unassigned' + ? UserMinus + : activity.event === 'closed' + ? CheckCircle2 + : activity.event === 'reopened' + ? CircleDot + : activity.event === 'moved_columns_in_project' + ? MoveRight + : Link2 + return ( +
+ + + + {activity.actorAvatarUrl ? ( + + ) : null} +
+
+ {activity.actor} + {renderTimelineActivityMessage(activity)} + + {formatRelativeTime(activity.createdAt)} + +
+
+
+ ) +} + +export function renderIssueConversationEntry( + entry: IssueConversationEntry, + ctx: ConversationCommentCardContext +): React.JSX.Element { + return entry.kind === 'comment' + ? renderCommentCard(entry.comment, false, ctx) + : renderTimelineActivity(entry.activity) +} + +export function ConversationTabTimelineActivity({ + activity +}: { + activity: GitHubIssueTimelineItem +}): React.JSX.Element { + return renderTimelineActivity(activity) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab.tsx new file mode 100644 index 00000000000..435ab07d6ac --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/conversation-tab.tsx @@ -0,0 +1,354 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { + filterPRCommentsByAudience, + getPRCommentAudienceCounts, + type PRCommentAudienceFilter +} from '../../../../../shared/pr-comment-audience' +import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' +import { groupPRComments } from '../../../../../shared/pr-comment-groups' +import { + getCommentReplyTargetCandidates, + resolveCommentReplyTarget +} from '@/components/comment-reply-target-state' +import { + attachPRReviewReplyParent, + canPostPRReviewThreadReply +} from '@/components/right-sidebar/pr-comments-ai-launch-ack' +import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' +import { useAppStore } from '@/store' +import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context' +import { + resolveGitHubBodyDraft, + shouldSyncGitHubBodyDraft +} from '@/components/github-body-draft-state' +import type { GitHubIssueTimelineItem, PRComment } from '../../../../../shared/github/comment-types' +import type { + GitHubAssignableUser, + GitHubPRFile +} from '../../../../../shared/github/pull-request-types' +import type { + GitHubWorkItem, + GitHubWorkItemDetails +} from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import { translate } from '@/i18n/i18n' +import { + parseOwnerRepoFromItemUrl, + resolvePullRequestRepo +} from '@/components/github/github-work-item-identity' +import { + addIssueCommentForRepo, + addPRReviewCommentReplyForRepo +} from '@/components/github/github-work-item-comment-mutations' +import { runWorkItemBodyUpdate } from '@/components/github/github-work-item-edit-mutations' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { + EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS, + getIssueConversationEntries +} from './issue-conversation-entries' +import { GHCommentComposer } from './gh-comment-composer' +import { ConversationTabDescription } from './conversation-tab-description' +import { ConversationTabActivity } from './conversation-tab-activity' +import { ConversationTabPRSidebar } from './conversation-tab-pr-sidebar' + +export function ConversationTab({ + item, + repoPath, + repoId, + sourceContext, + body, + comments, + timelineItems, + files, + headSha, + baseSha, + loading, + detailsLoaded, + checks, + localState, + onStateChange, + projectOrigin, + onMutated, + onChecksUpdated, + onBodyUpdated, + onCommentAdded, + onReviewersRequested +}: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + sourceContext?: TaskSourceContext | null + body: string + comments: PRComment[] + timelineItems?: GitHubIssueTimelineItem[] + files: GitHubPRFile[] + headSha: string | undefined + baseSha: string | undefined + loading: boolean + detailsLoaded: boolean + checks: GitHubWorkItemDetails['checks'] + localState: GitHubWorkItem['state'] + onStateChange: (state: GitHubWorkItem['state']) => void + projectOrigin: GitHubItemDialogProjectOrigin | undefined + onMutated: () => void + onChecksUpdated: (checks: PRCheckDetail[]) => void + onBodyUpdated: (body: string) => void + onCommentAdded: (comment: PRComment) => void + onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void +}): React.JSX.Element { + const authorLabel = item.author ?? 'unknown' + const [replyingTo, setReplyingTo] = useState(null) + const [commentFilter, setCommentFilter] = useState('all') + const [bodyDraft, setBodyDraft] = useState(body) + const [bodyEditing, setBodyEditing] = useState(false) + const [bodySaving, setBodySaving] = useState(false) + const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext) + const botAuthorOverrides = usePRBotAuthorOverrides() + const commentCounts = useMemo( + () => getPRCommentAudienceCounts(comments, botAuthorOverrides), + [botAuthorOverrides, comments] + ) + const visibleComments = useMemo( + () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides), + [botAuthorOverrides, commentFilter, comments] + ) + const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) + const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS + const issueConversationEntries = useMemo( + () => getIssueConversationEntries(comments, resolvedTimelineItems), + [comments, resolvedTimelineItems] + ) + const replyTargetComments = getCommentReplyTargetCandidates(item.type, comments, visibleComments) + const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, replyTargetComments) + + if (resolvedReplyingTo !== replyingTo) { + // Why: filters/refetches can hide the active reply target; clear before paint so a stale composer doesn't flash for the wrong comment set. + setReplyingTo(resolvedReplyingTo) + } + + const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing) + if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) { + // Why: a background refresh can change the body while the editor is closed; reconcile before paint so reopening never sees a stale draft. + setBodyDraft(resolvedBodyDraft) + } + + const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) + const markdownGitHubRepo = useMemo( + () => (projectOrigin ? { owner: projectOrigin.owner, repo: projectOrigin.repo } : bodySlug), + [bodySlug, projectOrigin] + ) + const canEditBody = + item.type === 'pr' + ? Boolean(projectOrigin || bodySlug) + : Boolean(projectOrigin || canUseRepoMutationContext) + const bodyChanged = resolvedBodyDraft !== body + + const handleSaveBody = useCallback(async (): Promise => { + if (bodySaving || !bodyChanged) { + setBodyEditing(false) + return + } + setBodySaving(true) + try { + await runWorkItemBodyUpdate({ + item, + repoPath, + sourceContext, + projectOrigin, + body: resolvedBodyDraft, + parsedSlug: bodySlug + }) + onBodyUpdated(resolvedBodyDraft) + setBodyEditing(false) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + translate('auto.components.GitHubItemDialog.5221548274', 'Description updated.') + ) + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.GitHubItemDialog.58c73cb0d8', + 'Failed to update description.' + ) + ) + } finally { + setBodySaving(false) + } + }, [ + bodyChanged, + resolvedBodyDraft, + bodySaving, + bodySlug, + item, + onBodyUpdated, + projectOrigin, + repoPath, + sourceContext + ]) + + const handleReply = useCallback( + async (comment: PRComment, replyBody: string): Promise => { + if (!canUseRepoMutationContext) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.745c9089ec', + 'Unable to reply without a repository path.' + ) + ) + return false + } + // Why: nest under review threads (path/threadId/discussion_r); never post a + // separate top-level conversation comment for those. + const isReviewThreadReply = item.type === 'pr' && canPostPRReviewThreadReply(comment) + const result = isReviewThreadReply + ? await addPRReviewCommentReplyForRepo({ + repoPath: repoPath ?? '', + repoId: repoId ?? item.repoId, + sourceContext, + prNumber: item.number, + prRepo, + commentId: comment.id, + body: replyBody, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }) + : await addIssueCommentForRepo({ + repoPath: repoPath ?? '', + repoId: repoId ?? item.repoId, + sourceContext, + number: item.number, + // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. + body: buildPRCommentConversationReplyBody(comment.author, replyBody), + type: item.type, + prRepo + }) + + if (!result.ok) { + toast.error( + result.error || + translate('auto.components.GitHubItemDialog.283699bc82', 'Failed to post reply.') + ) + return false + } + onCommentAdded( + isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment + ) + setReplyingTo(null) + toast.success(translate('auto.components.GitHubItemDialog.10f4ff5be8', 'Reply posted.')) + return true + }, + [ + canUseRepoMutationContext, + item.number, + item.repoId, + item.type, + onCommentAdded, + prRepo, + repoId, + repoPath, + sourceContext + ] + ) + + const commentCardProps = { + repoPath, + repoId: repoId ?? item.repoId, + sourceContext, + prNumber: item.number, + prRepo, + files, + headSha, + baseSha, + markdownGitHubRepo, + resolvedReplyingTo, + onToggleReply: (commentId: number) => { + setReplyingTo((current) => (current === commentId ? null : commentId)) + }, + onReply: handleReply, + onCancelReply: () => setReplyingTo(null) + } + + return ( +
+
+ { + void handleSaveBody() + }} + /> + + {detailsLoaded ? ( + + ) : null} + + {detailsLoaded && canUseRepoMutationContext && ( + + )} +
+ + {item.type === 'pr' ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/gh-comment-composer.tsx b/src/renderer/src/components/github-item-dialog/discuss-item/gh-comment-composer.tsx new file mode 100644 index 00000000000..e0e64b66416 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/gh-comment-composer.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useState } from 'react' +import { LoaderCircle, Send } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { useMountedRef } from '@/hooks/useMountedRef' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { + getCommentBodySubmitState, + hasBoundedCommentBodyText +} from '@/lib/comment-body-submit-state' +import { addIssueCommentForRepo } from '@/components/github/github-work-item-comment-mutations' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' + +export function GHCommentComposer({ + className, + repoPath, + repoId, + sourceContext, + issueNumber, + itemType, + prRepo, + onCommentAdded +}: { + className?: string + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + issueNumber: number + itemType: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + onCommentAdded: (comment: PRComment) => void +}): React.JSX.Element { + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const mountedRef = useMountedRef() + + const handleSubmit = useCallback(async () => { + const bodyState = getCommentBodySubmitState(body) + if (bodyState.status === 'empty') { + return + } + if (bodyState.status === 'too-large-leading-whitespace') { + toast.error( + translate( + 'auto.components.GitHubItemDialog.commentTooLarge', + 'Comment is too large to submit safely.' + ) + ) + return + } + setSubmitting(true) + try { + const result = await addIssueCommentForRepo({ + repoPath, + repoId: repoId ?? undefined, + sourceContext, + number: issueNumber, + body: bodyState.body, + type: itemType, + prRepo + }) + if (!mountedRef.current) { + return + } + if (result.ok) { + setBody('') + // Why: use GitHub's returned comment so the optimistic row shows the real login/avatar immediately. + onCommentAdded(result.comment) + } else { + toast.error( + result.error ?? + translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') + ) + } + } catch (err) { + if (mountedRef.current) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') + ) + } + } finally { + if (mountedRef.current) { + setSubmitting(false) + } + } + }, [ + body, + mountedRef, + repoPath, + repoId, + sourceContext, + issueNumber, + itemType, + prRepo, + onCommentAdded + ]) + const canSubmitComment = hasBoundedCommentBodyText(body) + + return ( +
+ void handleSubmit()} + /> + + + + + + {translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} + + +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/discuss-item/issue-conversation-entries.ts b/src/renderer/src/components/github-item-dialog/discuss-item/issue-conversation-entries.ts new file mode 100644 index 00000000000..6af66ea62fe --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/discuss-item/issue-conversation-entries.ts @@ -0,0 +1,68 @@ +import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget, + PRComment +} from '../../../../../shared/github/comment-types' +import { translate } from '@/i18n/i18n' + +export type IssueConversationEntry = + | { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number } + | { + kind: 'activity' + id: string + createdAt: string + activity: GitHubIssueTimelineItem + index: number + } + +export const EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS: GitHubIssueTimelineItem[] = [] + +function getTimelineSortValue(createdAt: string): number { + const value = new Date(createdAt).getTime() + return Number.isFinite(value) ? value : 0 +} + +export function getIssueConversationEntries( + comments: PRComment[], + timelineItems: GitHubIssueTimelineItem[] +): IssueConversationEntry[] { + return [ + ...comments.map( + (comment, index): IssueConversationEntry => ({ + kind: 'comment', + id: `comment:${comment.id}`, + createdAt: comment.createdAt, + comment, + index + }) + ), + ...timelineItems.map( + (activity, index): IssueConversationEntry => ({ + kind: 'activity', + id: `activity:${activity.id}`, + createdAt: activity.createdAt, + activity, + index: comments.length + index + }) + ) + ].sort((a, b) => { + const diff = getTimelineSortValue(a.createdAt) - getTimelineSortValue(b.createdAt) + return diff === 0 ? a.index - b.index : diff + }) +} + +export function getTimelineTargetLabel(target: GitHubIssueTimelineTarget): string { + const prefix = target.type === 'pr' ? 'PR' : 'issue' + const title = target.title ? ` ${target.title}` : '' + return `${prefix} #${target.number}${title}` +} + +export function getTimelineStateReasonLabel(reason: string | null | undefined): string | null { + if (reason === 'completed') { + return translate('auto.components.GitHubItemDialog.timeline.completed', 'as completed') + } + if (reason === 'not_planned') { + return translate('auto.components.GitHubItemDialog.timeline.notPlanned', 'as not planned') + } + return null +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-assignees.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-assignees.tsx new file mode 100644 index 00000000000..e0a98495bb9 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-assignees.tsx @@ -0,0 +1,207 @@ +import React from 'react' +import { ChevronDown, LoaderCircle, Pencil } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import { translate } from '@/i18n/i18n' + +const checkIcon = ( + + + +) + +export function GHEditSectionAssigneesColumn({ + localAssignees, + repoAssignees, + isPending, + popoverOpen, + onPopoverOpenChange, + onToggle +}: { + localAssignees: string[] + repoAssignees: { data: GitHubAssignableUser[]; loading: boolean; error: string | null } + isPending: boolean + popoverOpen: boolean + onPopoverOpenChange: (open: boolean) => void + onToggle: (login: string) => void +}): React.JSX.Element { + return ( +
+
+ {translate('auto.components.GitHubItemDialog.83ac703dda', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => ( + + ))} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.c67de9e2fe', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((login) => { + const user = repoAssignees.data.find((u) => u.login === login) + return ( +
  • + {user?.avatarUrl ? ( + + ) : ( +
    + )} + + {login} + +
  • + ) + })} +
+ )} +
+ ) +} + +export function GHEditSectionAssigneesPill({ + localAssignees, + repoAssignees, + isPending, + popoverOpen, + onPopoverOpenChange, + onToggle +}: { + localAssignees: string[] + repoAssignees: { data: GitHubAssignableUser[]; loading: boolean; error: string | null } + isPending: boolean + popoverOpen: boolean + onPopoverOpenChange: (open: boolean) => void + onToggle: (login: string) => void +}): React.JSX.Element { + return ( + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx new file mode 100644 index 00000000000..d01c8f18fd9 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-horizontal.tsx @@ -0,0 +1,190 @@ +import React from 'react' +import { ArrowRight, ChevronDown, FolderKanban, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { translate } from '@/i18n/i18n' +import { GHEditSectionStatusPopover } from './gh-edit-section-status-popover' +import { GHEditSectionAssigneesPill } from './gh-edit-section-assignees' +import { GHEditSectionLabelsPill } from './gh-edit-section-labels' + +export function GHEditSectionHorizontal({ + item, + localState, + localLabels, + localAssignees, + repoLabels, + repoAssignees, + repositoryLabelsUrl, + attachedWorkspaceLabel, + hasAttachedWorkspace, + isStatePending, + isAssigneesPending, + isLabelsPending, + statusPopoverOpen, + assigneePopoverOpen, + labelPopoverOpen, + duplicatePickerOpen, + duplicateSearch, + duplicateError, + duplicatePickerTitle, + filteredDuplicateCandidates, + directDuplicateTarget, + onStatusOpenChange, + onAssigneeOpenChange, + onLabelOpenChange, + onStateChange, + onDuplicateSearchChange, + onDuplicateSearchSubmit, + onCloseAsDuplicate, + onBackFromDuplicate, + onOpenDuplicatePicker, + onAssigneeToggle, + onLabelToggle, + onOpenOrUseWorkspace, + onUse +}: { + item: GitHubWorkItem + localState: GitHubWorkItem['state'] + localLabels: string[] + localAssignees: string[] + repoLabels: { data: string[]; loading: boolean; error: string | null } + repoAssignees: { data: GitHubAssignableUser[]; loading: boolean; error: string | null } + repositoryLabelsUrl: string | null + attachedWorkspaceLabel?: string | null + hasAttachedWorkspace: boolean + isStatePending: boolean + isAssigneesPending: boolean + isLabelsPending: boolean + statusPopoverOpen: boolean + assigneePopoverOpen: boolean + labelPopoverOpen: boolean + duplicatePickerOpen: boolean + duplicateSearch: string + duplicateError: string | null + duplicatePickerTitle: string + filteredDuplicateCandidates: GitHubWorkItem[] + directDuplicateTarget: number | null + onStatusOpenChange: (open: boolean) => void + onAssigneeOpenChange: (open: boolean) => void + onLabelOpenChange: (open: boolean) => void + onStateChange: (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => void + onDuplicateSearchChange: (value: string) => void + onDuplicateSearchSubmit: () => void + onCloseAsDuplicate: (targetIssueNumber: number | string) => void + onBackFromDuplicate: () => void + onOpenDuplicatePicker: () => void + onAssigneeToggle: (login: string) => void + onLabelToggle: (label: string) => void + onOpenOrUseWorkspace: () => void + onUse: (item: GitHubWorkItem) => void +}): React.JSX.Element { + return ( +
+ + + +
+ {attachedWorkspaceLabel ? ( + + + {attachedWorkspaceLabel} + + ) : null} + {hasAttachedWorkspace ? ( + + + + + + + + + onUse(item)}> + + {translate('auto.components.GitHubItemDialog.36182aa57f', 'Start new workspace')} + + + + ) : ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-labels.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-labels.tsx new file mode 100644 index 00000000000..be967bffb4f --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-labels.tsx @@ -0,0 +1,195 @@ +import React from 'react' +import { ChevronDown, LoaderCircle, Pencil } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { GitHubLabelsSettingsLink } from './github-labels-settings-link' + +const checkIcon = ( + + + +) + +export function GHEditSectionLabelsColumn({ + localLabels, + repoLabels, + repositoryLabelsUrl, + isPending, + popoverOpen, + onPopoverOpenChange, + onToggle +}: { + localLabels: string[] + repoLabels: { data: string[]; loading: boolean; error: string | null } + repositoryLabelsUrl: string | null + isPending: boolean + popoverOpen: boolean + onPopoverOpenChange: (open: boolean) => void + onToggle: (label: string) => void +}): React.JSX.Element { + return ( +
+
+ {translate('auto.components.GitHubItemDialog.217e55d87c', 'Labels')} + + + + + + {repoLabels.error ? ( +
+ {repoLabels.error} +
+ ) : null} + {!repoLabels.error ? ( +
+ {repoLabels.data.map((label) => ( + + ))} +
+ ) : null} + 0} + onOpen={() => onPopoverOpenChange(false)} + /> +
+
+
+ {localLabels.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.886a64b081', 'None yet')} +
+ ) : ( +
+ {localLabels.map((name) => ( + + {name} + + ))} +
+ )} +
+ ) +} + +export function GHEditSectionLabelsPill({ + localLabels, + repoLabels, + repositoryLabelsUrl, + isPending, + popoverOpen, + onPopoverOpenChange, + onToggle +}: { + localLabels: string[] + repoLabels: { data: string[]; loading: boolean; error: string | null } + repositoryLabelsUrl: string | null + isPending: boolean + popoverOpen: boolean + onPopoverOpenChange: (open: boolean) => void + onToggle: (label: string) => void +}): React.JSX.Element { + return ( + + + + + + {repoLabels.error ? ( +
+ {repoLabels.error} +
+ ) : null} + {!repoLabels.error ? ( +
+ {repoLabels.data.map((label) => ( + + ))} +
+ ) : null} + 0} + onOpen={() => onPopoverOpenChange(false)} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts new file mode 100644 index 00000000000..42fba184a2e --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts @@ -0,0 +1,310 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import type { useImmediateMutation } from '@/hooks/useIssueMetadata' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' +import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' +import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' + +export type GHEditProjectRowPatch = { + state?: GitHubWorkItem['state'] + labels?: string[] + assignees?: string[] +} + +export type GHEditMutationRun = ReturnType['run'] + +type GHEditMutationBase = { + itemNumber: GitHubWorkItem['number'] + itemRepoId: GitHubWorkItem['repoId'] + repoPath: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + run: GHEditMutationRun + patchProjectRowIfNeeded: (patch: GHEditProjectRowPatch) => void + onMutated: () => void +} + +export function runGHEditStateChange({ + newState, + closeAction, + localState, + itemId, + itemNumber, + itemRepoId, + repoPath, + sourceContext, + projectOrigin, + run, + onStateChange, + patchWorkItem, + patchProjectRowIfNeeded, + onMutated +}: GHEditMutationBase & { + itemId: GitHubWorkItem['id'] + newState: 'open' | 'closed' + closeAction?: TaskPageGitHubCloseAction + localState: GitHubWorkItem['state'] + onStateChange: (state: GitHubWorkItem['state']) => void + patchWorkItem: ( + id: string, + patch: { state: GitHubWorkItem['state'] }, + repoId: string | undefined, + options: { sourceContext?: TaskSourceContext | null } + ) => void +}): void { + // Why: a close reason still has to reach GitHub even when the item already reads as closed locally. + if (newState === localState && !closeAction) { + return + } + const prevState = localState + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + let authority: { revert: () => boolean } | null = null + void run('state', { + mutate: () => + runIssueUpdate({ + repoId: itemRepoId, + repoPath, + sourceContext, + projectOrigin, + number: itemNumber, + updates: + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } + }), + onOptimistic: () => { + authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: itemRepoId, + itemId, + state: newState, + sourceContext + }) + onStateChange(newState) + patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext }) + patchProjectRowIfNeeded({ state: newState }) + }, + onRevert: () => { + if (authority?.revert()) { + onStateChange(prevState) + patchWorkItem(itemId, { state: prevState }, itemRepoId, { sourceContext }) + patchProjectRowIfNeeded({ state: prevState }) + } + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext }) + patchProjectRowIfNeeded({ state: newState }) + onMutated() + }, + onError: (err) => toast.error(err) + }) +} + +export function closeGHEditAsDuplicate({ + targetIssueNumber, + itemNumber, + setDuplicateError, + handleStateChange, + setStatusPopoverOpen, + setDuplicatePickerOpen +}: { + targetIssueNumber: number | string + itemNumber: number + setDuplicateError: (value: string | null) => void + handleStateChange: (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => void + setStatusPopoverOpen: (value: boolean) => void + setDuplicatePickerOpen: (value: boolean) => void +}): void { + const validation = validateTaskPageGitHubDuplicateTarget(String(targetIssueNumber), itemNumber) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setStatusPopoverOpen(false) + setDuplicatePickerOpen(false) +} + +export function runGHEditLabelToggle({ + label, + localLabels, + itemId, + itemNumber, + itemRepoId, + repoPath, + sourceContext, + projectOrigin, + run, + onLabelsChange, + patchWorkItem, + patchProjectRowIfNeeded, + onMutated +}: GHEditMutationBase & { + itemId: GitHubWorkItem['id'] + label: string + localLabels: string[] + onLabelsChange: (labels: string[]) => void + patchWorkItem: ( + id: string, + patch: { labels: string[] }, + repoId: string | undefined, + options: { sourceContext?: TaskSourceContext | null } + ) => void +}): void { + const isAdding = !localLabels.includes(label) + const prevLabels = localLabels + const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label) + + if (isAdding) { + void run('labels', { + mutate: () => + runIssueUpdate({ + repoId: itemRepoId, + repoPath, + sourceContext, + projectOrigin, + number: itemNumber, + updates: { addLabels: [label] } + }), + onOptimistic: () => { + onLabelsChange(newLabels) + patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext }) + patchProjectRowIfNeeded({ labels: newLabels }) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onRevert: () => { + onLabelsChange(prevLabels) + patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext }) + patchProjectRowIfNeeded({ labels: prevLabels }) + }, + onError: (err) => toast.error(err) + }) + return + } + void run('labels', { + mutate: () => + runIssueUpdate({ + repoId: itemRepoId, + repoPath, + sourceContext, + projectOrigin, + number: itemNumber, + updates: { removeLabels: [label] } + }), + onOptimistic: () => { + onLabelsChange(newLabels) + patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext }) + patchProjectRowIfNeeded({ labels: newLabels }) + }, + onRevert: () => { + onLabelsChange(prevLabels) + patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext }) + patchProjectRowIfNeeded({ labels: prevLabels }) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onError: (err) => toast.error(err) + }) +} + +export function runGHEditAssigneeToggle({ + login, + localAssignees, + assigneesItemKey, + editedAssigneesItemKeyRef, + itemNumber, + itemRepoId, + repoPath, + sourceContext, + projectOrigin, + run, + setLocalAssignees, + patchProjectRowIfNeeded, + onMutated +}: GHEditMutationBase & { + login: string + localAssignees: string[] + assigneesItemKey: string + editedAssigneesItemKeyRef: { current: string | null } + setLocalAssignees: (value: string[]) => void +}): void { + const isAssigned = localAssignees.includes(login) + const prevAssignees = localAssignees + const newAssignees = isAssigned + ? prevAssignees.filter((l) => l !== login) + : [...prevAssignees, login] + + // Why: scope the optimistic guard to this repo item so switching items doesn't suppress the next item's assignee sync. + editedAssigneesItemKeyRef.current = assigneesItemKey + if (isAssigned) { + void run('assignees', { + mutate: () => + runIssueUpdate({ + repoId: itemRepoId, + repoPath, + sourceContext, + projectOrigin, + number: itemNumber, + updates: { removeAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(newAssignees) + patchProjectRowIfNeeded({ assignees: newAssignees }) + }, + onRevert: () => { + // Why: leaving the guard set after a failed toggle suppresses assignee prop syncs indefinitely. + editedAssigneesItemKeyRef.current = null + setLocalAssignees(prevAssignees) + patchProjectRowIfNeeded({ assignees: prevAssignees }) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onError: (err) => toast.error(err) + }) + return + } + void run('assignees', { + mutate: () => + runIssueUpdate({ + repoId: itemRepoId, + repoPath, + sourceContext, + projectOrigin, + number: itemNumber, + updates: { addAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(newAssignees) + patchProjectRowIfNeeded({ assignees: newAssignees }) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onRevert: () => { + // Why: leaving the guard set after a failed toggle suppresses assignee prop syncs indefinitely. + editedAssigneesItemKeyRef.current = null + setLocalAssignees(prevAssignees) + patchProjectRowIfNeeded({ assignees: prevAssignees }) + }, + onError: (err) => toast.error(err) + }) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx new file mode 100644 index 00000000000..3452f027f99 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-status-popover.tsx @@ -0,0 +1,234 @@ +import React from 'react' +import { + Ban, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + CircleDashed, + CircleDot, + Copy, + Search +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { getStateLabel } from '@/components/github/work-item-state-presentation' +import { translate } from '@/i18n/i18n' +import { getStateTone } from '../load-item-details/work-item-state-badge' + +export function GHEditSectionStatusPopover({ + item, + variant, + localState, + isPending, + statusPopoverOpen, + duplicatePickerOpen, + duplicateSearch, + duplicateError, + duplicatePickerTitle, + filteredDuplicateCandidates, + directDuplicateTarget, + onOpenChange, + onStateChange, + onDuplicateSearchChange, + onDuplicateSearchSubmit, + onCloseAsDuplicate, + onBackFromDuplicate, + onOpenDuplicatePicker +}: { + item: GitHubWorkItem + variant: 'sidebar' | 'pill' + localState: GitHubWorkItem['state'] + isPending: boolean + statusPopoverOpen: boolean + duplicatePickerOpen: boolean + duplicateSearch: string + duplicateError: string | null + duplicatePickerTitle: string + filteredDuplicateCandidates: GitHubWorkItem[] + directDuplicateTarget: number | null + onOpenChange: (open: boolean) => void + onStateChange: (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => void + onDuplicateSearchChange: (value: string) => void + onDuplicateSearchSubmit: () => void + onCloseAsDuplicate: (targetIssueNumber: number | string) => void + onBackFromDuplicate: () => void + onOpenDuplicatePicker: () => void +}): React.JSX.Element { + const isSidebar = variant === 'sidebar' + return ( + + + + + + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + onDuplicateSearchChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + onDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) : ( + <> + + + + + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx new file mode 100644 index 00000000000..9db9dabf10a --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-top-columns.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import { FolderKanban } from 'lucide-react' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { translate } from '@/i18n/i18n' +import { GHEditSectionStatusPopover } from './gh-edit-section-status-popover' +import { GHEditSectionAssigneesColumn } from './gh-edit-section-assignees' +import { GHEditSectionLabelsColumn } from './gh-edit-section-labels' + +export function GHEditSectionTopColumns({ + item, + localState, + localLabels, + localAssignees, + repoLabels, + repoAssignees, + repositoryLabelsUrl, + attachedWorkspaceLabel, + isStatePending, + isAssigneesPending, + isLabelsPending, + statusPopoverOpen, + assigneePopoverOpen, + labelPopoverOpen, + duplicatePickerOpen, + duplicateSearch, + duplicateError, + duplicatePickerTitle, + filteredDuplicateCandidates, + directDuplicateTarget, + onStatusOpenChange, + onAssigneeOpenChange, + onLabelOpenChange, + onStateChange, + onDuplicateSearchChange, + onDuplicateSearchSubmit, + onCloseAsDuplicate, + onBackFromDuplicate, + onOpenDuplicatePicker, + onAssigneeToggle, + onLabelToggle +}: { + item: GitHubWorkItem + localState: GitHubWorkItem['state'] + localLabels: string[] + localAssignees: string[] + repoLabels: { data: string[]; loading: boolean; error: string | null } + repoAssignees: { data: GitHubAssignableUser[]; loading: boolean; error: string | null } + repositoryLabelsUrl: string | null + attachedWorkspaceLabel?: string | null + isStatePending: boolean + isAssigneesPending: boolean + isLabelsPending: boolean + statusPopoverOpen: boolean + assigneePopoverOpen: boolean + labelPopoverOpen: boolean + duplicatePickerOpen: boolean + duplicateSearch: string + duplicateError: string | null + duplicatePickerTitle: string + filteredDuplicateCandidates: GitHubWorkItem[] + directDuplicateTarget: number | null + onStatusOpenChange: (open: boolean) => void + onAssigneeOpenChange: (open: boolean) => void + onLabelOpenChange: (open: boolean) => void + onStateChange: (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => void + onDuplicateSearchChange: (value: string) => void + onDuplicateSearchSubmit: () => void + onCloseAsDuplicate: (targetIssueNumber: number | string) => void + onBackFromDuplicate: () => void + onOpenDuplicatePicker: () => void + onAssigneeToggle: (login: string) => void + onLabelToggle: (label: string) => void +}): React.JSX.Element { + // Why: lay property fields as top columns so the description isn't squeezed by a right rail. + return ( + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx new file mode 100644 index 00000000000..43b47323864 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx @@ -0,0 +1,391 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' +import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../../shared/task-source-context' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' +import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity' +import { translate } from '@/i18n/i18n' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { getGitHubRepositoryLabelsUrl } from './repository-labels-url' +import { + closeGHEditAsDuplicate, + runGHEditAssigneeToggle, + runGHEditLabelToggle, + runGHEditStateChange +} from './gh-edit-section-mutations' +import { GHEditSectionTopColumns } from './gh-edit-section-top-columns' +import { GHEditSectionHorizontal } from './gh-edit-section-horizontal' + +export function GHEditSection({ + item, + repoPath, + repoId, + sourceContext, + projectOrigin, + localState, + localLabels, + onStateChange, + onLabelsChange, + onMutated, + assignees, + onUse, + onOpenOrUse, + attachedWorkspaceLabel, + layout = 'horizontal' +}: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + localState: GitHubWorkItem['state'] + localLabels: string[] + onStateChange: (state: GitHubWorkItem['state']) => void + onLabelsChange: (labels: string[]) => void + /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */ + onMutated: () => void + assignees: string[] + onUse: (item: GitHubWorkItem) => void + onOpenOrUse?: (item: GitHubWorkItem) => void + attachedWorkspaceLabel?: string | null + /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */ + layout?: 'horizontal' | 'top-columns' +}): React.JSX.Element | null { + const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const [statusPopoverOpen, setStatusPopoverOpen] = useState(false) + const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) + const [duplicateSearch, setDuplicateSearch] = useState('') + const [duplicateError, setDuplicateError] = useState(null) + const [localAssignees, setLocalAssignees] = useState(assignees) + const editedAssigneesItemKeyRef = useRef(null) + const assigneesItemKey = `${item.repoId}\0${item.id}` + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const { isPending, run } = useImmediateMutation() + // Why: from a Project view, keep projectViewCache in sync too — patchWorkItem only walks workItemsCache, so the table would render stale without this. See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. + const patchProjectRowIfNeeded = useCallback( + (patch: Parameters[2]) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch) + }, + [projectOrigin, patchProjectRowContent] + ) + + // Why: with projectOrigin set, read labels/assignees from the row's repo, not the workspace path, or popovers list a different repo than writes target. + const slugOwner = projectOrigin?.owner ?? null + const slugRepo = projectOrigin?.repo ?? null + const repoLabelsByPath = useRepoLabels( + projectOrigin ? null : repoPath, + projectOrigin ? null : repoId, + sourceSettings + ) + const repoLabelsBySlug = useRepoLabelsBySlug( + slugOwner, + slugRepo, + sourceSettings, + projectOrigin?.host + ) + const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath + const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) + const repoAssigneesByPath = useRepoAssignees( + projectOrigin ? null : repoPath, + projectOrigin ? null : repoId, + sourceSettings + ) + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assignees, + sourceSettings, + projectOrigin?.host + ) + const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath + const hasAttachedWorkspace = + attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = useMemo(() => { + if (projectOrigin) { + return `${projectOrigin.owner}/${projectOrigin.repo}` + } + const parsed = parseOwnerRepoFromItemUrl(item.url) + return parsed + ? `${parsed.owner}/${parsed.repo}` + : translate('auto.components.TaskPage.repository', 'Repository') + }, [item.url, projectOrigin]) + const handleOpenOrUseWorkspace = useCallback((): void => { + if (onOpenOrUse) { + onOpenOrUse(item) + return + } + onUse(item) + }, [item, onOpenOrUse, onUse]) + + // Why: sync local assignees on item change / detail resolve, but skip if the user made an optimistic edit so we don't clobber in-flight changes. + useEffect(() => { + if (editedAssigneesItemKeyRef.current === assigneesItemKey) { + return + } + setLocalAssignees(assignees) + }, [assigneesItemKey, assignees]) + + const handleStateChange = useCallback( + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { + runGHEditStateChange({ + newState, + closeAction, + localState, + itemId: item.id, + itemNumber: item.number, + itemRepoId: item.repoId, + repoPath, + sourceContext, + projectOrigin, + run, + onStateChange, + patchWorkItem, + patchProjectRowIfNeeded, + onMutated + }) + }, + [ + item.id, + item.number, + item.repoId, + localState, + repoPath, + sourceContext, + projectOrigin, + patchWorkItem, + patchProjectRowIfNeeded, + run, + onStateChange, + onMutated + ] + ) + + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + closeGHEditAsDuplicate({ + targetIssueNumber, + itemNumber: item.number, + setDuplicateError, + handleStateChange, + setStatusPopoverOpen, + setDuplicatePickerOpen + }) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handleStatusPopoverOpenChange = useCallback((nextOpen: boolean) => { + setStatusPopoverOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + + const handleLabelToggle = useCallback( + (label: string) => { + runGHEditLabelToggle({ + label, + localLabels, + itemId: item.id, + itemNumber: item.number, + itemRepoId: item.repoId, + repoPath, + sourceContext, + projectOrigin, + run, + onLabelsChange, + patchWorkItem, + patchProjectRowIfNeeded, + onMutated + }) + }, + [ + item.id, + item.number, + item.repoId, + localLabels, + repoPath, + sourceContext, + projectOrigin, + patchWorkItem, + patchProjectRowIfNeeded, + run, + onLabelsChange, + onMutated + ] + ) + + const handleAssigneeToggle = useCallback( + (login: string) => { + runGHEditAssigneeToggle({ + login, + localAssignees, + assigneesItemKey, + editedAssigneesItemKeyRef, + itemNumber: item.number, + itemRepoId: item.repoId, + repoPath, + sourceContext, + projectOrigin, + run, + setLocalAssignees, + patchProjectRowIfNeeded, + onMutated + }) + }, + [ + item.number, + item.repoId, + assigneesItemKey, + repoPath, + sourceContext, + projectOrigin, + localAssignees, + patchProjectRowIfNeeded, + run, + onMutated + ] + ) + + if (item.type === 'pr') { + return null + } + + const layoutProps = { + item, + localState, + localLabels, + localAssignees, + repoLabels, + repoAssignees, + repositoryLabelsUrl, + attachedWorkspaceLabel, + isStatePending: isPending('state'), + isAssigneesPending: isPending('assignees'), + isLabelsPending: isPending('labels'), + statusPopoverOpen, + assigneePopoverOpen, + labelPopoverOpen, + duplicatePickerOpen, + duplicateSearch, + duplicateError, + duplicatePickerTitle, + filteredDuplicateCandidates, + directDuplicateTarget, + onStatusOpenChange: handleStatusPopoverOpenChange, + onAssigneeOpenChange: setAssigneePopoverOpen, + onLabelOpenChange: setLabelPopoverOpen, + onStateChange: handleStateChange, + onDuplicateSearchChange: (value: string) => { + setDuplicateSearch(value) + setDuplicateError(null) + }, + onDuplicateSearchSubmit: handleDuplicateSearchSubmit, + onCloseAsDuplicate: closeAsDuplicate, + onBackFromDuplicate: () => { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + }, + onOpenDuplicatePicker: () => { + setDuplicatePickerOpen(true) + setDuplicateSearch('') + setDuplicateError(null) + }, + onAssigneeToggle: handleAssigneeToggle, + onLabelToggle: handleLabelToggle + } + + if (layout === 'top-columns') { + return + } + + return ( + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/github-labels-settings-link.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/github-labels-settings-link.tsx new file mode 100644 index 00000000000..d5883a77c10 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/github-labels-settings-link.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { ExternalLink, Settings } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export function GitHubLabelsSettingsLink({ + url, + separated, + onOpen +}: { + url: string | null + separated?: boolean + onOpen?: () => void +}): React.JSX.Element | null { + if (!url) { + return null + } + + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/repository-labels-url.ts b/src/renderer/src/components/github-item-dialog/edit-item-fields/repository-labels-url.ts new file mode 100644 index 00000000000..9ca3270f197 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/repository-labels-url.ts @@ -0,0 +1,19 @@ +export function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { + try { + const parsed = new URL(itemUrl) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return null + } + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 2) { + return null + } + // Why: preserve the origin so GitHub Enterprise URLs keep working while re-pathing to the repo-scoped labels page. + parsed.pathname = `/${segments[0]}/${segments[1]}/labels` + parsed.search = '' + parsed.hash = '' + return parsed.toString() + } catch { + return null + } +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts new file mode 100644 index 00000000000..b4a46e24c33 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts @@ -0,0 +1,263 @@ +import type React from 'react' +import { toast } from 'sonner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + resetGitHubChecksTabForSource, + updateGitHubChecksTabLocalChecks, + type GitHubChecksTabState +} from '@/components/github-checks-tab-state' +import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context' +import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' +import { buildFixBrokenChecksPrompt } from '@/components/pr-checks-fix-prompt' +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' + +type RuntimeHost = GitHubRuntimeHost | null + +export type ChecksTabActionContext = { + canUseChecksRepoContext: boolean + runtimeHost: RuntimeHost + sourceContext?: TaskSourceContext | null + repoId: string | null + repoPath: string | null + itemNumber: number + itemRepoId: string + headSha: string | undefined + prRepo: GitHubOwnerRepo | null + mountedRef: { current: boolean } + committedChecksContextOwnerRef: { current: object } + nextChecksRefreshRequestIdRef: { current: number } + activeChecksRefreshRequestIdRef: { current: number | null } + nextCheckDetailsRequestIdRef: { current: number } + setChecksState: React.Dispatch> + setRefreshingOwner: React.Dispatch< + React.SetStateAction<{ contextOwner: object; requestId: number } | null> + > + setRerunningOwner: React.Dispatch> + onChecksUpdated: (checks: PRCheckDetail[]) => void +} + +export async function refreshGitHubChecksTab( + ctx: ChecksTabActionContext, + expectedContextOwner?: object +): Promise { + if (!ctx.canUseChecksRepoContext) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.e7007aa1d8', + 'Unable to refresh checks without a repository path.' + ) + ) + return null + } + const refreshContextOwner = expectedContextOwner ?? ctx.committedChecksContextOwnerRef.current + if (ctx.committedChecksContextOwnerRef.current !== refreshContextOwner) { + return null + } + const refreshRequestId = ++ctx.nextChecksRefreshRequestIdRef.current + ctx.activeChecksRefreshRequestIdRef.current = refreshRequestId + ctx.setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) + try { + const nextChecks = (await (ctx.runtimeHost + ? callRuntimeRpc( + { kind: 'environment', environmentId: ctx.runtimeHost.environmentId }, + 'github.prChecks', + { + repo: getGitHubRuntimeRepoId(ctx.sourceContext, ctx.repoId ?? ctx.itemRepoId), + prNumber: ctx.itemNumber, + headSha: ctx.headSha, + prRepo: ctx.prRepo, + noCache: true + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prChecks({ + repoPath: ctx.repoPath ?? '', + repoId: ctx.repoId ?? undefined, + sourceContext: ctx.sourceContext, + prNumber: ctx.itemNumber, + headSha: ctx.headSha, + prRepo: ctx.prRepo, + noCache: true + }))) as PRCheckDetail[] + if ( + !ctx.mountedRef.current || + ctx.committedChecksContextOwnerRef.current !== refreshContextOwner || + ctx.activeChecksRefreshRequestIdRef.current !== refreshRequestId + ) { + return null + } + ctx.setChecksState((current) => + current.contextOwner === refreshContextOwner + ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) + : current + ) + ctx.onChecksUpdated(nextChecks) + return nextChecks + } catch (err) { + if ( + ctx.mountedRef.current && + ctx.committedChecksContextOwnerRef.current === refreshContextOwner && + ctx.activeChecksRefreshRequestIdRef.current === refreshRequestId + ) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.0bbdc673c1', 'Failed to refresh checks') + ) + } + return null + } finally { + if (ctx.activeChecksRefreshRequestIdRef.current === refreshRequestId) { + ctx.activeChecksRefreshRequestIdRef.current = null + } + if (ctx.mountedRef.current) { + ctx.setRefreshingOwner((current) => + current?.requestId === refreshRequestId ? null : current + ) + } + } +} + +export async function rerunGitHubChecksTab( + ctx: ChecksTabActionContext, + failedOnly: boolean, + rerunning: boolean +): Promise { + if (!ctx.canUseChecksRepoContext || rerunning) { + return + } + const rerunContextOwner = ctx.committedChecksContextOwnerRef.current + ctx.setRerunningOwner(rerunContextOwner) + try { + const result = ctx.runtimeHost + ? await callRuntimeRpc>>( + { kind: 'environment', environmentId: ctx.runtimeHost.environmentId }, + 'github.rerunPRChecks', + { + repo: getGitHubRuntimeRepoId(ctx.sourceContext, ctx.repoId ?? ctx.itemRepoId), + prNumber: ctx.itemNumber, + headSha: ctx.headSha, + failedOnly, + prRepo: ctx.prRepo + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.rerunPRChecks({ + repoPath: ctx.repoPath ?? '', + repoId: ctx.repoId ?? undefined, + sourceContext: ctx.sourceContext, + prNumber: ctx.itemNumber, + headSha: ctx.headSha, + failedOnly, + prRepo: ctx.prRepo + }) + if ( + !ctx.mountedRef.current || + ctx.committedChecksContextOwnerRef.current !== rerunContextOwner + ) { + return + } + if (!result.ok) { + toast.error(result.error) + return + } + toast.success( + result.count === 1 + ? translate('auto.components.GitHubItemDialog.ddafe851e1', 'Check rerun requested') + : translate('auto.components.GitHubItemDialog.e463ec935f', 'Check reruns requested') + ) + await refreshGitHubChecksTab(ctx, rerunContextOwner) + } catch (err) { + if ( + ctx.mountedRef.current && + ctx.committedChecksContextOwnerRef.current === rerunContextOwner + ) { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.GitHubItemDialog.9e7c221b8d', 'Failed to rerun checks') + ) + } + } finally { + if (ctx.mountedRef.current) { + ctx.setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) + } + } +} + +export async function fixBrokenGitHubChecks({ + item, + repoId, + fixingChecks, + failedChecksLength, + list, + setFixingChecks +}: { + item: GitHubWorkItem + repoId: string | null + fixingChecks: boolean + failedChecksLength: number + list: PRCheckDetail[] + setFixingChecks: (value: boolean) => void +}): Promise { + const targetRepoId = repoId ?? item.repoId + if (!targetRepoId || fixingChecks) { + return + } + if (failedChecksLength === 0) { + toast.message( + translate('auto.components.GitHubItemDialog.1690fd7f4a', 'No broken checks to fix.') + ) + return + } + + const basePrompt = buildFixBrokenChecksPrompt({ + reviewKind: 'PR', + reviewNumber: item.number, + reviewTitle: item.title, + reviewUrl: item.url, + checks: list + }) + setFixingChecks(true) + try { + const started = await startFixChecksAgent({ + item, + repoId: targetRepoId, + basePrompt, + launchSource: 'task_page', + telemetrySource: 'sidebar', + openModalFallback: () => { + toast.error( + translate( + 'auto.components.GitHubItemDialog.06482d6190', + 'Unable to create a fix workspace automatically.' + ) + ) + } + }) + if (started) { + toast.success( + translate( + 'auto.components.GitHubItemDialog.28986b3747', + 'Started an AI agent for the broken checks.' + ) + ) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error('Failed to start fix checks agent', err) + toast.error( + translate( + 'auto.components.GitHubItemDialog.03e542fcfe', + 'Failed to start an AI agent for the broken checks: {{value0}}', + { value0: message } + ) + ) + } finally { + setFixingChecks(false) + } +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-details.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-details.tsx new file mode 100644 index 00000000000..ccb5bed6380 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-details.tsx @@ -0,0 +1,239 @@ +import React from 'react' +import { ExternalLink, LoaderCircle, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import type { CheckDetailsLoadState } from '@/components/github-checks-tab-state' +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import { translate } from '@/i18n/i18n' +import { getCheckConclusion } from '@/components/pr-check-counts' +import { + formatCheckTimestamp, + getKeyedCheckAnnotations, + getKeyedCheckJobs, + getCheckDetailsKey, + getCheckStatusLabel +} from '@/components/github/pr-check-presentation' + +export function ChecksTabCheckDetails({ + check, + state, + onRetry +}: { + check: PRCheckDetail + state: CheckDetailsLoadState | undefined + onRetry: (check: PRCheckDetail, key: string) => void +}): React.JSX.Element { + const details = state?.details + const openUrl = details?.detailsUrl ?? details?.url ?? check.url + const startedAt = formatCheckTimestamp(details?.startedAt) + const completedAt = formatCheckTimestamp(details?.completedAt) + const detailsStatusCheck: PRCheckDetail = { + ...check, + status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status, + conclusion: (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion + } + const hasOutput = Boolean(details?.title || details?.summary || details?.text) + const hasAnnotations = (details?.annotations.length ?? 0) > 0 + const hasJobs = (details?.jobs.length ?? 0) > 0 + + return ( +
+ {state?.loading && !state.error ? ( +
+ + {translate('auto.components.GitHubItemDialog.934d87ab96', 'Loading check details…')} +
+ ) : ( +
+
+ + {translate('auto.components.GitHubItemDialog.9c3ba11a05', 'Status:')}{' '} + {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} + + {startedAt && ( + + {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} {startedAt} + + )} + {completedAt && ( + + {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')}{' '} + {completedAt} + + )} + {check.checkRunId && ( + + {translate('auto.components.GitHubItemDialog.485609c4f2', 'check #')} + {check.checkRunId} + + )} +
+ + {state?.error && ( +
+ + {state.error} + + +
+ )} + + {hasOutput && ( +
+ {details?.title && ( +
{details.title}
+ )} + {details?.summary && ( + + )} + {details?.text && ( + + )} +
+ )} + + {hasAnnotations && ( +
+
+ {translate('auto.components.GitHubItemDialog.96d8f36798', 'Annotations')} +
+
+ {getKeyedCheckAnnotations(details!.annotations).map( + ({ key, item: annotation }, index) => ( +
0 && 'border-t border-border/30' + )} + > +
+ + {annotation.path ?? + translate('auto.components.GitHubItemDialog.7d42606f66', 'Annotation')} + {annotation.startLine ? `:${annotation.startLine}` : ''} + + {annotation.annotationLevel && ( + + {annotation.annotationLevel} + + )} +
+ {annotation.title && ( +
+ {annotation.title} +
+ )} +
+ {annotation.message} +
+ {annotation.rawDetails && ( +
+                          {annotation.rawDetails}
+                        
+ )} +
+ ) + )} +
+
+ )} + + {hasJobs && ( +
+
+ {translate('auto.components.GitHubItemDialog.08d072664d', 'Jobs')} +
+
+ {getKeyedCheckJobs(details!.jobs).map(({ key, item: job }, index) => ( +
0 && 'border-t border-border/30')} + > +
+ + {job.name} + + + {job.conclusion ?? + job.status ?? + translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} + +
+ {job.steps.length > 0 && ( +
+ {job.steps.map((step) => ( +
+ {step.name} + {step.conclusion ?? step.status} +
+ ))} +
+ )} +
+ ))} +
+
+ )} + + {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( +
+ {getCheckConclusion(check) === 'action_required' + ? translate( + 'auto.components.GitHubItemDialog.checkActionRequiredHint', + 'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.' + ) + : translate( + 'auto.components.GitHubItemDialog.744197c84d', + 'No inline output is available for this check.' + )} +
+ )} + + {openUrl && ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-row.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-row.tsx new file mode 100644 index 00000000000..a527df259c7 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-check-row.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { ChevronDown, CircleDashed } from 'lucide-react' +import { cn } from '@/lib/utils' +import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' +import type { CheckDetailsLoadState } from '@/components/github-checks-tab-state' +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import { getCheckConclusion } from '@/components/pr-check-counts' +import { getCheckDetailsKey, getCheckStatusLabel } from '@/components/github/pr-check-presentation' +import { ChecksTabCheckDetails } from './checks-tab-check-details' + +export function renderCheckRow({ + check, + variant, + expandedCheckKey, + detailsByCheckKey, + onToggle, + requestCheckDetails +}: { + check: PRCheckDetail + variant: 'compact' | 'page' + expandedCheckKey: string | null + detailsByCheckKey: Record + onToggle: (check: PRCheckDetail) => void + requestCheckDetails: (check: PRCheckDetail, key: string) => void +}): React.JSX.Element { + const key = getCheckDetailsKey(check) + return ( + + ) +} + +export function ChecksTabCheckRow({ + check, + variant, + expanded, + detailsState, + onToggle, + onRetry +}: { + check: PRCheckDetail + variant: 'compact' | 'page' + expanded: boolean + detailsState: CheckDetailsLoadState | undefined + onToggle: (check: PRCheckDetail) => void + onRetry: (check: PRCheckDetail, key: string) => void +}): React.JSX.Element { + const conclusion = getCheckConclusion(check) + const Icon = CHECK_ICON[conclusion] ?? CircleDashed + const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' + const statusLabel = getCheckStatusLabel(check) + return ( +
+ + {expanded ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-header.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-header.tsx new file mode 100644 index 00000000000..22b42c780f7 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-header.tsx @@ -0,0 +1,203 @@ +import React from 'react' +import { ChevronDown, LoaderCircle, RefreshCw, Wrench } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export function ChecksTabActions({ + variant, + canUseChecksRepoContext, + refreshing, + rerunning, + fixingChecks, + canFixBrokenChecks, + failedChecksLength, + listLength, + onRefresh, + onRerun, + onFix +}: { + variant: 'compact' | 'page' + canUseChecksRepoContext: boolean + refreshing: boolean + rerunning: boolean + fixingChecks: boolean + canFixBrokenChecks: boolean + failedChecksLength: number + listLength: number + onRefresh: () => void + onRerun: (failedOnly: boolean) => void + onFix: () => void +}): { + refreshAction: React.JSX.Element + fixBrokenChecksAction: React.JSX.Element | null + rerunAction: React.JSX.Element | null + secondaryActions: React.JSX.Element | null + actions: React.JSX.Element +} { + const refreshAction = ( + + + + + + {translate('auto.components.GitHubItemDialog.9a1004fc76', 'Refresh checks')} + + + ) + const fixBrokenChecksAction = + failedChecksLength > 0 || fixingChecks ? ( + + + + + + {translate( + 'auto.components.GitHubItemDialog.f4b1292569', + 'Start the default AI agent on these checks' + )} + + + ) : null + const rerunAction = + listLength > 0 || rerunning ? ( + + + + + + void onRerun(true)} + > + + {translate('auto.components.GitHubItemDialog.e31651a224', 'Rerun failed checks')} + + void onRerun(false)}> + + {translate('auto.components.GitHubItemDialog.71c11aff84', 'Rerun all checks')} + + + + ) : null + const secondaryActions = + variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction || + rerunAction ? ( +
+ {fixBrokenChecksAction} + {variant === 'page' ? rerunAction : null} +
+ ) : null + const actions = ( +
+ {refreshAction} + {fixBrokenChecksAction} + {rerunAction} +
+ ) + return { refreshAction, fixBrokenChecksAction, rerunAction, secondaryActions, actions } +} + +export function ChecksTabCompactHeader({ + SummaryIcon, + summaryColor, + counts, + summaryLabel, + listLength, + refreshAction, + rerunAction, + secondaryActions +}: { + SummaryIcon: React.ComponentType<{ className?: string }> + summaryColor: string + counts: { failing: number; pending: number } + summaryLabel: string + listLength: number + refreshAction: React.JSX.Element + rerunAction: React.JSX.Element | null + secondaryActions: React.JSX.Element | null +}): React.JSX.Element { + return ( +
+
+
+ 0 && counts.failing === 0 && 'animate-spin' + )} + /> +
+
+ {translate('auto.components.GitHubItemDialog.4bd1f5b055', 'Checks')} +
+ {listLength > 0 && ( +
+ {summaryLabel} +
+ )} +
+
+
+ {refreshAction} + {listLength > 0 && ( +
+ {rerunAction} +
+ )} +
+
+ {secondaryActions ? ( +
{secondaryActions}
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-request-details.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-request-details.ts new file mode 100644 index 00000000000..b5ac57e1fb1 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-request-details.ts @@ -0,0 +1,81 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' +import { + beginGitHubChecksTabDetails, + settleGitHubChecksTabDetails, + type CheckDetailsLoadState +} from '@/components/github-checks-tab-state' +import { getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context' +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import { translate } from '@/i18n/i18n' +import type { ChecksTabActionContext } from './checks-tab-actions' + +export function requestGitHubCheckDetails( + ctx: ChecksTabActionContext, + check: PRCheckDetail, + key: string +): void { + if (!ctx.canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { + return + } + const requestId = ++ctx.nextCheckDetailsRequestIdRef.current + const commit = (next: Omit): void => { + if (!ctx.mountedRef.current) { + return + } + ctx.setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) + } + ctx.setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) + const detailsRequest = withGitHubCheckDetailsTimeout((signal) => + ctx.runtimeHost + ? callRuntimeRpc>>( + { kind: 'environment', environmentId: ctx.runtimeHost.environmentId }, + 'github.prCheckDetails', + { + repo: getGitHubRuntimeRepoId(ctx.sourceContext, ctx.repoId ?? ctx.itemRepoId), + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo: ctx.prRepo + }, + { timeoutMs: 30_000, signal } + ) + : window.api.gh.prCheckDetails({ + repoPath: ctx.repoPath ?? '', + repoId: ctx.repoId ?? undefined, + sourceContext: ctx.sourceContext, + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo: ctx.prRepo + }) + ) + void detailsRequest + .then((details) => { + commit({ + loading: false, + details, + error: details + ? null + : translate( + 'auto.components.GitHubItemDialog.e15a8b77ef', + 'No inline details are available for this check.' + ) + }) + }) + .catch((err) => { + commit({ + loading: false, + details: null, + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.GitHubItemDialog.e45324fbed', + 'Failed to load check details.' + ) + }) + }) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-summary.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-summary.ts new file mode 100644 index 00000000000..95fff5f7fe3 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-summary.ts @@ -0,0 +1,32 @@ +import { CircleDashed } from 'lucide-react' +import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' +import type { getCheckCounts } from '@/components/pr-check-counts' + +export function getChecksTabSummaryPresentation(counts: ReturnType): { + SummaryIcon: (typeof CHECK_ICON)[keyof typeof CHECK_ICON] | typeof CircleDashed + summaryColor: string +} { + // Why: keying the green tick off `list.length` painted an all-neutral PR green above the words + // "0 of N checks passing"; nothing passed, so it reads unresolved like the checks pill does. + const SummaryIcon = + counts.failing > 0 + ? CHECK_ICON.failure + : counts.needsAction > 0 + ? CHECK_ICON.action_required + : counts.pending > 0 + ? CHECK_ICON.pending + : counts.passing > 0 + ? CHECK_ICON.success + : CircleDashed + const summaryColor = + counts.failing > 0 + ? CHECK_COLOR.failure + : counts.needsAction > 0 + ? CHECK_COLOR.action_required + : counts.pending > 0 + ? CHECK_COLOR.pending + : counts.passing > 0 + ? CHECK_COLOR.success + : 'text-muted-foreground' + return { SummaryIcon, summaryColor } +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx new file mode 100644 index 00000000000..56cede20729 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx @@ -0,0 +1,400 @@ +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { CircleDashed, LoaderCircle } from 'lucide-react' +import { useMountedRef } from '@/hooks/useMountedRef' +import { cn } from '@/lib/utils' +import { CHECK_COLOR } from '@/components/right-sidebar/checks-panel-content' +import { + createGitHubChecksTabState, + resolveGitHubChecksTabState, + toggleGitHubChecksTabExpandedKey +} from '@/components/github-checks-tab-state' +import { + canUseGitHubRepoContext, + getGitHubSourceRuntimeHost +} from '@/lib/github-source-runtime-context' +import { getBrokenChecks } from '@/components/pr-checks-fix-prompt' +import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import type { + GitHubWorkItem, + GitHubWorkItemDetails +} from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import { sortChecksBySeverity } from '../../../../../shared/pr-check-severity-order' +import { + getCheckCountChips, + getCheckCounts, + getChecksSummaryLabel +} from '@/components/pr-check-counts' +import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' +import { getCheckDetailsKey } from '@/components/github/pr-check-presentation' +import { getChecksTabSummaryPresentation } from './checks-tab-summary' +import { renderCheckRow } from './checks-tab-check-row' +import { + fixBrokenGitHubChecks, + refreshGitHubChecksTab, + rerunGitHubChecksTab +} from './checks-tab-actions' +import { requestGitHubCheckDetails } from './checks-tab-request-details' +import { ChecksTabActions, ChecksTabCompactHeader } from './checks-tab-header' + +export function ChecksTab({ + item, + repoPath, + repoId, + sourceContext, + headSha, + checks, + loading, + variant = 'compact', + onChecksUpdated +}: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + sourceContext?: TaskSourceContext | null + headSha: string | undefined + checks: GitHubWorkItemDetails['checks'] + loading: boolean + variant?: 'compact' | 'page' + onChecksUpdated: (checks: PRCheckDetail[]) => void +}): React.JSX.Element { + const [fixingChecks, setFixingChecks] = useState(false) + const mountedRef = useMountedRef() + const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) + const nextCheckDetailsRequestIdRef = useRef(0) + const checkDetailsContextKey = [ + sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', + repoId ?? item.repoId ?? '', + repoPath ?? '', + prRepo ? githubRepoIdentityKey(prRepo) : '', + item.id, + item.number, + headSha ?? '' + ].join('\0') + const [checksState, setChecksState] = useState(() => + createGitHubChecksTabState(checks, checkDetailsContextKey) + ) + const resolvedChecksState = resolveGitHubChecksTabState( + checksState, + checks, + checkDetailsContextKey + ) + const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) + const nextChecksRefreshRequestIdRef = useRef(0) + const activeChecksRefreshRequestIdRef = useRef(null) + const [refreshingOwner, setRefreshingOwner] = useState<{ + contextOwner: object + requestId: number + } | null>(null) + const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner + const [rerunningOwner, setRerunningOwner] = useState(null) + const rerunning = rerunningOwner === resolvedChecksState.contextOwner + useLayoutEffect(() => { + committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner + }, [resolvedChecksState.contextOwner]) + if (resolvedChecksState !== checksState) { + // Why: a parent check refresh replaces the source list; reset local state before stale rows/details can paint. + setChecksState(resolvedChecksState) + } + const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState + const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) + const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) + const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) + const sorted = sortChecksBySeverity(list) + const failedChecks = getBrokenChecks(list) + const counts = getCheckCounts(list) + const summaryLabel = getChecksSummaryLabel(list) + const { SummaryIcon, summaryColor } = getChecksTabSummaryPresentation(counts) + const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) + + const handleRefresh = useCallback( + async (expectedContextOwner?: object): Promise => + refreshGitHubChecksTab( + { + canUseChecksRepoContext, + runtimeHost, + sourceContext, + repoId, + repoPath, + itemNumber: item.number, + itemRepoId: item.repoId, + headSha, + prRepo, + mountedRef, + committedChecksContextOwnerRef, + nextChecksRefreshRequestIdRef, + activeChecksRefreshRequestIdRef, + nextCheckDetailsRequestIdRef, + setChecksState, + setRefreshingOwner, + setRerunningOwner, + onChecksUpdated + }, + expectedContextOwner + ), + [ + canUseChecksRepoContext, + headSha, + item.number, + item.repoId, + mountedRef, + onChecksUpdated, + runtimeHost, + prRepo, + repoId, + repoPath, + sourceContext + ] + ) + + const handleRerun = useCallback( + async (failedOnly: boolean): Promise => + rerunGitHubChecksTab( + { + canUseChecksRepoContext, + runtimeHost, + sourceContext, + repoId, + repoPath, + itemNumber: item.number, + itemRepoId: item.repoId, + headSha, + prRepo, + mountedRef, + committedChecksContextOwnerRef, + nextChecksRefreshRequestIdRef, + activeChecksRefreshRequestIdRef, + nextCheckDetailsRequestIdRef, + setChecksState, + setRefreshingOwner, + setRerunningOwner, + onChecksUpdated + }, + failedOnly, + rerunning + ), + [ + canUseChecksRepoContext, + headSha, + item.number, + item.repoId, + mountedRef, + onChecksUpdated, + prRepo, + runtimeHost, + rerunning, + repoId, + repoPath, + sourceContext + ] + ) + + const handleFixBrokenChecks = useCallback(async (): Promise => { + await fixBrokenGitHubChecks({ + item, + repoId, + fixingChecks, + failedChecksLength: failedChecks.length, + list, + setFixingChecks + }) + }, [failedChecks.length, fixingChecks, item, list, repoId]) + + const requestCheckDetails = useCallback( + (check: PRCheckDetail, key: string): void => { + requestGitHubCheckDetails( + { + canUseChecksRepoContext, + runtimeHost, + sourceContext, + repoId, + repoPath, + itemNumber: item.number, + itemRepoId: item.repoId, + headSha, + prRepo, + mountedRef, + committedChecksContextOwnerRef, + nextChecksRefreshRequestIdRef, + activeChecksRefreshRequestIdRef, + nextCheckDetailsRequestIdRef, + setChecksState, + setRefreshingOwner, + setRerunningOwner, + onChecksUpdated + }, + check, + key + ) + }, + [ + canUseChecksRepoContext, + headSha, + item.number, + item.repoId, + mountedRef, + onChecksUpdated, + runtimeHost, + prRepo, + repoId, + repoPath, + sourceContext + ] + ) + + const handleToggleCheckDetails = useCallback( + (check: PRCheckDetail): void => { + const key = getCheckDetailsKey(check) + setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) + if (detailsByCheckKey[key]) { + return + } + requestCheckDetails(check, key) + }, + [detailsByCheckKey, requestCheckDetails] + ) + + const { refreshAction, rerunAction, secondaryActions, actions } = ChecksTabActions({ + variant, + canUseChecksRepoContext, + refreshing, + rerunning, + fixingChecks, + canFixBrokenChecks, + failedChecksLength: failedChecks.length, + listLength: list.length, + onRefresh: () => { + void handleRefresh() + }, + onRerun: (failedOnly) => { + void handleRerun(failedOnly) + }, + onFix: () => { + void handleFixBrokenChecks() + } + }) + const compactHeader = ( + + ) + + if (loading && list.length === 0) { + return ( + <> + {variant === 'compact' ? compactHeader : null} +
+ +
+ + ) + } + if (list.length === 0) { + if (variant === 'page') { + return ( +
+
+ +
+ + {translate('auto.components.GitHubItemDialog.ecffebc251', 'No checks found')} + + + {translate( + 'auto.components.GitHubItemDialog.90020cc1f3', + 'This pull request has no reported checks yet.' + )} + +
+ {actions} +
+
+ ) + } + return ( + <> + {compactHeader} +
+ +
+ {translate('auto.components.GitHubItemDialog.e52bed9264', 'No checks reported yet')} +
+
+ + ) + } + if (variant === 'page') { + const countChips = getCheckCountChips(counts) + return ( +
+
+ 0 && counts.failing === 0 && 'animate-spin' + )} + /> +
+ {summaryLabel} + {countChips.length > 1 && ( + + {countChips.map((chip, i) => ( + + {i > 0 && ·} + {chip.label} + + ))} + + )} +
+ {actions} +
+
+ {sorted.map((check, index) => ( +
0 && 'border-t border-border/40')} + > + {renderCheckRow({ + check, + variant, + expandedCheckKey, + detailsByCheckKey, + onToggle: handleToggleCheckDetails, + requestCheckDetails + })} +
+ ))} +
+
+ ) + } + return ( + <> + {compactHeader} +
+ {sorted.map((check) => + renderCheckRow({ + check, + variant, + expandedCheckKey, + detailsByCheckKey, + onToggle: handleToggleCheckDetails, + requestCheckDetails + }) + )} +
+ + ) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx new file mode 100644 index 00000000000..b706ba073bf --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx @@ -0,0 +1,169 @@ +import React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import type { editor as monacoEditor } from 'monaco-editor' +import { DiffSectionItem } from '@/components/editor/DiffSectionItem' +import { translate } from '@/i18n/i18n' +import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' +import { CombinedDiffFileTree } from '@/components/editor/CombinedDiffFileTree' +import type { DiffSection } from '@/components/editor/diff-section-types' +import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model' +import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types' +import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types' +import type { DiffSectionItemProps } from '@/components/editor/diff-section-item-props' +import { PRFilesCombinedDiffToolbar } from './pr-files-combined-diff-toolbar' + +export function PRFilesCombinedDiffBody({ + files, + repoPath, + repoId, + prNumber, + fileTreeCollapsed, + allSectionsCollapsed, + sideBySide, + setFileTreeCollapsed, + setAllSectionsCollapsed, + setSideBySide, + entries, + sectionIndexByKey, + activeTreeSectionKey, + viewedSectionKeys, + handleTreeNavigate, + scrollContainerRef, + virtualizer, + sections, + isDark, + settings, + sectionHeights, + inlineReviewComments, + loadSection, + retrySection, + toggleSection, + openFilesOnGitHub, + renderViewedCheckbox, + handleAddLineComment, + fileByPath, + setSectionHeights, + setSections, + modifiedEditorsRef, + handleSectionSaveRef +}: { + files: GitHubPRFile[] + repoPath: string + repoId: string + prNumber: number + fileTreeCollapsed: boolean + allSectionsCollapsed: boolean + sideBySide: boolean + setFileTreeCollapsed: (next: boolean) => void + setAllSectionsCollapsed: (next: boolean) => void + setSideBySide: React.Dispatch> + entries: GitBranchChangeEntry[] + sectionIndexByKey: Map + activeTreeSectionKey: string | null + viewedSectionKeys: Set + handleTreeNavigate: (entry: CombinedDiffFileTreeEntry) => void + scrollContainerRef: React.RefObject + virtualizer: Virtualizer + sections: DiffSection[] + isDark: boolean + settings: DiffSectionItemProps['settings'] + sectionHeights: Record + inlineReviewComments: DecoratedDiffComment[] + loadSection: (index: number) => void + retrySection: (index: number) => void + toggleSection: (index: number) => void + openFilesOnGitHub: () => void + renderViewedCheckbox: (section: DiffSection) => React.ReactNode + handleAddLineComment: ( + section: DiffSection, + args: { lineNumber: number; startLine?: number; body: string } + ) => Promise + fileByPath: Map + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> + modifiedEditorsRef: React.RefObject> + handleSectionSaveRef: React.MutableRefObject<(index: number) => Promise> +}): React.JSX.Element { + return ( +
+ setFileTreeCollapsed(false)} + onToggleAllCollapsed={() => setAllSectionsCollapsed(!allSectionsCollapsed)} + onToggleSideBySide={() => setSideBySide((prev) => !prev)} + /> +
+ +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const section = sections[virtualItem.index] + if (!section) { + return null + } + return ( +
+ + fileByPath.get(section.path)?.reviewCommentLineNumbers + } + setSectionHeights={setSectionHeights} + setSections={setSections} + modifiedEditorsRef={modifiedEditorsRef} + handleSectionSaveRef={handleSectionSaveRef} + /> +
+ ) + })} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-load.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-load.ts new file mode 100644 index 00000000000..5b7aaf96e48 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-load.ts @@ -0,0 +1,288 @@ +import type React from 'react' +import { toast } from 'sonner' +import type { DiffSection } from '@/components/editor/diff-section-types' +import { addPRReviewCommentForRepo } from '@/components/github/github-work-item-comment-mutations' +import type { PRComment } from '../../../../../shared/github/comment-types' +import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' +import { + getStoredTextDiffContent, + getStoredTextDiffResult +} from '@/components/editor/large-diff-section-content' +import { + getPRFileContentsRenderLimit, + getPRFileDiffResult +} from '@/components/github/pr-file-diff-mapping' +import type { GitDiffResult } from '../../../../../shared/git-diff-compare-types' +import type { + GitHubOwnerRepo, + GitHubPRFile, + GitHubPRFileContents +} from '../../../../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import { loadPRFileContents } from '../load-item-details/pr-file-content-cache' + +type LoadSectionArgs = { + index: number + sectionsRef: { current: DiffSection[] } + loadedIndicesRef: { current: Set } + loadingIndicesRef: { current: Set } + fileByPath: Map + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + headSha: string | undefined + baseSha: string | undefined + setSections: React.Dispatch> +} + +export function loadPRFilesCombinedDiffSection({ + index, + sectionsRef, + loadedIndicesRef, + loadingIndicesRef, + fileByPath, + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + headSha, + baseSha, + setSections +}: LoadSectionArgs): void { + const section = sectionsRef.current[index] + if (!section || section.collapsed) { + return + } + if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { + return + } + const file = fileByPath.get(section.path) + if (!file) { + return + } + loadingIndicesRef.current.add(index) + + const load = async (): Promise<{ + result: GitDiffResult + resultContents?: GitHubPRFileContents + error?: string + }> => { + if (file.isBinary) { + return { + result: { + kind: 'binary', + originalContent: '', + modifiedContent: '', + originalIsBinary: true, + modifiedIsBinary: true + } + } + } + if (!headSha || !baseSha) { + return { + result: { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + }, + error: translate( + 'auto.components.GitHubItemDialog.829674460a', + 'Diff unavailable because the PR commit SHAs are missing.' + ) + } + } + const contents = await loadPRFileContents({ + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + file, + headSha, + baseSha + }) + return { result: getPRFileDiffResult(contents), resultContents: contents } + } + + load() + .catch((error) => ({ + result: { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } as GitDiffResult, + resultContents: undefined, + error: + error instanceof Error + ? error.message + : translate('auto.components.GitHubItemDialog.d9fa90b625', 'Failed to load diff.') + })) + .then(({ result, resultContents, error }) => { + loadingIndicesRef.current.delete(index) + const largeDiffRenderLimit = + !error && result.kind === 'text' && resultContents + ? getPRFileContentsRenderLimit(resultContents) + : null + const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) + const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) + loadedIndicesRef.current.add(index) + setSections((prev) => + prev.map((current, currentIndex) => + currentIndex === index + ? { + ...current, + diffResult: storedResult, + originalContent: storedContent.originalContent, + modifiedContent: storedContent.modifiedContent, + loading: false, + error, + largeDiffRenderLimit + } + : current + ) + ) + }) +} + +export function retryPRFilesCombinedDiffSection({ + index, + loadedIndicesRef, + loadingIndicesRef, + setSectionHeights, + setSections, + loadSection +}: { + index: number + loadedIndicesRef: { current: Set } + loadingIndicesRef: { current: Set } + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> + loadSection: (index: number) => void +}): void { + loadedIndicesRef.current.delete(index) + loadingIndicesRef.current.delete(index) + setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index + ? { + ...section, + diffResult: null, + originalContent: '', + modifiedContent: '', + loading: true, + error: undefined, + largeDiffRenderLimit: null + } + : section + ) + ) + loadSection(index) +} + +export function togglePRFilesCombinedDiffSection({ + index, + sectionsRef, + setSections, + loadSection +}: { + index: number + sectionsRef: { current: DiffSection[] } + setSections: React.Dispatch> + loadSection: (index: number) => void +}): void { + const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section + ) + ) + if (shouldLoadAfterExpand) { + window.requestAnimationFrame(() => loadSection(index)) + } +} + +export function setAllPRFilesCombinedDiffSectionsCollapsed({ + collapsed, + setSections, + sectionsRef, + loadSection +}: { + collapsed: boolean + setSections: React.Dispatch> + sectionsRef: { current: DiffSection[] } + loadSection: (index: number) => void +}): void { + setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) + if (!collapsed) { + window.requestAnimationFrame(() => { + sectionsRef.current.forEach((_, index) => loadSection(index)) + }) + } +} + +export async function addPRFilesCombinedDiffLineComment({ + section, + lineNumber, + startLine, + body, + headSha, + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + onCommentAdded +}: { + section: DiffSection + lineNumber: number + startLine?: number + body: string + headSha: string | undefined + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + onCommentAdded: (comment: PRComment) => void +}): Promise { + if (!headSha) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.d1fa2cf888', + 'Unable to comment without the PR head SHA.' + ) + ) + return false + } + const result = await addPRReviewCommentForRepo({ + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + commitId: headSha, + path: section.path, + line: lineNumber, + startLine, + body + }) + if (!result.ok) { + toast.error( + result.error || + translate('auto.components.GitHubItemDialog.b0b09778c8', 'Failed to add review comment.') + ) + return false + } + onCommentAdded(result.comment) + toast.success(translate('auto.components.GitHubItemDialog.a341343303', 'Review comment added.')) + return true +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-signature.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-signature.ts new file mode 100644 index 00000000000..602e54b0f16 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-signature.ts @@ -0,0 +1,35 @@ +import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types' + +/** Identifies one diff set: the PR revision plus the file shapes rendered for it. */ +export function getPRFilesCombinedDiffSignature({ + files, + repoId, + prNumber, + prRepo, + headSha, + baseSha +}: { + files: GitHubPRFile[] + repoId: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + headSha: string | undefined + baseSha: string | undefined +}): string { + return JSON.stringify({ + repoId, + prNumber, + prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, + headSha: headSha ?? null, + baseSha: baseSha ?? null, + files: files.map((file) => ({ + path: file.path, + oldPath: file.oldPath ?? null, + status: file.status, + additions: file.additions, + deletions: file.deletions, + isBinary: file.isBinary + })) + }) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-toolbar.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-toolbar.tsx new file mode 100644 index 00000000000..dbb0b8103c4 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-toolbar.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { PanelLeftOpen } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { isPRFileViewed } from '@/components/github/pr-file-content-size' +import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types' + +export function PRFilesCombinedDiffToolbar({ + files, + fileTreeCollapsed, + allSectionsCollapsed, + sideBySide, + onShowFileTree, + onToggleAllCollapsed, + onToggleSideBySide +}: { + files: GitHubPRFile[] + fileTreeCollapsed: boolean + allSectionsCollapsed: boolean + sideBySide: boolean + onShowFileTree: () => void + onToggleAllCollapsed: () => void + onToggleSideBySide: () => void +}): React.JSX.Element { + return ( +
+
+ {fileTreeCollapsed && ( + + + + + + {translate('auto.components.GitHubItemDialog.1257d1435d', 'Show file tree')} + + + )} + + {files.filter(isPRFileViewed).length} / {files.length}{' '} + {translate('auto.components.GitHubItemDialog.f2d02cdf8c', 'files viewed')} + +
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx new file mode 100644 index 00000000000..f4add6132b6 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx @@ -0,0 +1,382 @@ +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { editor as monacoEditor } from 'monaco-editor' +import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' +import { + createCombinedDiffSectionIndexMap, + handleCombinedDiffFileTreeNavigation +} from '@/components/editor/CombinedDiffFileTree' +import { + getDiffSectionEstimatedHeight, + isIntrinsicHeightImageDiff +} from '@/components/editor/diff-section-layout' +import type { DiffSection } from '@/components/editor/diff-section-types' +import { + getCombinedDiffBranchEntriesInTreeOrder, + type CombinedDiffFileTreeEntry +} from '@/components/editor/combined-diff-file-tree-model' +import { useAppStore } from '@/store' +import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types' +import { isPRFileViewed } from '@/components/github/pr-file-content-size' +import { + PR_DIFF_OVERSCAN, + getPRFileSectionKey, + gitHubPRFileToBranchEntry, + type PRFilesCombinedDiffViewerProps +} from '@/components/github/pr-file-diff-mapping' +import { formatRelativeTime } from '@/components/github/work-item-state-presentation' +import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' +import { PRFilesCombinedDiffBody } from './pr-files-combined-diff-body' +import { getPRFilesCombinedDiffSignature } from './pr-files-combined-diff-signature' +import { + addPRFilesCombinedDiffLineComment, + loadPRFilesCombinedDiffSection, + retryPRFilesCombinedDiffSection, + setAllPRFilesCombinedDiffSectionsCollapsed, + togglePRFilesCombinedDiffSection +} from './pr-files-combined-diff-load' + +type PRFilesCombinedDiffSectionsProps = PRFilesCombinedDiffViewerProps & { + sideBySide: boolean + setSideBySide: React.Dispatch> + fileTreeCollapsed: boolean + setFileTreeCollapsed: (next: boolean) => void +} + +export function PRFilesCombinedDiffViewer( + props: PRFilesCombinedDiffViewerProps +): React.JSX.Element { + const { files, repoId, prNumber, prRepo, headSha, baseSha } = props + const signature = useMemo( + () => + getPRFilesCombinedDiffSignature({ + files, + repoId, + prNumber, + prRepo, + headSha, + baseSha + }), + [baseSha, files, headSha, prNumber, prRepo, repoId] + ) + // Why: view preferences outlive a diff-set swap, so they live above the keyed subtree. + const [sideBySide, setSideBySide] = useState(false) + const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false) + // Why: remounting on the signature retires the previous diff set's sections, loaded + // indices, and in-flight results in one step, so nothing can leak across the swap. + return ( + + ) +} + +function PRFilesCombinedDiffSections({ + files, + comments, + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + prUrl, + headSha, + baseSha, + pendingViewedPaths, + onCommentAdded, + onViewedChange, + sideBySide, + setSideBySide, + fileTreeCollapsed, + setFileTreeCollapsed +}: PRFilesCombinedDiffSectionsProps): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const isDark = + settings?.theme === 'dark' || + (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + // Why: this subtree is keyed by the diff signature, so its file set is fixed for the + // mount. Freezing it in state keeps a stable identity without caching through a ref. + const [entries] = useState(() => + getCombinedDiffBranchEntriesInTreeOrder('commit', files.map(gitHubPRFileToBranchEntry)) + ) + const [sections, setSections] = useState(() => + entries.map((entry) => ({ + key: getPRFileSectionKey(entry.path), + path: entry.path, + oldPath: entry.oldPath, + status: entry.status, + added: entry.added, + removed: entry.removed, + originalContent: '', + modifiedContent: '', + collapsed: false, + loading: true, + error: undefined, + dirty: false, + diffResult: null, + largeDiffRenderLimit: null + })) + ) + const fileByPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files]) + const inlineReviewComments = useMemo( + () => + comments.flatMap((comment): DecoratedDiffComment[] => { + // Why: outdated threads' line number can attach the comment to unrelated current code, so skip them inline. + if (comment.isOutdated || !comment.path || typeof comment.line !== 'number') { + return [] + } + const createdAtMs = new Date(comment.createdAt).getTime() + return [ + { + id: `github-pr-comment:${comment.id}`, + worktreeId: `github-pr:${repoId}:${prNumber}`, + filePath: comment.path, + source: 'diff', + startLine: comment.startLine, + lineNumber: comment.line, + body: comment.body, + createdAt: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(), + side: 'modified', + author: comment.author, + authorAvatarUrl: comment.authorAvatarUrl, + createdAtLabel: formatRelativeTime(comment.createdAt), + url: comment.url, + canDelete: false, + canEdit: false + } + ] + }), + [comments, prNumber, repoId] + ) + const [sectionHeights, setSectionHeights] = useState>({}) + const [activeTreeSectionKey, setActiveTreeSectionKey] = useState(null) + const scrollContainerRef = useRef(null) + const loadedIndicesRef = useRef>(new Set()) + const loadingIndicesRef = useRef>(new Set()) + const sectionsRef = useRef(sections) + const modifiedEditorsRef = useRef>(new Map()) + const handleSectionSaveRef = useRef<(index: number) => Promise>(async () => {}) + + // Why: commit-phase write (a render React abandons would leak one), and it must be a layout + // effect — collapsing rekeys a section's virtual item, so the remounted DiffSectionItem's + // passive effect calls loadSection before any passive effect here could sync this ref. + useLayoutEffect(() => { + sectionsRef.current = sections + }, [sections]) + + const loadSection = useCallback( + (index: number) => { + loadPRFilesCombinedDiffSection({ + index, + sectionsRef, + loadedIndicesRef, + loadingIndicesRef, + fileByPath, + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + headSha, + baseSha, + setSections + }) + }, + [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] + ) + + const retrySection = useCallback( + (index: number) => { + retryPRFilesCombinedDiffSection({ + index, + loadedIndicesRef, + loadingIndicesRef, + setSectionHeights, + setSections, + loadSection + }) + }, + [loadSection] + ) + + const toggleSection = useCallback( + (index: number) => { + togglePRFilesCombinedDiffSection({ + index, + sectionsRef, + setSections, + loadSection + }) + }, + [loadSection] + ) + + const setAllSectionsCollapsed = useCallback( + (collapsed: boolean) => { + setAllPRFilesCombinedDiffSectionsCollapsed({ + collapsed, + setSections, + sectionsRef, + loadSection + }) + }, + [loadSection] + ) + + const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) + const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) + const viewedSectionKeys = useMemo( + () => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))), + [files] + ) + + const virtualizer = useVirtualizer({ + count: sections.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: (index) => { + const section = sections[index] + if (!section) { + return 88 + } + return getDiffSectionEstimatedHeight({ + collapsed: section.collapsed, + measuredContentHeight: sectionHeights[index], + originalContent: section.originalContent, + modifiedContent: section.modifiedContent, + changedLineCount: + section.added === undefined && section.removed === undefined + ? undefined + : (section.added ?? 0) + (section.removed ?? 0), + useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult), + isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true, + lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined + }) + }, + overscan: PR_DIFF_OVERSCAN, + getItemKey: (index) => { + const section = sections[index] + return section ? `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}` : `${index}` + } + }) + + useLayoutEffect(() => { + virtualizer.measure() + }, [sideBySide, virtualizer]) + + const handleTreeNavigate = useCallback( + (entry: CombinedDiffFileTreeEntry) => { + const navigatedIndex = handleCombinedDiffFileTreeNavigation({ + mode: 'commit', + entry, + sections: sectionsRef.current, + sectionIndexByKey, + toggleSection, + scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' }) + }) + if (navigatedIndex !== null) { + setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null) + } + }, + [sectionIndexByKey, toggleSection, virtualizer] + ) + + const openFilesOnGitHub = useCallback(() => { + void window.api.shell.openUrl(`${prUrl.replace(/\/$/, '')}/files`) + }, [prUrl]) + + const handleAddLineComment = useCallback( + async ( + section: DiffSection, + { + lineNumber, + startLine, + body + }: { + lineNumber: number + startLine?: number + body: string + } + ) => + addPRFilesCombinedDiffLineComment({ + section, + lineNumber, + startLine, + body, + headSha, + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + onCommentAdded + }), + [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] + ) + + const renderViewedCheckbox = useCallback( + (section: DiffSection) => { + const file = fileByPath.get(section.path) + if (!file) { + return null + } + const viewed = isPRFileViewed(file) + const pending = pendingViewedPaths.has(file.path) + return ( + { + if (!pending) { + void onViewedChange(file.path, !viewed) + } + }} + /> + ) + }, + [fileByPath, onViewedChange, pendingViewedPaths] + ) + + return ( + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-merge-menu.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-merge-menu.tsx new file mode 100644 index 00000000000..110a3d44d8a --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-merge-menu.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { ChevronDown, ExternalLink, GitMerge, LoaderCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import type { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' +import type { resolveGitHubPRMergeMethods } from '../../../../../shared/github/pull-request-merge-methods' +import type { GitHubPRMergeMethod } from '../../../../../shared/github/pull-request-types' +import { translate } from '@/i18n/i18n' + +export function PRActionsMergeMenu({ + itemUrl, + mergePending, + mergeDisabled, + canMergeWithRepoContext, + mergePresentation, + mergeMethods, + onMerge, + onAutoMerge +}: { + itemUrl: string + mergePending: boolean + mergeDisabled: boolean + canMergeWithRepoContext: boolean + mergePresentation: ReturnType + mergeMethods: ReturnType + onMerge: (method: GitHubPRMergeMethod) => void + onAutoMerge: () => void +}): React.JSX.Element { + return ( + + + + + + + + + {!canMergeWithRepoContext + ? translate( + 'auto.components.GitHubItemDialog.5932578f51', + 'Merge requires a registered local repo' + ) + : mergePresentation.tooltip} + + + + {mergePresentation.autoMergeAction && ( + onAutoMerge()} + > + + {mergePresentation.autoMergeAction.label} + + )} + {mergePresentation.autoMergeAction && } + {mergeMethods.methods.map(({ method, label }) => ( + onMerge(method)}> + + {label} + + ))} + window.api.shell.openUrl(itemUrl)}> + + {translate('auto.components.GitHubItemDialog.53fe19aefc', 'Open GitHub merge box')} + + + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx new file mode 100644 index 00000000000..487411e75e8 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx @@ -0,0 +1,380 @@ +import React, { useCallback, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { CircleDot, GitPullRequest, GitPullRequestClosed, LoaderCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + getGitHubMutationRoutingSettings, + getGitHubRuntimeRepoId +} from '@/lib/github-source-runtime-context' +import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' +import { + GITHUB_PR_MERGE_METHOD_LABELS, + resolveGitHubPRMergeMethods +} from '../../../../../shared/github/pull-request-merge-methods' +import type { GitHubPRMergeMethod } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' +import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' +import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations' +import { runPullRequestStateUpdate } from '@/components/github/github-work-item-edit-mutations' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { PRActionsMergeMenu } from './pr-actions-merge-menu' +import { WorkItemStateBadge } from '../load-item-details/work-item-state-badge' + +export function PRActionsPanel({ + item, + repoPath, + repoId, + sourceContext, + projectOrigin, + localState, + onStateChange, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + localState: GitHubWorkItem['state'] + onStateChange: (state: GitHubWorkItem['state']) => void + onMutated: () => void +}): React.JSX.Element { + const [statePending, setStatePending] = useState(false) + const [mergePending, setMergePending] = useState(false) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const confirm = useConfirmationDialog() + const actionItem = { ...item, state: localState } + const mergePresentation = presentGitHubPRMergeState(actionItem) + const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) + const sourceSettings = useAppStore( + useShallow((s) => + getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) + ) + ) + const mergeTarget = getActiveRuntimeTarget(sourceSettings) + const prRepo = resolvePullRequestRepo(item, projectOrigin) + const canMutateWithRepoContext = + !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' + const canMutateState = localState !== 'merged' && canMutateWithRepoContext + const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' + const canMergeWithRepoContext = !!repoPath || mergeTarget.kind === 'environment' + const mergeDisabled = + !canMergeWithRepoContext || mergePending || !mergePresentation.directMergeAvailable + + const patchProjectRowIfNeeded = useCallback( + (state: GitHubWorkItem['state']) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) + }, + [patchProjectRowContent, projectOrigin] + ) + + const applyStatePatch = useCallback( + (state: GitHubWorkItem['state']) => { + onStateChange(state) + patchWorkItem(item.id, { state }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(state) + }, + [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext] + ) + + const handleStateChange = async (): Promise => { + if (!canMutateState || statePending) { + return + } + const label = + nextState === 'closed' + ? translate('auto.components.GitHubItemDialog.4aecf121e7', 'Close') + : translate('auto.components.GitHubItemDialog.8812225174', 'Reopen') + const confirmed = await confirm({ + title: translate( + 'auto.components.GitHubItemDialog.03d7216d62', + '{{value0}} PR #{{value1}}?', + { value0: label, value1: item.number } + ), + description: + nextState === 'closed' + ? translate( + 'auto.components.GitHubItemDialog.de45fedf7b', + 'This will close the pull request on GitHub.' + ) + : translate( + 'auto.components.GitHubItemDialog.b6f1b7adbd', + 'This will reopen the pull request on GitHub.' + ), + confirmLabel: label, + confirmVariant: nextState === 'closed' ? 'destructive' : 'default' + }) + if (!confirmed) { + return + } + const previousState = localState + setStatePending(true) + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + const authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: nextState, + sourceContext + }) + applyStatePatch(nextState) + try { + await runPullRequestStateUpdate({ + repoPath, + repoId, + sourceContext, + projectOrigin, + number: item.number, + prRepo, + updates: { state: nextState } + }) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + nextState === 'closed' + ? translate('auto.components.GitHubItemDialog.9f88657c4e', 'Pull request closed') + : translate('auto.components.GitHubItemDialog.bd3b4492a0', 'Pull request reopened') + ) + onMutated() + } catch (err) { + if (authority.revert()) { + applyStatePatch(previousState) + } + // Why: full sentences per branch — interpolating a lowercased label breaks locales with different casing rules. + toast.error( + err instanceof Error + ? err.message + : nextState === 'closed' + ? translate( + 'auto.components.GitHubItemDialog.09d67a0f9b', + 'Failed to close pull request' + ) + : translate( + 'auto.components.GitHubItemDialog.88809e79db', + 'Failed to reopen pull request' + ) + ) + } finally { + setStatePending(false) + } + } + + const handleMerge = async (method: GitHubPRMergeMethod): Promise => { + if (mergeDisabled) { + return + } + const label = GITHUB_PR_MERGE_METHOD_LABELS[method] + const confirmed = await confirm({ + title: translate( + 'auto.components.GitHubItemDialog.03d7216d62', + '{{value0}} PR #{{value1}}?', + { value0: label, value1: item.number } + ), + description: translate( + 'auto.components.GitHubItemDialog.a27ee5ca1a', + 'This will update the pull request on GitHub.' + ), + confirmLabel: label + }) + if (!confirmed) { + return + } + setMergePending(true) + try { + const result = + mergeTarget.kind === 'environment' + ? await callRuntimeRpc>>( + mergeTarget, + 'github.mergePR', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + prNumber: item.number, + method, + prRepo + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.mergePR({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, + prNumber: item.number, + method, + prRepo + }) + if (!result.ok) { + toast.error( + result.error || + translate('auto.components.GitHubItemDialog.aba792c8b3', 'Failed to merge pull request') + ) + return + } + // Why: merge is confirmed here; hold 'merged' against search-lagged refetches. + assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: 'merged', + sourceContext + }) + applyStatePatch('merged') + if (mergeTarget.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + type: 'pr', + number: item.number + }, + { local: false } + ) + } + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success(translate('auto.components.GitHubItemDialog.dbe5e2448e', 'Pull request merged')) + onMutated() + } catch { + toast.error( + translate('auto.components.GitHubItemDialog.aba792c8b3', 'Failed to merge pull request') + ) + } finally { + setMergePending(false) + } + } + + const handleAutoMerge = async (): Promise => { + if (!canMergeWithRepoContext || !mergePresentation.autoMergeAction) { + return + } + const enabled = mergePresentation.autoMergeAction.kind === 'enable' + setMergePending(true) + try { + const result = + mergeTarget.kind === 'environment' + ? await callRuntimeRpc>>( + mergeTarget, + 'github.setPRAutoMerge', + { + repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), + prNumber: item.number, + enabled, + method: enabled ? mergeMethods.defaultMethod : undefined, + prRepo + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRAutoMerge({ + repoPath: repoPath ?? '', + repoId: repoId ?? undefined, + sourceContext, + prNumber: item.number, + enabled, + method: enabled ? mergeMethods.defaultMethod : undefined, + prRepo + }) + if (!result.ok) { + toast.error( + result.error || + (enabled + ? translate( + 'auto.components.GitHubItemDialog.825a8fb8cd', + 'Failed to enable auto-merge' + ) + : translate( + 'auto.components.GitHubItemDialog.ce360fc318', + 'Failed to disable auto-merge' + )) + ) + return + } + if (mergeTarget.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + type: 'pr', + number: item.number + }, + { local: false } + ) + } + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + enabled + ? translate('auto.components.GitHubItemDialog.a35ea5a0f6', 'Auto-merge enabled') + : translate('auto.components.GitHubItemDialog.4b390bd50d', 'Auto-merge disabled') + ) + onMutated() + } catch { + toast.error( + enabled + ? translate('auto.components.GitHubItemDialog.825a8fb8cd', 'Failed to enable auto-merge') + : translate('auto.components.GitHubItemDialog.ce360fc318', 'Failed to disable auto-merge') + ) + } finally { + setMergePending(false) + } + } + + return ( + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx new file mode 100644 index 00000000000..4a6db255e19 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-panel.tsx @@ -0,0 +1,397 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { LoaderCircle, Pencil } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { useRepoAssignees } from '@/hooks/useIssueMetadata' +import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { getGitHubPRReviewerRows } from '@/components/github-pr-reviewer-display' +import { + filterGitHubPRReviewerCandidates, + getGitHubPRReviewerQueryState +} from '@/components/github/github-pr-reviewer-candidate-filter' +import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' +import { mergeReviewerSuggestions } from '@/components/github/work-item-state-presentation' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { PRReviewersPickerList } from './pr-reviewers-picker-row' +import { PRReviewersRequestedList } from './pr-reviewers-requested-list' +import { removePRReviewers, requestPRReviewers } from './pr-reviewers-request-actions' + +export function PRReviewersPanel({ + item, + loading, + repoPath, + sourceContext, + projectOrigin, + onReviewersRequested +}: { + item: GitHubWorkItem + loading: boolean + repoPath: string | null + sourceContext?: TaskSourceContext | null + projectOrigin?: GitHubItemDialogProjectOrigin + onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [reviewerInput, setReviewerInput] = useState('') + const [activeReviewerCursor, setActiveReviewerCursor] = useState({ + resetKey: '', + index: 0 + }) + const [submitting, setSubmitting] = useState(false) + const [localReviewRequests, setLocalReviewRequests] = useState( + () => item.reviewRequests ?? [] + ) + const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + reviewRequests: item.reviewRequests + })) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const reviewerInputRef = useRef(null) + const reviewerInputFocusFrameRef = useRef(null) + const reviewerPanelMountedRef = useRef(true) + + const cancelReviewerInputFocusFrame = useCallback((): void => { + if (reviewerInputFocusFrameRef.current !== null) { + cancelAnimationFrame(reviewerInputFocusFrameRef.current) + reviewerInputFocusFrameRef.current = null + } + }, []) + + const scheduleReviewerInputFocus = useCallback((): void => { + if (!reviewerPanelMountedRef.current) { + return + } + cancelReviewerInputFocusFrame() + reviewerInputFocusFrameRef.current = requestAnimationFrame(() => { + reviewerInputFocusFrameRef.current = null + reviewerInputRef.current?.focus() + }) + }, [cancelReviewerInputFocusFrame]) + + useEffect(() => { + reviewerPanelMountedRef.current = true + return () => { + reviewerPanelMountedRef.current = false + cancelReviewerInputFocusFrame() + } + }, [cancelReviewerInputFocusFrame]) + + // Why: clear stale optimistic reviewer requests on item switch/refetch before paint; a passive Effect would leave one stale render. + if ( + reviewRequestsSource.itemId !== item.id || + reviewRequestsSource.repoId !== item.repoId || + reviewRequestsSource.reviewRequests !== item.reviewRequests + ) { + setReviewRequestsSource({ + itemId: item.id, + repoId: item.repoId, + reviewRequests: item.reviewRequests + }) + setLocalReviewRequests(item.reviewRequests ?? []) + } + + const reviewerSeedUsers = useMemo(() => { + const byLogin = new Map() + const add = (user: GitHubAssignableUser): void => { + if (!user.login) { + return + } + byLogin.set(user.login.toLowerCase(), user) + } + for (const user of localReviewRequests) { + add(user) + } + for (const review of item.latestReviews ?? []) { + add({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? '' + }) + } + if (item.author) { + add({ login: item.author, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) + }, [item.author, item.latestReviews, localReviewRequests]) + + const reviewRepo = useMemo( + () => resolvePullRequestRepo(item, projectOrigin), + [item, projectOrigin] + ) + const reviewerMetadataBySlug = useRepoAssigneesBySlug( + open && reviewRepo ? reviewRepo.owner : null, + open && reviewRepo ? reviewRepo.repo : null, + reviewerSeedUsers.map((user) => user.login), + sourceSettings, + reviewRepo?.host + ) + const reviewerMetadataByPath = useRepoAssignees( + open && !reviewRepo ? repoPath : null, + open && !reviewRepo ? item.repoId : null, + sourceSettings + ) + const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath + const displayItem = { ...item, reviewRequests: localReviewRequests } + const reviewers = getGitHubPRReviewerRows(displayItem) + const authorLogin = item.author?.toLowerCase() ?? null + const reviewerCandidates = useMemo( + () => + mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( + (user) => user.login.toLowerCase() !== authorLogin + ), + [authorLogin, reviewerMetadata.data, reviewerSeedUsers] + ) + const reviewerCandidatesByLogin = useMemo( + () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), + [reviewerCandidates] + ) + const selectedReviewerLogins = useMemo( + () => + new Set( + localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) + ), + [localReviewRequests] + ) + const reviewerQueryState = useMemo( + () => getGitHubPRReviewerQueryState(reviewerInput), + [reviewerInput] + ) + const reviewerQuery = reviewerQueryState.query + const filteredReviewerCandidates = useMemo( + () => + filterGitHubPRReviewerCandidates({ + candidates: reviewerCandidates, + queryState: reviewerQueryState + }), + [reviewerCandidates, reviewerQueryState] + ) + const suggestedReviewerRows = useMemo( + () => + reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge + ? reviewerSeedUsers + .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) + .filter((user) => user.login.toLowerCase() !== authorLogin) + .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) + .slice(0, 1) + : [], + [ + authorLogin, + reviewerCandidatesByLogin, + reviewerQuery.length, + reviewerQueryState.isTooLarge, + reviewerSeedUsers, + selectedReviewerLogins + ] + ) + const everyoneElseReviewerRows = useMemo(() => { + const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) + return filteredReviewerCandidates.filter( + (user) => !suggestedLogins.has(user.login.toLowerCase()) + ) + }, [filteredReviewerCandidates, suggestedReviewerRows]) + const actionableReviewerRows = useMemo( + () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], + [everyoneElseReviewerRows, suggestedReviewerRows] + ) + + const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}` + if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) { + setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 }) + } + const activeReviewerIndex = + activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0 + const setActiveReviewerIndex = useCallback( + (nextIndex: number | ((current: number) => number)): void => { + setActiveReviewerCursor((current) => { + const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0 + return { + resetKey: reviewerCursorResetKey, + index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex + } + }) + }, + [reviewerCursorResetKey] + ) + + const hasReviewerMetadata = + item.reviewDecision !== undefined || + localReviewRequests.length > 0 || + item.reviewRequests !== undefined || + item.latestReviews !== undefined + const canRequestReview = + !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' + + const actionArgs = { + submitting, + selectedReviewerLogins, + localReviewRequests, + sourceSettings, + repoPath, + sourceContext, + item, + reviewRepo, + reviewerPanelMountedRef, + reviewerCandidates, + setSubmitting, + setLocalReviewRequests, + setReviewerInput, + patchWorkItem, + onReviewersRequested + } + + const handleRequestReview = async (requestedLogins?: string[]): Promise => { + await requestPRReviewers({ ...actionArgs, requestedLogins, reviewerInput }) + } + + const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { + await removePRReviewers({ ...actionArgs, reviewersToRemove }) + } + + const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { + await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) + ? handleRemoveReviewers([reviewer.login]) + : handleRequestReview([reviewer.login])) + scheduleReviewerInputFocus() + } + + const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + setOpen(nextOpen) + if (nextOpen) { + scheduleReviewerInputFocus() + return + } + setReviewerInput('') + } + + return ( +
+
+ {translate('auto.components.GitHubItemDialog.dc8a092c57', 'Reviewers')} + + + + + { + event.preventDefault() + }} + > +
+ setReviewerInput(event.target.value)} + disabled={submitting || !canRequestReview} + placeholder={translate( + 'auto.components.GitHubItemDialog.bb42774171', + 'Type or choose a user' + )} + aria-label={translate('auto.components.GitHubItemDialog.934add88b6', 'Reviewer')} + aria-expanded={open} + aria-haspopup="listbox" + className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs" + onKeyDown={(event) => { + if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) { + event.preventDefault() + setActiveReviewerIndex( + (current) => (current + 1) % actionableReviewerRows.length + ) + return + } + if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { + event.preventDefault() + setActiveReviewerIndex( + (current) => + (current - 1 + actionableReviewerRows.length) % + actionableReviewerRows.length + ) + return + } + if (event.key === 'Enter') { + event.preventDefault() + const activeReviewer = actionableReviewerRows[activeReviewerIndex] + if (activeReviewer) { + void requestReviewer(activeReviewer) + return + } + void handleRequestReview() + return + } + if (event.key === 'Escape') { + event.preventDefault() + handleReviewerPickerOpenChange(false) + } + }} + /> +
+ { + void requestReviewer(reviewer) + }} + /> +
+
+
+ { + void handleRemoveReviewers(reviewersToRemove) + }} + /> +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-picker-row.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-picker-row.tsx new file mode 100644 index 00000000000..3c42e27b373 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-picker-row.tsx @@ -0,0 +1,177 @@ +import React from 'react' +import { Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import { translate } from '@/i18n/i18n' + +export function PRReviewerPickerRow({ + reviewer, + suggested, + selected, + active, + onHover, + onFocusIndex, + onToggle +}: { + reviewer: GitHubAssignableUser + suggested: boolean + selected: boolean + active: boolean + onHover: () => void + onFocusIndex: () => void + onToggle: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function PRReviewersPickerList({ + loading, + error, + hasReviewerMetadata, + filteredReviewerCandidates, + suggestedReviewerRows, + everyoneElseReviewerRows, + selectedReviewerLogins, + activeReviewerIndex, + actionableReviewerRows, + onHover, + onFocus, + onSelect +}: { + loading: boolean + error?: string | null + hasReviewerMetadata: boolean + filteredReviewerCandidates: GitHubAssignableUser[] + suggestedReviewerRows: GitHubAssignableUser[] + everyoneElseReviewerRows: GitHubAssignableUser[] + selectedReviewerLogins: Set + activeReviewerIndex: number + actionableReviewerRows: GitHubAssignableUser[] + onHover: (index: number) => void + onFocus: (index: number) => void + onSelect: (reviewer: GitHubAssignableUser) => void +}): React.JSX.Element { + return ( +
+ {loading ? ( +
+ {translate('auto.components.GitHubItemDialog.a98433e73d', 'Loading...')} +
+ ) : filteredReviewerCandidates.length > 0 ? ( + <> + {suggestedReviewerRows.length > 0 ? ( + <> +
+ {translate('auto.components.GitHubItemDialog.c2b21818e1', 'Suggestions')} +
+ {suggestedReviewerRows.map((reviewer, index) => ( + onHover(index)} + onFocusIndex={() => onFocus(index)} + onToggle={() => onSelect(reviewer)} + /> + ))} + + ) : null} +
+ {translate('auto.components.GitHubItemDialog.1ffce94a8b', 'Everyone else')} +
+ {everyoneElseReviewerRows.length > 0 ? ( + everyoneElseReviewerRows.map((reviewer, index) => ( + onHover(suggestedReviewerRows.length + index)} + onFocusIndex={() => onFocus(suggestedReviewerRows.length + index)} + onToggle={() => onSelect(reviewer)} + /> + )) + ) : ( +
+ {translate('auto.components.GitHubItemDialog.70e84e3d0b', 'No matching reviewers.')} +
+ )} + + ) : ( +
+ {error ?? + (hasReviewerMetadata + ? translate('auto.components.GitHubItemDialog.70e84e3d0b', 'No matching reviewers.') + : translate( + 'auto.components.GitHubItemDialog.3f79ffc8b7', + 'Open the PR details to view current reviewers.' + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts new file mode 100644 index 00000000000..36263803876 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts @@ -0,0 +1,279 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getGitHubRuntimeRepoId } from '@/lib/github-source-runtime-context' +import { + normalizeGitHubReviewerLogins, + parseGitHubReviewerInputLogins +} from '@/components/github-pr-reviewer-display' +import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations' +import { buildRequestedReviewUsers } from '@/components/github/work-item-state-presentation' +import type { + GitHubAssignableUser, + GitHubOwnerRepo +} from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' + +type ReviewerRequestArgs = { + submitting: boolean + selectedReviewerLogins: Set + localReviewRequests: GitHubAssignableUser[] + sourceSettings: Pick + repoPath: string | null + sourceContext?: TaskSourceContext | null + item: GitHubWorkItem + reviewRepo: GitHubOwnerRepo | null + reviewerPanelMountedRef: { current: boolean } + reviewerCandidates: GitHubAssignableUser[] + setSubmitting: (value: boolean) => void + setLocalReviewRequests: (value: GitHubAssignableUser[]) => void + setReviewerInput: (value: string) => void + patchWorkItem: ( + id: string, + patch: { reviewRequests: GitHubAssignableUser[] }, + repoId: string | undefined, + options: { sourceContext?: TaskSourceContext | null } + ) => void + onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void +} + +export async function requestPRReviewers({ + submitting, + requestedLogins, + reviewerInput, + selectedReviewerLogins, + localReviewRequests, + sourceSettings, + repoPath, + sourceContext, + item, + reviewRepo, + reviewerPanelMountedRef, + reviewerCandidates, + setSubmitting, + setLocalReviewRequests, + setReviewerInput, + patchWorkItem, + onReviewersRequested +}: ReviewerRequestArgs & { + requestedLogins?: string[] + reviewerInput: string +}): Promise { + if (submitting) { + return + } + const logins = normalizeGitHubReviewerLogins( + requestedLogins ?? parseGitHubReviewerInputLogins(reviewerInput), + selectedReviewerLogins + ) + if (logins.length === 0) { + toast.error(translate('auto.components.GitHubItemDialog.94ab23a9f9', 'Enter a reviewer')) + return + } + if (localReviewRequests.length + logins.length > 15) { + toast.error( + translate('auto.components.GitHubItemDialog.12e761610e', 'You can request up to 15 reviewers') + ) + return + } + const target = getActiveRuntimeTarget(sourceSettings) + if (target.kind !== 'environment' && !repoPath) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.b4af16bf43', + 'No repo context available for this pull request.' + ) + ) + return + } + setSubmitting(true) + try { + const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.requestPRReviewers', + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.requestPRReviewers({ + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }) + if (!reviewerPanelMountedRef.current) { + return + } + if (!result.ok) { + toast.error( + result.error ?? + translate('auto.components.GitHubItemDialog.c42d942b75', 'Failed to request reviewer') + ) + return + } + const nextReviewRequests = buildRequestedReviewUsers( + logins, + reviewerCandidates, + localReviewRequests + ) + setLocalReviewRequests(nextReviewRequests) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) + onReviewersRequested(nextReviewRequests) + if (target.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + type: 'pr', + number: item.number + }, + { local: false } + ) + } + setReviewerInput('') + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + logins.length === 1 + ? translate('auto.components.GitHubItemDialog.ea985e657f', 'Reviewer requested') + : translate('auto.components.GitHubItemDialog.c016e4bac3', 'Reviewers requested') + ) + } catch { + if (reviewerPanelMountedRef.current) { + toast.error( + translate('auto.components.GitHubItemDialog.c42d942b75', 'Failed to request reviewer') + ) + } + } finally { + if (reviewerPanelMountedRef.current) { + setSubmitting(false) + } + } +} + +export async function removePRReviewers({ + submitting, + reviewersToRemove, + localReviewRequests, + sourceSettings, + repoPath, + sourceContext, + item, + reviewRepo, + reviewerPanelMountedRef, + setSubmitting, + setLocalReviewRequests, + setReviewerInput, + patchWorkItem, + onReviewersRequested +}: ReviewerRequestArgs & { + reviewersToRemove: string[] +}): Promise { + if (submitting) { + return + } + const selected = new Set(localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())) + const logins = reviewersToRemove + .map((reviewer) => reviewer.trim().replace(/^@/, '')) + .filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase())) + if (logins.length === 0) { + return + } + const target = getActiveRuntimeTarget(sourceSettings) + if (target.kind !== 'environment' && !repoPath) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.b4af16bf43', + 'No repo context available for this pull request.' + ) + ) + return + } + setSubmitting(true) + try { + const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) + const result = + target.kind === 'environment' + ? await callRuntimeRpc<{ ok: boolean; error?: string }>( + target, + 'github.removePRReviewers', + { + repo: runtimeRepo, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.removePRReviewers({ + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + prNumber: item.number, + reviewers: logins, + prRepo: reviewRepo + }) + if (!reviewerPanelMountedRef.current) { + return + } + if (!result.ok) { + toast.error( + result.error ?? + translate('auto.components.GitHubItemDialog.73487fb975', 'Failed to remove reviewer') + ) + return + } + const removed = new Set(logins.map((login) => login.toLowerCase())) + const nextReviewRequests = localReviewRequests.filter( + (reviewer) => !removed.has(reviewer.login.toLowerCase()) + ) + setLocalReviewRequests(nextReviewRequests) + patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { + sourceContext + }) + onReviewersRequested(nextReviewRequests) + if (target.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: repoPath ?? '', + repoId: item.repoId, + sourceContext, + type: 'pr', + number: item.number + }, + { local: false } + ) + } + setReviewerInput('') + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + logins.length === 1 + ? translate('auto.components.GitHubItemDialog.69515bff81', 'Reviewer removed') + : translate('auto.components.GitHubItemDialog.2e69540652', 'Reviewers removed') + ) + } catch { + if (reviewerPanelMountedRef.current) { + toast.error( + translate('auto.components.GitHubItemDialog.73487fb975', 'Failed to remove reviewer') + ) + } + } finally { + if (reviewerPanelMountedRef.current) { + setSubmitting(false) + } + } +} diff --git a/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-requested-list.tsx b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-requested-list.tsx new file mode 100644 index 00000000000..13f354a2d88 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/land-pull-request/pr-reviewers-requested-list.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { LoaderCircle, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ReviewerAvatar } from '@/components/github/work-item-state-presentation' +import type { GitHubPRReviewerRow } from '@/components/github-pr-reviewer-display' +import { translate } from '@/i18n/i18n' + +export function PRReviewersRequestedList({ + loading, + hasReviewerMetadata, + reviewers, + selectedReviewerLogins, + submitting, + canRequestReview, + onRemoveReviewers +}: { + loading: boolean + hasReviewerMetadata: boolean + reviewers: GitHubPRReviewerRow[] + selectedReviewerLogins: Set + submitting: boolean + canRequestReview: boolean + onRemoveReviewers: (reviewersToRemove: string[]) => void +}): React.JSX.Element { + if (loading && !hasReviewerMetadata) { + return ( +
+ + {translate('auto.components.GitHubItemDialog.6a45771d47', 'Loading reviewers')} +
+ ) + } + if (reviewers.length > 0) { + return ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
{reviewer.name}
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.GitHubItemDialog.5c1c973855', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) + } + return ( +
+ {translate('auto.components.GitHubItemDialog.36f9ac4a47', 'No reviewers requested.')} +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts new file mode 100644 index 00000000000..c0a6fc7a3b8 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts @@ -0,0 +1,28 @@ +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { + GitHubWorkItemProjectOrigin, + ItemDialogTab +} from '@/components/github/github-work-item-identity' + +/** Re-exported so Project-view callers keep a stable import path. */ +export type GitHubItemDialogProjectOrigin = GitHubWorkItemProjectOrigin + +export type GitHubItemDialogProps = { + workItem: GitHubWorkItem | null + repoPath: string | null + repoId?: string | null + sourceContext?: TaskSourceContext | null + initialTab?: ItemDialogTab + backLabel?: string + /** Called when the user clicks the primary CTA to start work from this item. */ + onUse: (item: GitHubWorkItem) => void + onReviewRequestsChange?: ( + itemKey: { id: string; repoId: string }, + reviewRequests: GitHubAssignableUser[] + ) => void + onClose: () => void + /** Optional Project-origin context; when set, edits route via slug-addressed IPCs against the row's repo (slug routing wins for writes). */ + projectOrigin?: GitHubItemDialogProjectOrigin +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/pr-file-content-cache.ts b/src/renderer/src/components/github-item-dialog/load-item-details/pr-file-content-cache.ts new file mode 100644 index 00000000000..c91502f20f1 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/pr-file-content-cache.ts @@ -0,0 +1,153 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getGitHubRuntimeRepoId, + getGitHubSourceRuntimeHost +} from '@/lib/github-source-runtime-context' +import { + PR_FILE_CONTENT_CACHE_MAX_BYTES, + getRetainedPRFileContentsByteCount +} from '@/components/github/pr-file-content-size' +import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' +import type { + GitHubOwnerRepo, + GitHubPRFile, + GitHubPRFileContents +} from '../../../../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context' + +// Why: bounded LRU so opening many PRs with many files doesn't grow this module-level map unboundedly until reload. +const PR_FILE_CONTENT_CACHE_MAX = 64 +type PRFileContentCacheEntry = { + value: Promise | GitHubPRFileContents + byteCount: number +} +const prFileContentCache = new Map() +let prFileContentCacheBytes = 0 + +function touchPRFileContentCache( + key: string, + value: Promise | GitHubPRFileContents +): void { + const retainedByteCount = value instanceof Promise ? 0 : getRetainedPRFileContentsByteCount(value) + if (retainedByteCount === null) { + const existing = prFileContentCache.get(key) + prFileContentCacheBytes -= existing?.byteCount ?? 0 + prFileContentCache.delete(key) + return + } + + const existing = prFileContentCache.get(key) + prFileContentCacheBytes -= existing?.byteCount ?? 0 + // Why: re-insert to move to MRU position; Map insertion order keeps the oldest key first for eviction. + prFileContentCache.delete(key) + const byteCount = retainedByteCount + prFileContentCache.set(key, { value, byteCount }) + prFileContentCacheBytes += byteCount + while ( + prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX || + prFileContentCacheBytes > PR_FILE_CONTENT_CACHE_MAX_BYTES + ) { + const oldest = prFileContentCache.keys().next().value + if (oldest === undefined) { + break + } + const evicted = prFileContentCache.get(oldest) + prFileContentCacheBytes -= evicted?.byteCount ?? 0 + prFileContentCache.delete(oldest) + } +} + +export function getPRFileContentCacheKey(args: { + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + file: GitHubPRFile + headSha: string + baseSha: string +}): string { + const repositoryKey = args.repoId ? `repo:${args.repoId}` : `path:${args.repoPath}` + const sourceKey = + args.sourceContext?.provider === 'github' + ? `source:${getTaskSourceCacheScope(args.sourceContext)}` + : 'source:local' + return [ + repositoryKey, + sourceKey, + args.prNumber, + args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', + args.file.path, + args.file.oldPath ?? '', + args.file.status, + args.headSha, + args.baseSha + ].join('\0') +} + +export function loadPRFileContents(args: { + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + file: GitHubPRFile + headSha: string + baseSha: string +}): Promise { + const cacheKey = getPRFileContentCacheKey(args) + const cached = prFileContentCache.get(cacheKey) + if (cached) { + touchPRFileContentCache(cacheKey, cached.value) + return Promise.resolve(cached.value) + } + let request: Promise + const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) + request = ( + runtimeHost + ? callRuntimeRpc( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.prFileContents', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + prNumber: args.prNumber, + prRepo: args.prRepo ?? null, + path: args.file.path, + oldPath: args.file.oldPath, + status: args.file.status, + headSha: args.headSha, + baseSha: args.baseSha + }, + { timeoutMs: 30_000 } + ) + : window.api.gh.prFileContents({ + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + prNumber: args.prNumber, + prRepo: args.prRepo ?? null, + path: args.file.path, + oldPath: args.file.oldPath, + status: args.file.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + ) + .then((contents) => { + if (prFileContentCache.get(cacheKey)?.value === request) { + touchPRFileContentCache(cacheKey, contents) + } + return contents + }) + .catch((err) => { + const cachedRequest = prFileContentCache.get(cacheKey) + if (cachedRequest?.value === request) { + prFileContentCacheBytes -= cachedRequest.byteCount + prFileContentCache.delete(cacheKey) + } + throw err + }) + touchPRFileContentCache(cacheKey, request) + return request +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/pr-file-viewed-change.ts b/src/renderer/src/components/github-item-dialog/load-item-details/pr-file-viewed-change.ts new file mode 100644 index 00000000000..b49877225f1 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/pr-file-viewed-change.ts @@ -0,0 +1,94 @@ +import { toast } from 'sonner' +import { setPRFileViewedForRepo } from '@/components/github/github-work-item-comment-mutations' +import { resolvePullRequestRepo } from '@/components/github/github-work-item-identity' +import { translate } from '@/i18n/i18n' +import type { GitHubPRFileViewedState } from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { GitHubItemDialogProjectOrigin } from './github-item-dialog-types' +import { patchCachedPRFileViewedState } from './work-item-details-cache' + +export async function syncPRFileViewedState(args: { + canUseDetailsRepoContext: boolean + pullRequestId: string | undefined + workItem: GitHubWorkItem | null + detailsCacheKey: string | null + repoPath: string | null + sourceContext?: TaskSourceContext | null + projectOrigin?: GitHubItemDialogProjectOrigin + path: string + viewed: boolean + setPendingViewedPaths: (updater: (prev: Set) => Set) => void +}): Promise { + const { + canUseDetailsRepoContext, + pullRequestId, + workItem, + detailsCacheKey, + repoPath, + sourceContext, + projectOrigin, + path, + viewed, + setPendingViewedPaths + } = args + if (!canUseDetailsRepoContext || !pullRequestId || !workItem || workItem.type !== 'pr') { + toast.error( + translate( + 'auto.components.GitHubItemDialog.c0253318d6', + 'Unable to sync viewed state for this pull request.' + ) + ) + return false + } + setPendingViewedPaths((prev) => new Set(prev).add(path)) + const nextState: GitHubPRFileViewedState = viewed ? 'VIEWED' : 'UNVIEWED' + const previousState = detailsCacheKey + ? patchCachedPRFileViewedState(detailsCacheKey, path, nextState) + : undefined + try { + const ok = await setPRFileViewedForRepo({ + repoId: workItem.repoId, + repoPath: repoPath ?? '', + sourceContext, + prNumber: workItem.number, + prRepo: resolvePullRequestRepo(workItem, projectOrigin), + pullRequestId, + path, + viewed + }) + if (!ok) { + if (detailsCacheKey && previousState) { + patchCachedPRFileViewedState(detailsCacheKey, path, previousState) + } + toast.error( + translate( + 'auto.components.GitHubItemDialog.b7bf31b8de', + 'Failed to sync viewed state with GitHub.' + ) + ) + return false + } + return true + } catch (err) { + // Why: an RPC timeout or IPC throw must roll the optimistic patch back, else the shared cache keeps claiming the file is viewed. + if (detailsCacheKey && previousState) { + patchCachedPRFileViewedState(detailsCacheKey, path, previousState) + } + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.GitHubItemDialog.b7bf31b8de', + 'Failed to sync viewed state with GitHub.' + ) + ) + return false + } finally { + setPendingViewedPaths((prev) => { + const next = new Set(prev) + next.delete(path) + return next + }) + } +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts new file mode 100644 index 00000000000..0d685da6020 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts @@ -0,0 +1,321 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' +import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context' +import { + normalizeItemDialogTab, + type ItemDialogTab +} from '@/components/github/github-work-item-identity' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { + GitHubWorkItem, + GitHubWorkItemDetails +} from '../../../../../shared/github/work-item-types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../../../shared/task-source-context' +import type { GitHubItemDialogProjectOrigin } from './github-item-dialog-types' +import { + WORK_ITEM_DETAILS_FRESH_MS, + getWorkItemDetailsCacheKey, + invalidateWorkItemDetailsCacheByMatch, + invalidateWorkItemDetailsCacheForKey, + subscribeWorkItemDetailsCache, + touchWorkItemDetailsCache, + workItemDetailsCache, + workItemDetailsCacheGeneration +} from './work-item-details-cache' +import { settleWorkItemDetailsFetch } from './work-item-details-fetch-settle' +import { syncPRFileViewedState } from './pr-file-viewed-change' + +export function useGitHubItemDialogDetails({ + workItem, + repoPath, + effectiveRepoId, + sourceContext, + initialTab, + projectOrigin, + onReviewRequestsChange +}: { + workItem: GitHubWorkItem | null + repoPath: string | null + effectiveRepoId: string | null + sourceContext?: TaskSourceContext | null + initialTab?: ItemDialogTab + projectOrigin?: GitHubItemDialogProjectOrigin + onReviewRequestsChange?: ( + itemKey: { id: string; repoId: string }, + reviewRequests: GitHubAssignableUser[] + ) => void +}) { + const [tab, setTab] = useState(() => normalizeItemDialogTab(workItem, initialTab)) + + // Why: the cache key must include issue source preference so toggling origin/upstream for the same issue number doesn't read the wrong repo's details. + const issueSourcePreference = useAppStore((s) => { + if (!repoPath && !effectiveRepoId) { + return undefined + } + return s.repos.find((r) => (effectiveRepoId ? r.id === effectiveRepoId : r.path === repoPath)) + ?.issueSourcePreference + }) + const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) + const detailsCacheKey = useMemo(() => { + if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) { + return null + } + return getWorkItemDetailsCacheKey({ + repoPath: repoPath ?? '', + repoId: effectiveRepoId, + issueSourcePreference, + sourceCacheScope: + sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null, + type: workItem.type, + number: workItem.number + }) + }, [ + canUseDetailsRepoContext, + repoPath, + effectiveRepoId, + sourceContext, + workItem, + issueSourcePreference + ]) + + // Why: reset during render so an item switch never paints the previous item's tab. + const tabResetKey = + workItem && effectiveRepoId && detailsCacheKey && canUseDetailsRepoContext + ? `${workItem.id}\0${initialTab ?? ''}` + : null + const [resolvedTabKey, setResolvedTabKey] = useState(tabResetKey) + if (resolvedTabKey !== tabResetKey) { + setResolvedTabKey(tabResetKey) + if (workItem && tabResetKey) { + setTab(normalizeItemDialogTab(workItem, initialTab)) + } + } + + // Why: hold comments added before the detail fetch resolves so they merge into the result instead of being overwritten. + const optimisticCommentsRef = useRef([]) + // Why: distinguish "reopen same item" from "switch item" — reopen must keep optimistic comments since gh's 60s cache omits the just-posted one. + const prevItemIdRef = useRef(null) + + // Why: subscribe to the module-level cache so reopening a cached item paints synchronously on first render. + const cachedEntry = useSyncExternalStore( + subscribeWorkItemDetailsCache, + useCallback( + () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined), + [detailsCacheKey] + ) + ) + + // Why: bumped on cold open (no cached details yet) so the details memo re-runs and surfaces the optimistic comment before the fetch lands. + const [optimisticTick, setOptimisticTick] = useState(0) + + // Why: key off cachedEntry identity (stable), not the optimistic ref array (fresh each render), to avoid needless recompute. + const details = useMemo(() => { + const cachedDetails = cachedEntry?.details ?? null + const opt = optimisticCommentsRef.current + if (!cachedDetails) { + // Why: on cold open, details may still be loading — surface optimistic comments via a minimal shell so a pre-fetch comment isn't invisible. + if (opt.length > 0 && workItem) { + return { item: workItem, body: '', comments: [...opt] } + } + return null + } + if (opt.length === 0) { + return cachedDetails + } + const ids = new Set(cachedDetails.comments.map((c) => c.id)) + const missing = opt.filter((c) => !ids.has(c.id)) + if (missing.length === 0) { + return cachedDetails + } + return { + ...cachedDetails, + comments: [...cachedDetails.comments, ...missing] + } + // Why: optimisticTick forces this ref-reading memo to re-run on cold-open writes; lint can't see the dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cachedEntry, workItem, optimisticTick]) + + const loading = !!cachedEntry?.pending && !cachedEntry?.details + const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null + const detailsLoaded = Boolean(cachedEntry?.details) + + // Why: if a cross-window mutation invalidates the open drawer's entry (cachedEntry undefined, fetch deps unchanged), bump a tick to force the refetch. + const [refetchTick, setRefetchTick] = useState(0) + useEffect(() => { + if (workItem && detailsCacheKey && !cachedEntry) { + setRefetchTick((n) => n + 1) + } + }, [workItem, detailsCacheKey, cachedEntry]) + + useEffect(() => { + if (!workItem || !effectiveRepoId || !detailsCacheKey || !canUseDetailsRepoContext) { + return + } + // Why: clear optimistic comments only on item switch — on reopen, gh's 60s cache omits the just-posted comment, so keep the ref to re-merge. + if (workItem.id !== prevItemIdRef.current) { + optimisticCommentsRef.current = [] + } + prevItemIdRef.current = workItem.id + + const cached = workItemDetailsCache.get(detailsCacheKey) + const now = Date.now() + const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS + + if (hasFreshData) { + return + } + + // Why: dedupe concurrent opens for the same key — share one in-flight promise instead of racing two `gh` subprocesses. + const inflight: Promise = + cached?.pending ?? + lookupGitHubWorkItemDetailsForSource({ + repoPath: repoPath ?? '', + repoId: effectiveRepoId, + sourceContext, + number: workItem.number, + type: workItem.type + }) + + // Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back. + const launchedAtGeneration = workItemDetailsCacheGeneration + + if (!cached?.pending) { + touchWorkItemDetailsCache(detailsCacheKey, { + details: cached?.details ?? null, + fetchedAt: cached?.fetchedAt ?? 0, + pending: inflight, + error: cached?.error + }) + } + + settleWorkItemDetailsFetch({ detailsCacheKey, inflight, launchedAtGeneration }) + }, [ + canUseDetailsRepoContext, + repoPath, + effectiveRepoId, + sourceContext, + workItem, + detailsCacheKey, + refetchTick + ]) + + const displayWorkItem = useMemo(() => { + if (!workItem) { + return null + } + if (!details?.item) { + return workItem + } + return { ...workItem, ...details.item, repoId: workItem.repoId } + }, [details?.item, workItem]) + + useEffect(() => { + if (!workItem || details?.item.reviewRequests === undefined) { + return + } + // Why: PR details can carry fresher reviewer metadata than the list row; push it back so the Tasks review chip isn't stale. + onReviewRequestsChange?.( + { id: workItem.id, repoId: workItem.repoId }, + details.item.reviewRequests + ) + }, [details?.item.reviewRequests, onReviewRequestsChange, workItem]) + + const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set()) + + const appendOptimisticComment = useCallback( + (comment: PRComment) => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + // Why: skip refreshDetails() — gh's 60s cache would overwrite the optimistic comment; next open picks up the server version. + optimisticCommentsRef.current.push(comment) + // Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields. + if (detailsCacheKey) { + const prev = workItemDetailsCache.get(detailsCacheKey) + if (prev?.details) { + const ids = new Set(prev.details.comments.map((c) => c.id)) + if (!ids.has(comment.id)) { + touchWorkItemDetailsCache(detailsCacheKey, { + details: { + ...prev.details, + comments: [...prev.details.comments, comment] + }, + fetchedAt: 0, + error: undefined + }) + return + } + } + } + // Why: no cache write fires while details are still loading; bump local state so the memo re-runs and shows the optimistic comment. + setOptimisticTick((n) => n + 1) + }, + [detailsCacheKey] + ) + + const invalidateCurrentDetailsCache = useCallback((): void => { + if (!workItem) { + return + } + // Why: local repos invalidate all source-pref variants; runtime-only entries need their exact source-scoped key (no local path). + if (repoPath) { + invalidateWorkItemDetailsCacheByMatch({ + repoPath, + repoId: effectiveRepoId ?? undefined, + type: workItem.type, + number: workItem.number + }) + return + } + if (detailsCacheKey) { + invalidateWorkItemDetailsCacheForKey(detailsCacheKey) + } + }, [detailsCacheKey, effectiveRepoId, repoPath, workItem]) + + const handlePRFileViewedChange = useCallback( + async (path: string, viewed: boolean): Promise => + syncPRFileViewedState({ + canUseDetailsRepoContext, + pullRequestId: details?.pullRequestId, + workItem, + detailsCacheKey, + repoPath, + sourceContext, + projectOrigin, + path, + viewed, + setPendingViewedPaths + }), + [ + canUseDetailsRepoContext, + details?.pullRequestId, + detailsCacheKey, + projectOrigin, + repoPath, + sourceContext, + workItem + ] + ) + + const isIssuePage = workItem?.type === 'issue' + + return { + tab, + setTab, + canUseDetailsRepoContext, + detailsCacheKey, + details, + loading, + error, + detailsLoaded, + displayWorkItem, + appendOptimisticComment, + invalidateCurrentDetailsCache, + handlePRFileViewedChange, + pendingViewedPaths, + isIssuePage + } +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts new file mode 100644 index 00000000000..36087ef00b2 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts @@ -0,0 +1,210 @@ +import type { PRCheckDetail } from '../../../../../shared/github/check-types' +import type { + GitHubAssignableUser, + GitHubPRFileViewedState +} from '../../../../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types' +import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' + +// Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md. +export const WORK_ITEM_DETAILS_CACHE_MAX = 50 +export const WORK_ITEM_DETAILS_FRESH_MS = 30_000 +export const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' +export type WorkItemDetailsCacheEntry = { + details: GitHubWorkItemDetails | null + fetchedAt: number + pending?: Promise + error?: string +} +export const workItemDetailsCache = new Map() + +// Why: drawers subscribe via useSyncExternalStore so a cached item paints synchronously; snapshot stability relies on every write replacing entry identity (delete+set). +const workItemDetailsCacheListeners = new Set<() => void>() +export function subscribeWorkItemDetailsCache(listener: () => void): () => void { + workItemDetailsCacheListeners.add(listener) + return () => { + workItemDetailsCacheListeners.delete(listener) + } +} +function notifyWorkItemDetailsCache(): void { + for (const listener of workItemDetailsCacheListeners) { + listener() + } +} + +export function getWorkItemDetailsCacheKey(args: { + repoPath: string + repoId: string + issueSourcePreference: string | undefined + sourceCacheScope?: string | null + type: 'issue' | 'pr' + number: number +}): string { + // Why: key on every axis that changes which (repo, item) the IPC resolves to; `\0` separator avoids ambiguity with fields containing `:` or `/`. + // Why: repoPath is the second part so match-based invalidation can find entries from a cross-window event that carries only the path. + const keyParts = args.sourceCacheScope + ? [ + args.repoId, + args.repoPath, + args.sourceCacheScope, + args.issueSourcePreference ?? 'auto', + args.type + ] + : [args.repoId, args.repoPath, args.issueSourcePreference ?? 'auto', args.type] + return [...keyParts, args.number].join('\0') +} + +export function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { + // Why: re-insert to move to MRU position; Map insertion order keeps the oldest key first for eviction. + workItemDetailsCache.delete(key) + workItemDetailsCache.set(key, entry) + while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { + const oldest = workItemDetailsCache.keys().next().value + if (oldest === undefined) { + break + } + workItemDetailsCache.delete(oldest) + } + notifyWorkItemDetailsCache() +} + +// Why: exposed so mutation handlers can drop a stale entry after a local mutation; cross-window invalidation arrives via the gh:workItemMutated listener below. +export function invalidateWorkItemDetailsCacheForKey(key: string): void { + // Why: bump generation so a fetch launched before this invalidation won't write its stale result back. + workItemDetailsCacheGeneration += 1 + const existed = workItemDetailsCache.delete(key) + if (existed) { + notifyWorkItemDetailsCache() + } +} + +// Why: bumped on every invalidation so an in-flight refetch started before a mutation can detect its result is stale and skip writing it back. +export let workItemDetailsCacheGeneration = 0 + +// Why: without the exact key (e.g. a cross-window event carries only repoPath+number+type), drop every entry matching that tuple regardless of source preference. +export function invalidateWorkItemDetailsCacheByMatch(args: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +}): void { + if (!args.repoId && !args.repoPath) { + // Why: an empty path would otherwise match every runtime-only entry of that type/number across repos. + return + } + const suffix = `\0${args.type}\0${args.number}` + let removed = false + for (const key of Array.from(workItemDetailsCache.keys())) { + if (!key.endsWith(suffix)) { + continue + } + // Why: repoId identifies the entry exactly; repoPath is the only handle a cross-window event carries, so match either side of the key head. + const [keyRepoId, keyRepoPath] = key.split('\0') + const matches = args.repoId ? keyRepoId === args.repoId : keyRepoPath === args.repoPath + if (matches) { + workItemDetailsCache.delete(key) + removed = true + } + } + if (removed) { + workItemDetailsCacheGeneration += 1 + notifyWorkItemDetailsCache() + } +} + +export function patchCachedPRFileViewedState( + cacheKey: string, + path: string, + viewerViewedState: GitHubPRFileViewedState +): GitHubPRFileViewedState | undefined { + const prev = workItemDetailsCache.get(cacheKey) + const files = prev?.details?.files + if (!prev?.details || !files) { + return undefined + } + let previousState: GitHubPRFileViewedState | undefined + const nextFiles = files.map((file) => { + if (file.path !== path) { + return file + } + previousState = file.viewerViewedState ?? 'UNVIEWED' + return { ...file, viewerViewedState } + }) + if (previousState === undefined || previousState === viewerViewedState) { + return previousState + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { ...prev.details, files: nextFiles }, + error: undefined + }) + return previousState +} + +export function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { + const prev = workItemDetailsCache.get(cacheKey) + if (!prev?.details) { + return + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { ...prev.details, checks }, + fetchedAt: Date.now(), + error: undefined + }) +} + +export function patchCachedPRReviewRequests( + cacheKey: string, + reviewRequests: GitHubAssignableUser[] +): void { + const prev = workItemDetailsCache.get(cacheKey) + if (!prev?.details) { + return + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { + ...prev.details, + item: { ...prev.details.item, reviewRequests } + }, + fetchedAt: Date.now(), + error: undefined + }) +} + +export function patchCachedWorkItemBody(cacheKey: string, body: string): void { + const prev = workItemDetailsCache.get(cacheKey) + if (!prev?.details) { + return + } + touchWorkItemDetailsCache(cacheKey, { + ...prev, + details: { ...prev.details, body }, + fetchedAt: Date.now(), + error: undefined + }) +} + +// Why: install once — all dialogs share the cache; track unsubscribe so Vite HMR doesn't accumulate listeners across dev reloads. +let workItemMutatedUnsub: (() => void) | undefined +let workItemDetailsCacheEventUnsub: (() => void) | undefined +if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { + workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => { + invalidateWorkItemDetailsCacheByMatch({ + repoPath: payload.repoPath, + repoId: payload.repoId, + type: payload.type, + number: payload.number + }) + }) + workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => { + invalidateWorkItemDetailsCacheByMatch(payload) + }) +} +if (import.meta !== undefined && import.meta.hot) { + import.meta.hot.dispose(() => { + workItemMutatedUnsub?.() + workItemDetailsCacheEventUnsub?.() + }) +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-fetch-settle.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-fetch-settle.ts new file mode 100644 index 00000000000..ec7d684bc6d --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-fetch-settle.ts @@ -0,0 +1,58 @@ +import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types' +import { + WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE, + touchWorkItemDetailsCache, + workItemDetailsCache, + workItemDetailsCacheGeneration +} from './work-item-details-cache' + +export function settleWorkItemDetailsFetch(args: { + detailsCacheKey: string + inflight: Promise + launchedAtGeneration: number +}): void { + const { detailsCacheKey, inflight, launchedAtGeneration } = args + void inflight + .then((result) => { + const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + const prev = workItemDetailsCache.get(detailsCacheKey) + if (invalidatedMidFlight && prev?.pending !== inflight) { + // Why: entry was deliberately dropped (or later repopulated) — don't recreate or touch it. + return + } + // Why: null means unavailable/not found, not loaded empty content. + if (result === null && prev?.details) { + touchWorkItemDetailsCache(detailsCacheKey, { + details: prev.details, + fetchedAt: prev.fetchedAt, + error: undefined + }) + } else if (result === null) { + touchWorkItemDetailsCache(detailsCacheKey, { + details: null, + fetchedAt: 0, + error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE + }) + } else { + touchWorkItemDetailsCache(detailsCacheKey, { + details: result, + fetchedAt: Date.now(), + error: undefined + }) + } + }) + .catch((err) => { + const message = err instanceof Error ? err.message : 'Failed to load details' + const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + const prev = workItemDetailsCache.get(detailsCacheKey) + if (invalidatedMidFlight && prev?.pending !== inflight) { + return + } + // Why: stale-on-error — keep cached data, drop the pending promise so next open retries; show the error only when nothing is cached. + touchWorkItemDetailsCache(detailsCacheKey, { + details: prev?.details ?? null, + fetchedAt: prev?.fetchedAt ?? 0, + error: message + }) + }) +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx new file mode 100644 index 00000000000..cd2135b9873 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-state-badge.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import { getStateLabel } from '@/components/github/work-item-state-presentation' + +export function getStateTone(item: GitHubWorkItem): string { + if (item.type === 'pr') { + if (item.state === 'merged') { + return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' + } + if (item.state === 'draft') { + return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' + } + if (item.state === 'closed') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + } + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + } + if (item.state === 'closed') { + // Why: keep closed issues neutral (may be completed/resolved); red is reserved for PR closed-without-merge. + return 'border-ring/50 bg-primary/10 text-foreground' + } + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' +} + +export function WorkItemStateBadge({ + item, + className +}: { + item: GitHubWorkItem + className?: string +}): React.JSX.Element { + return ( + + {getStateLabel(item)} + + ) +} diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-body.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-body.tsx new file mode 100644 index 00000000000..97004ef8937 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-body.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { + GitHubWorkItem, + GitHubWorkItemDetails +} from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { ConversationTab } from '../discuss-item/conversation-tab' +import { GHEditSection } from '../edit-item-fields/gh-edit-section' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { + patchCachedPRChecks, + patchCachedPRReviewRequests, + patchCachedWorkItemBody +} from '../load-item-details/work-item-details-cache' + +export function GitHubItemDialogIssueBody({ + workItem, + displayWorkItem, + repoPath, + effectiveRepoId, + sourceContext, + projectOrigin, + details, + detailsCacheKey, + loading, + detailsLoaded, + localState, + localLabels, + onStateChange, + onLabelsChange, + onMutated, + onUse, + onOpenOrUse, + attachedWorkspaceLabel, + onCommentAdded, + onReviewRequestsChange, + canUseDetailsRepoContext +}: { + workItem: GitHubWorkItem + displayWorkItem: GitHubWorkItem | null + repoPath: string | null + effectiveRepoId: string | null + sourceContext?: TaskSourceContext | null + projectOrigin?: GitHubItemDialogProjectOrigin + details: GitHubWorkItemDetails | null + detailsCacheKey: string | null + loading: boolean + detailsLoaded: boolean + localState: GitHubWorkItem['state'] + localLabels: string[] + onStateChange: (state: GitHubWorkItem['state']) => void + onLabelsChange: (labels: string[]) => void + onMutated: () => void + onUse: (item: GitHubWorkItem) => void + onOpenOrUse: (item: GitHubWorkItem) => void + attachedWorkspaceLabel: string | null + onCommentAdded: (comment: PRComment) => void + onReviewRequestsChange?: ( + itemKey: { id: string; repoId: string }, + reviewRequests: GitHubAssignableUser[] + ) => void + canUseDetailsRepoContext: boolean +}): React.JSX.Element { + const body = details?.body ?? '' + const comments = details?.comments ?? [] + const timelineItems = details?.timelineItems ?? [] + const files = details?.files ?? [] + const checks = details?.checks ?? [] + + return ( +
+ {/* Why: full content width so the description isn't squeezed by a right rail; px-2 + ConversationTab px-4 = header px-6. */} +
+ {(canUseDetailsRepoContext || projectOrigin) && ( +
+ +
+ )} +
+ { + if (detailsCacheKey) { + patchCachedPRChecks(detailsCacheKey, nextChecks) + } + }} + onBodyUpdated={(nextBody) => { + if (detailsCacheKey) { + patchCachedWorkItemBody(detailsCacheKey, nextBody) + } + }} + onCommentAdded={onCommentAdded} + onReviewersRequested={(nextReviewRequests) => { + if (detailsCacheKey) { + patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests) + } + onReviewRequestsChange?.( + { id: workItem.id, repoId: workItem.repoId }, + nextReviewRequests + ) + }} + /> +
+
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-header.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-header.tsx new file mode 100644 index 00000000000..10a8c3e1543 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-issue-header.tsx @@ -0,0 +1,254 @@ +import React from 'react' +import { + ArrowRight, + Check, + ChevronDown, + ChevronLeft, + CircleDashed, + CircleDot, + Copy, + ExternalLink, + FolderKanban, + Plus +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity' +import { formatRelativeTime } from '@/components/github/work-item-state-presentation' +import { translate } from '@/i18n/i18n' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import { WorkItemIssueSourceIndicator } from './work-item-issue-source-indicator' + +export function GitHubItemDialogIssueHeader({ + workItem, + backLabel, + onClose, + linkCopied, + setLinkCopyButtonRef, + handleCopyWorkItemLink, + issueAttachedWorkspace, + handleOpenOrUseIssueWorkspace, + onUse, + localState, + effectiveRepoId, + repoPath, + issueAttachedWorkspaceLabel +}: { + workItem: GitHubWorkItem + backLabel: string + onClose: () => void + linkCopied: boolean + setLinkCopyButtonRef: (node: HTMLButtonElement | null) => void + handleCopyWorkItemLink: () => Promise + issueAttachedWorkspace: object | null + handleOpenOrUseIssueWorkspace: (item: GitHubWorkItem) => void + onUse: (item: GitHubWorkItem) => void + localState: GitHubWorkItem['state'] + effectiveRepoId: string | null + repoPath: string | null + issueAttachedWorkspaceLabel: string | null +}): React.JSX.Element { + const ownerRepo = parseOwnerRepoFromItemUrl(workItem.url) + const issueStateBadgeTone = + localState === 'closed' ? 'bg-rose-600 text-white' : 'bg-emerald-600 text-white' + + return ( + <> +
+
+ + · + {ownerRepo ? ( + <> + + {ownerRepo.owner} + / + {ownerRepo.repo} + + · + + ) : null} + #{workItem.number} +
+ + + + + + {linkCopied + ? translate('auto.components.GitHubItemDialog.038b3d39b1', 'Copied') + : translate('auto.components.GitHubItemDialog.c43fe79ee0', 'Copy GitHub link')} + + + + + + + + {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} + + +
+
+
+ +
+
+

+ {workItem.title} + #{workItem.number} +

+
+ {/* Why: Orca's signature affordance — keep primary so it stands out against GitHub's familiar surface. */} + {issueAttachedWorkspace ? ( + + + + + + + + + onUse(workItem)}> + + {translate( + 'auto.components.GitHubItemDialog.36182aa57f', + 'Start new workspace' + )} + + window.api.shell.openUrl(workItem.url)}> + + {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} + + + + ) : ( + + )} +
+
+
+ + {localState === 'closed' ? ( + + ) : ( + + )} + {localState === 'closed' + ? translate('auto.components.GitHubItemDialog.ab050dffec', 'Closed') + : translate('auto.components.GitHubItemDialog.dc1ca081a8', 'Open')} + + + + {workItem.author ?? + translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} + + + {translate('auto.components.GitHubItemDialog.55962099bc', 'opened this issue')} + + + {translate('auto.components.GitHubItemDialog.10ef1afb8e', '· updated')}{' '} + {formatRelativeTime(workItem.updatedAt)} + + + + {issueAttachedWorkspaceLabel ? ( + + + {issueAttachedWorkspaceLabel} + + ) : null} +
+
+ + ) +} diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-header.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-header.tsx new file mode 100644 index 00000000000..06874a643b7 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-header.tsx @@ -0,0 +1,170 @@ +import React from 'react' +import { + ArrowRight, + Check, + ChevronLeft, + CircleDot, + Copy, + ExternalLink, + FolderKanban, + GitPullRequest +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { formatRelativeTime } from '@/components/github/work-item-state-presentation' +import { translate } from '@/i18n/i18n' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import { WorkItemIssueSourceIndicator } from './work-item-issue-source-indicator' +import { WorkItemStateBadge } from '../load-item-details/work-item-state-badge' + +export function GitHubItemDialogPRHeader({ + workItem, + backLabel, + onClose, + localState, + issueAttachedWorkspaceLabel, + effectiveRepoId, + repoPath, + onUse, + linkCopied, + setLinkCopyButtonRef, + handleCopyWorkItemLink +}: { + workItem: GitHubWorkItem + backLabel: string + onClose: () => void + localState: GitHubWorkItem['state'] + issueAttachedWorkspaceLabel: string | null + effectiveRepoId: string | null + repoPath: string | null + onUse: (item: GitHubWorkItem) => void + linkCopied: boolean + setLinkCopyButtonRef: (node: HTMLButtonElement | null) => void + handleCopyWorkItemLink: () => Promise +}): React.JSX.Element { + const Icon = workItem.type === 'pr' ? GitPullRequest : CircleDot + + return ( +
+
+ +
+ +
+
+
+ + #{workItem.number} + + {workItem.type === 'pr' + ? translate('auto.components.GitHubItemDialog.a2495e4784', 'Pull request') + : translate('auto.components.GitHubItemDialog.3e544d966d', 'Issue')} + +
+

+ {workItem.title} +

+
+ + {workItem.author ?? + translate('auto.components.GitHubItemDialog.773ff70035', 'unknown')} + + + {translate('auto.components.GitHubItemDialog.8223320f8d', 'updated')}{' '} + {formatRelativeTime(workItem.updatedAt)} + + {workItem.branchName && ( + + {workItem.branchName} + + )} + {issueAttachedWorkspaceLabel ? ( + + + {issueAttachedWorkspaceLabel} + + ) : null} +
+ {workItem.type === 'issue' && ( + + )} +
+
+ {workItem.type === 'pr' && ( + + )} + + + + + + {linkCopied + ? translate('auto.components.GitHubItemDialog.038b3d39b1', 'Copied') + : translate('auto.components.GitHubItemDialog.c43fe79ee0', 'Copy GitHub link')} + + + + + + + + {translate('auto.components.GitHubItemDialog.3fdf777817', 'Open on GitHub')} + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-tabs.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-tabs.tsx new file mode 100644 index 00000000000..79de68a8924 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog-pr-tabs.tsx @@ -0,0 +1,231 @@ +import React from 'react' +import { FileText, ListChecks, LoaderCircle, MessageSquare, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + resolvePullRequestRepo, + type ItemDialogTab +} from '@/components/github/github-work-item-identity' +import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context' +import { translate } from '@/i18n/i18n' +import type { PRComment } from '../../../../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types' +import type { + GitHubWorkItem, + GitHubWorkItemDetails +} from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { ChecksTab } from '../inspect-pull-request/checks-tab' +import { ConversationTab } from '../discuss-item/conversation-tab' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { PRFilesCombinedDiffViewer } from '../inspect-pull-request/pr-files-combined-diff-viewer' +import { + patchCachedPRChecks, + patchCachedPRReviewRequests, + patchCachedWorkItemBody +} from '../load-item-details/work-item-details-cache' + +export function GitHubItemDialogPRTabs({ + workItem, + displayWorkItem, + repoPath, + effectiveRepoId, + sourceContext, + projectOrigin, + details, + detailsCacheKey, + loading, + detailsLoaded, + localState, + onStateChange, + onMutated, + onCommentAdded, + onReviewRequestsChange, + tab, + setTab, + pendingViewedPaths, + onViewedChange +}: { + workItem: GitHubWorkItem + displayWorkItem: GitHubWorkItem | null + repoPath: string | null + effectiveRepoId: string | null + sourceContext?: TaskSourceContext | null + projectOrigin?: GitHubItemDialogProjectOrigin + details: GitHubWorkItemDetails | null + detailsCacheKey: string | null + loading: boolean + detailsLoaded: boolean + localState: GitHubWorkItem['state'] + onStateChange: (state: GitHubWorkItem['state']) => void + onMutated: () => void + onCommentAdded: (comment: PRComment) => void + onReviewRequestsChange?: ( + itemKey: { id: string; repoId: string }, + reviewRequests: GitHubAssignableUser[] + ) => void + tab: ItemDialogTab + setTab: (tab: ItemDialogTab) => void + pendingViewedPaths: Set + onViewedChange: (path: string, viewed: boolean) => Promise +}): React.JSX.Element { + const body = details?.body ?? '' + const comments = details?.comments ?? [] + const timelineItems = details?.timelineItems ?? [] + const files = details?.files ?? [] + const filesUnavailable = details?.filesUnavailable ?? false + const canUseFilesRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) + const checks = details?.checks ?? [] + + return ( + setTab(value as ItemDialogTab)} + className="flex h-full min-h-0 flex-col gap-0" + > + + + + {translate('auto.components.GitHubItemDialog.e30a5470c9', 'Conversation')} + + {workItem.type === 'pr' && ( + <> + + + {translate('auto.components.GitHubItemDialog.4bd1f5b055', 'Checks')} + {checks.length > 0 && ( + {checks.length} + )} + + + + {translate('auto.components.GitHubItemDialog.999b5ad7d9', 'Files')} + {files.length > 0 && ( + {files.length} + )} + + + )} + + +
+ + { + if (detailsCacheKey) { + patchCachedPRChecks(detailsCacheKey, nextChecks) + } + }} + onBodyUpdated={(nextBody) => { + if (detailsCacheKey) { + patchCachedWorkItemBody(detailsCacheKey, nextBody) + } + }} + onCommentAdded={onCommentAdded} + onReviewersRequested={(nextReviewRequests) => { + if (detailsCacheKey) { + patchCachedPRReviewRequests(detailsCacheKey, nextReviewRequests) + } + onReviewRequestsChange?.( + { id: workItem.id, repoId: workItem.repoId }, + nextReviewRequests + ) + }} + /> + + + {workItem.type === 'pr' && ( + <> + + { + if (detailsCacheKey) { + patchCachedPRChecks(detailsCacheKey, nextChecks) + } + }} + /> + + + + {loading && files.length === 0 ? ( +
+ +
+ ) : !canUseFilesRepoContext ? ( + // Why: without a repo path or runtime host the viewer would call IPC with empty ids; diff loads and review comments both fail silently. +
+ {translate( + 'auto.components.GitHubItemDialog.00f55cc17b', + 'Repository access is unavailable for this pull request.' + )} +
+ ) : filesUnavailable && files.length === 0 ? ( + // Why: file fetch failed (rate limit, auth, unresolved remote); offer a retry instead of implying the PR is empty. +
+
+ {translate( + 'auto.components.GitHubItemDialog.filesUnavailable', + "Couldn't load changed files." + )} +
+ +
+ ) : files.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.3cd5ae5b7b', 'No files changed.')} +
+ ) : ( + + )} +
+ + )} +
+
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx new file mode 100644 index 00000000000..b3abb1c9853 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx @@ -0,0 +1,259 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { useAllWorktrees } from '@/store/selectors' +import { useAppStore } from '@/store' +import { + findGithubIssueWorkspaceAttachment, + getGithubWorkItemWorkspaceAttachmentLabel +} from '@/lib/github-work-item-workspace-attachment' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { translate } from '@/i18n/i18n' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { GitHubItemDialogProps } from '../load-item-details/github-item-dialog-types' +import { GitHubItemDialogIssueBody } from './github-item-dialog-issue-body' +import { GitHubItemDialogIssueHeader } from './github-item-dialog-issue-header' +import { GitHubItemDialogPRHeader } from './github-item-dialog-pr-header' +import { GitHubItemDialogPRTabs } from './github-item-dialog-pr-tabs' +import { GHEditSection } from '../edit-item-fields/gh-edit-section' +import { useGitHubItemDialogDetails } from '../load-item-details/use-github-item-dialog-details' +import { useGitHubItemDialogLinkCopy } from './use-github-item-dialog-link-copy' + +export default function GitHubItemDialog({ + workItem, + repoPath, + repoId, + sourceContext, + initialTab, + backLabel = 'Back', + projectOrigin, + onUse, + onReviewRequestsChange, + onClose +}: GitHubItemDialogProps): React.JSX.Element { + const [localState, setLocalState] = useState(workItem?.state ?? 'open') + const [localLabels, setLocalLabels] = useState(workItem?.labels ?? []) + const workItemId = workItem?.id + const workItemState = workItem?.state + const workItemLabels = workItem?.labels + const effectiveRepoId = repoId ?? workItem?.repoId ?? null + const allWorktrees = useAllWorktrees() + const issueAttachedWorkspace = useMemo( + () => + workItem?.type === 'issue' + ? findGithubIssueWorkspaceAttachment(allWorktrees, effectiveRepoId, workItem.number) + : null, + [allWorktrees, effectiveRepoId, workItem] + ) + const issueAttachedWorkspaceLabel = issueAttachedWorkspace + ? getGithubWorkItemWorkspaceAttachmentLabel(issueAttachedWorkspace) + : null + + const handleOpenOrUseIssueWorkspace = useCallback( + (item: GitHubWorkItem): void => { + const currentAttached = findGithubIssueWorkspaceAttachment( + useAppStore.getState().allWorktrees(), + effectiveRepoId, + item.number + ) + if (!currentAttached) { + onUse(item) + return + } + + const result = activateAndRevealWorktree(currentAttached.id) + if (result === false) { + toast.error( + translate( + 'auto.components.GitHubItemDialog.2ef631437e', + 'Unable to open the workspace attached to this issue.' + ) + ) + } + }, + [effectiveRepoId, onUse] + ) + + const { + tab, + setTab, + canUseDetailsRepoContext, + detailsCacheKey, + details, + loading, + error, + detailsLoaded, + displayWorkItem, + appendOptimisticComment, + invalidateCurrentDetailsCache, + handlePRFileViewedChange, + pendingViewedPaths, + isIssuePage + } = useGitHubItemDialogDetails({ + workItem, + repoPath, + effectiveRepoId, + sourceContext, + initialTab, + projectOrigin, + onReviewRequestsChange + }) + const { linkCopied, setLinkCopyButtonRef, handleCopyWorkItemLink } = + useGitHubItemDialogLinkCopy(workItem) + + const resolvedWorkItemState = details?.item.state ?? workItemState + // Why: the opening list row can be stale; the detail payload has authoritative state, so refresh the local edit UI from it. + useEffect(() => { + if (resolvedWorkItemState) { + setLocalState(resolvedWorkItemState) + } + if (workItemLabels) { + setLocalLabels(workItemLabels) + } + }, [workItemId, resolvedWorkItemState, workItemLabels]) + + // Why: a just-closed Radix overlay can leave `pointer-events: none` on , killing header button clicks; poll a few frames to clear it. + useEffect(() => { + if (!workItem) { + return + } + let cancelled = false + let count = 0 + let frameId: number | null = null + const tick = (): void => { + frameId = null + if (cancelled) { + return + } + if (document.body.style.pointerEvents === 'none') { + document.body.style.pointerEvents = '' + } + if (count++ < 5) { + frameId = requestAnimationFrame(tick) + } + } + tick() + return () => { + cancelled = true + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + }, [workItem]) + + const content = workItem ? ( +
+ {isIssuePage ? ( + + ) : ( + + )} + + {!isIssuePage && (canUseDetailsRepoContext || projectOrigin) && ( + + )} + +
+ {error ? ( +
{error}
+ ) : isIssuePage ? ( + + ) : ( + + )} +
+
+ ) : null + + return ( + // Why: rendered inline (not a Radix dialog), so e2e needs a stable hook to scope assertions to this detail surface. +
+ {content} +
+ ) +} diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/use-github-item-dialog-link-copy.ts b/src/renderer/src/components/github-item-dialog/open-dialog/use-github-item-dialog-link-copy.ts new file mode 100644 index 00000000000..a29a620dcbb --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/open-dialog/use-github-item-dialog-link-copy.ts @@ -0,0 +1,72 @@ +import { useCallback, useRef, useState } from 'react' +import { toast } from 'sonner' +import { + clearGitHubLinkCopied, + createGitHubLinkCopyState, + markGitHubLinkCopied, + resolveGitHubLinkCopyState +} from '@/components/github-link-copy-state' +import { translate } from '@/i18n/i18n' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' + +export function useGitHubItemDialogLinkCopy(workItem: GitHubWorkItem | null): { + linkCopied: boolean + setLinkCopyButtonRef: (node: HTMLButtonElement | null) => void + handleCopyWorkItemLink: () => Promise +} { + const workItemId = workItem?.id + const [linkCopyState, setLinkCopyState] = useState(() => createGitHubLinkCopyState(workItemId)) + const resolvedLinkCopyState = resolveGitHubLinkCopyState(linkCopyState, workItemId) + if (resolvedLinkCopyState !== linkCopyState) { + // Why: switching items must not paint a stale "copied" indicator from the previous item. + setLinkCopyState(resolvedLinkCopyState) + } + const linkCopied = resolvedLinkCopyState.copied + // Why: clipboard IPC can resolve after unmount; skip copied-state feedback rather than start a reset timer on a stale surface. + const linkCopyMountedRef = useRef(false) + const linkCopiedResetTimerRef = useRef(null) + const clearLinkCopiedResetTimer = useCallback((): void => { + if (linkCopiedResetTimerRef.current === null) { + return + } + window.clearTimeout(linkCopiedResetTimerRef.current) + linkCopiedResetTimerRef.current = null + }, []) + const setLinkCopyButtonRef = useCallback( + (node: HTMLButtonElement | null) => { + linkCopyMountedRef.current = node !== null + if (node === null) { + // Why: the copied-state timer belongs to this control; clear it on detach without a passive cleanup Effect. + clearLinkCopiedResetTimer() + } + }, + [clearLinkCopiedResetTimer] + ) + + const handleCopyWorkItemLink = useCallback(async (): Promise => { + if (!workItem) { + return + } + try { + // Why: Electron clipboard IPC works even when browser clipboard APIs lose focus/activation in nested overlays. + await window.api.ui.writeClipboardText(workItem.url) + if (!linkCopyMountedRef.current) { + return + } + clearLinkCopiedResetTimer() + const copiedWorkItemId = workItem.id + setLinkCopyState(markGitHubLinkCopied(copiedWorkItemId)) + linkCopiedResetTimerRef.current = window.setTimeout(() => { + linkCopiedResetTimerRef.current = null + setLinkCopyState((current) => clearGitHubLinkCopied(current, copiedWorkItemId)) + }, 1500) + toast.success(translate('auto.components.GitHubItemDialog.2e77dc2053', 'GitHub link copied')) + } catch { + toast.error( + translate('auto.components.GitHubItemDialog.5fea151559', 'Failed to copy GitHub link') + ) + } + }, [clearLinkCopiedResetTimer, workItem]) + + return { linkCopied, setLinkCopyButtonRef, handleCopyWorkItemLink } +} diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/work-item-issue-source-indicator.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/work-item-issue-source-indicator.tsx new file mode 100644 index 00000000000..df4c67919e1 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/open-dialog/work-item-issue-source-indicator.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react' +import { useAppStore } from '@/store' +import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' +import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity' +import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types' +import { PER_REPO_FETCH_LIMIT } from '../../../../../shared/work-items' + +// Why: recover the PR-source slug the dialog lacks from workItemsCache, scoped to this repoPath so a sibling repo sharing the issue-source (e.g. two forks) can't mislabel the chip; hide when unknown rather than guess (design doc §1). +export function WorkItemIssueSourceIndicator({ + url, + repoId, + repoPath +}: { + url: string + repoId: string | null + repoPath?: string | null +}): React.JSX.Element | null { + // Why: resolve repo sources via the primary cache entry, or any sibling entry if the Tasks view only populated a query-keyed slot (sources are repo-level, so any is safe). + const sources = useAppStore((s) => + s.getWorkItemsAnySourcesForRepo(repoId ?? '', PER_REPO_FETCH_LIMIT, repoPath ?? undefined) + ) + const issues = useMemo(() => { + const fromUrl = parseOwnerRepoFromItemUrl(url) + if (!fromUrl) { + return null + } + // Prefer the cache's resolved issue-source (canonicalized by main) over the best-effort URL parse when they match. + const cachedIssues = sources?.issues + if (cachedIssues && sameGitHubOwnerRepo(cachedIssues, fromUrl)) { + return cachedIssues + } + return fromUrl + }, [url, sources]) + const prs = sources?.prs ?? null + + if (!issues || !prs || sameGitHubOwnerRepo(issues, prs)) { + return null + } + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/github/pr-check-presentation.ts b/src/renderer/src/components/github/pr-check-presentation.ts index fb50d0a780a..a7d06e87509 100644 --- a/src/renderer/src/components/github/pr-check-presentation.ts +++ b/src/renderer/src/components/github/pr-check-presentation.ts @@ -1,5 +1,9 @@ import { getCheckConclusion } from '@/components/pr-check-counts' -import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { + PRCheckAnnotation, + PRCheckDetail, + PRCheckJob +} from '../../../../shared/github/check-types' export function getCheckStatusLabel(check: PRCheckDetail): string { const conclusion = getCheckConclusion(check) @@ -37,6 +41,47 @@ export function getCheckDetailsKey(check: PRCheckDetail): string { return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name) } +// Why: check annotations carry no id, so identify them by the fields that make one distinct. +function getCheckAnnotationKey(annotation: PRCheckAnnotation): string { + return [ + annotation.path ?? '', + annotation.startLine ?? '', + annotation.endLine ?? '', + annotation.annotationLevel ?? '', + annotation.title ?? '', + annotation.message + ].join('\0') +} + +// Why: a check run can repeat a job name (matrix reruns) and omit the id, so fall back to the +// fields that separate two same-named jobs. +function getCheckJobKey(job: PRCheckJob): string { + return job.id !== null + ? String(job.id) + : [job.name, job.url ?? '', job.startedAt ?? '', job.completedAt ?? ''].join('\0') +} + +// Why: identical twins are indistinguishable, so suffix repeats to keep list keys unique. +function withUniqueKeys(items: T[], getKey: (item: T) => string): { key: string; item: T }[] { + const seen = new Map() + return items.map((item) => { + const base = getKey(item) + const repeat = seen.get(base) ?? 0 + seen.set(base, repeat + 1) + return { key: repeat === 0 ? base : `${base}#${repeat}`, item } + }) +} + +export function getKeyedCheckAnnotations( + annotations: PRCheckAnnotation[] +): { key: string; item: PRCheckAnnotation }[] { + return withUniqueKeys(annotations, getCheckAnnotationKey) +} + +export function getKeyedCheckJobs(jobs: PRCheckJob[]): { key: string; item: PRCheckJob }[] { + return withUniqueKeys(jobs, getCheckJobKey) +} + export function formatCheckTimestamp(input: string | null | undefined): string | null { if (!input) { return null diff --git a/src/renderer/src/components/task-drawer-source-boundary.test.ts b/src/renderer/src/components/task-drawer-source-boundary.test.ts index 2a445ea9630..c320d852c54 100644 --- a/src/renderer/src/components/task-drawer-source-boundary.test.ts +++ b/src/renderer/src/components/task-drawer-source-boundary.test.ts @@ -18,7 +18,10 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('task drawer source boundaries', () => { it('threads GitHub task source context through detail mutations', () => { - const source = componentSource('GitHubItemDialog.tsx') + const source = [ + componentSource('github-item-dialog/edit-item-fields/gh-edit-section.tsx'), + componentSource('github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts') + ].join('\n') const issueUpdate = sourceBetween( componentSource('github/github-work-item-edit-mutations.ts'), 'async function runIssueUpdate', @@ -29,20 +32,16 @@ describe('task drawer source boundaries', () => { 'function addIssueCommentForRepo', 'function addPRReviewCommentForRepo' ) - const editSection = sourceBetween( - source, - 'function GHEditSection', - 'function GHCommentComposer' - ) + const editSection = source expect(issueUpdate).toContain('sourceContext: args.sourceContext') expect(commentUpdate).toContain('sourceContext: args.sourceContext') expect(editSection).toContain('sourceContext,') expect(editSection).toContain( - 'patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext })' + 'patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext })' ) expect(editSection).toContain( - 'patchWorkItem(item.id, { labels: newLabels }, item.repoId, { sourceContext })' + 'patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext })' ) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1be46cd92fc..dbb9d4dacaa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1035,7 +1035,7 @@ "e52bed9264": "No checks reported yet", "90020cc1f3": "This pull request has no reported checks yet.", "ecffebc251": "No checks found", - "5dddefdf58": "Open in GitHub", + "checkOpenInBrowser": "Open in browser", "744197c84d": "No inline output is available for this check.", "08d072664d": "Jobs", "96d8f36798": "Annotations", @@ -1183,7 +1183,15 @@ "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", "e15a8b77ef": "No inline details are available for this check.", "e45324fbed": "Failed to load check details.", - "dcb3c546fe": "Retry" + "dcb3c546fe": "Retry", + "85a2b66f54": "Open files on GitHub", + "86d84a17ca": "Add a review comment", + "d9fa90b625": "Failed to load diff.", + "4aecf121e7": "Close", + "8812225174": "Reopen", + "09d67a0f9b": "Failed to close pull request", + "88809e79db": "Failed to reopen pull request", + "00f55cc17b": "Repository access is unavailable for this pull request." }, "GitLabItemDialog": { "65e784c1f1": "Reopen", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6cc500a1eb5..ec8b4bf5433 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -904,7 +904,7 @@ "e52bed9264": "Aún no se reportaron checks", "90020cc1f3": "Este pull request aún no tiene checks reportados.", "ecffebc251": "No se encontraron checks", - "5dddefdf58": "Abrir en GitHub", + "checkOpenInBrowser": "Abrir en el navegador", "744197c84d": "No hay output inline disponible para este check.", "08d072664d": "Jobs", "96d8f36798": "Anotaciones", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fa7e623b28a..7a3e03aacc3 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -904,7 +904,7 @@ "e52bed9264": "まだチェックは報告されていません", "90020cc1f3": "この PR にはまだチェックが報告されていません。", "ecffebc251": "チェックが見つかりませんでした", - "5dddefdf58": "GitHub で開く", + "checkOpenInBrowser": "ブラウザで開く", "744197c84d": "このチェックではインライン出力は使用できません。", "08d072664d": "ジョブ", "96d8f36798": "注釈", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6e84ad2595b..3060a5b34c0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -904,7 +904,7 @@ "e52bed9264": "아직 보고된 체크가 없습니다.", "90020cc1f3": "이 PR에는 아직 보고된 체크가 없습니다.", "ecffebc251": "체크를 찾을 수 없습니다.", - "5dddefdf58": "GitHub에서 열기", + "checkOpenInBrowser": "브라우저에서 열기", "744197c84d": "이 체크에는 인라인 출력이 없습니다.", "08d072664d": "작업", "96d8f36798": "주석", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 58f0a7e5f2e..aac4af7f801 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -904,7 +904,7 @@ "e52bed9264": "尚未报告任何 CI 检查", "90020cc1f3": "此PR尚未报告检查。", "ecffebc251": "未找到检查", - "5dddefdf58": "在 GitHub 中打开", + "checkOpenInBrowser": "在浏览器中打开", "744197c84d": "此检查没有可用的内联输出。", "08d072664d": "任务", "96d8f36798": "注释", From f070033156f061af5064e1d6c884c0a3d7d7188a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:48:55 -0700 Subject: [PATCH 03/28] =?UTF-8?q?Revert=20"refactor(shell):=20one=20portab?= =?UTF-8?q?le=20Unix=20startup=20dialect=20instead=20of=20shell=20d?= =?UTF-8?q?=E2=80=A6"=20(#14975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b6ea3f17a971fe5600821f2e35eecb8c7f153e13. --- .github/workflows/pr.yml | 6 - .../scripts/pr-workflow-parallelism.test.mjs | 1 - src/main/daemon/daemon-pty-adapter.ts | 48 +-- .../daemon/daemon-zsh-shell-ready-rcfile.ts | 5 +- src/main/daemon/shell-ready.ts | 4 +- src/main/fish-history-session.test.ts | 197 ----------- src/main/fish-history-session.ts | 128 ------- src/main/ipc/pty.ts | 6 +- src/main/ipc/worktrees.ts | 28 +- src/main/providers/local-pty-provider.ts | 19 +- ...ocal-pty-shell-ready-wrapper-generation.ts | 5 +- src/main/providers/pty-provider-contract.ts | 4 - src/main/providers/ssh-pty-provider.test.ts | 10 +- src/main/providers/ssh-pty-provider.ts | 49 ++- .../providers/ssh-pty-session-reattach.ts | 36 -- src/main/providers/ssh-pty-spawn-request.ts | 4 - .../remote-worktree-history-cleanup.test.ts | 30 -- src/main/remote-worktree-history-cleanup.ts | 15 - src/main/runtime/orca-runtime.test.ts | 149 +-------- src/main/runtime/orca-runtime.ts | 17 - src/main/shell-templates.ts | 36 +- src/main/terminal-history-deletion.ts | 77 +---- ...inal-history-fish-session.node-pty.test.ts | 145 -------- src/main/terminal-history-gc.ts | 52 +-- src/main/terminal-history-id.ts | 6 - src/main/terminal-history-paths.ts | 6 +- .../terminal-history-tombstone-retry.test.ts | 137 +------- src/main/terminal-history.test.ts | 315 +----------------- src/main/terminal-history.ts | 201 ++--------- src/main/wsl-fish-history-cleanup.test.ts | 90 ----- src/main/wsl-fish-history-cleanup.ts | 81 ----- .../zsh-scoped-histfile.live-shell.test.ts | 106 ------ src/relay/pty-handler.ts | 49 +-- src/relay/pty-shell-launch.ts | 201 ++++++++++- src/relay/pty-shell-overlay-wrappers.ts | 207 ------------ src/relay/terminal-history.test.ts | 55 --- src/relay/terminal-history.ts | 134 -------- .../settings/CliSkillRuntimeSetup.tsx | 5 +- .../folder-workspace-composer-submit.test.ts | 2 +- ...nnection-cold-restore-agent-resume.test.ts | 2 +- .../src/lib/ai-vault-resume-command.test.ts | 8 +- .../src/lib/ai-vault-resume-command.ts | 28 +- .../src/lib/ai-vault-resume-shell.test.ts | 82 +++-- src/renderer/src/lib/ai-vault-resume-shell.ts | 17 +- .../launch-agent-background-session.test.ts | 2 +- ...h-agent-in-new-tab-windows-quoting.test.ts | 6 +- .../src/lib/launch-work-item-direct.test.ts | 4 +- .../lib/resume-sleeping-agent-session.test.ts | 2 +- .../src/lib/tui-agent-startup.test.ts | 2 +- src/shared/agent-resume-launch-command.ts | 7 +- src/shared/ai-vault-resume-command.ts | 32 +- src/shared/global-settings-types.ts | 3 +- src/shared/hermes-startup-query.ts | 11 +- ...artup-shell-portability.live-shell.test.ts | 240 ------------- .../tui-agent-startup-session-options.test.ts | 2 +- src/shared/tui-agent-startup-shell.test.ts | 88 ++--- src/shared/tui-agent-startup-shell.ts | 170 ++-------- src/shared/tui-agent-startup.test.ts | 11 +- 58 files changed, 506 insertions(+), 2877 deletions(-) delete mode 100644 src/main/fish-history-session.test.ts delete mode 100644 src/main/fish-history-session.ts delete mode 100644 src/main/remote-worktree-history-cleanup.test.ts delete mode 100644 src/main/remote-worktree-history-cleanup.ts delete mode 100644 src/main/terminal-history-fish-session.node-pty.test.ts delete mode 100644 src/main/terminal-history-id.ts delete mode 100644 src/main/wsl-fish-history-cleanup.test.ts delete mode 100644 src/main/wsl-fish-history-cleanup.ts delete mode 100644 src/main/zsh-scoped-histfile.live-shell.test.ts delete mode 100644 src/relay/pty-shell-overlay-wrappers.ts delete mode 100644 src/relay/terminal-history.test.ts delete mode 100644 src/relay/terminal-history.ts delete mode 100644 src/shared/startup-shell-portability.live-shell.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c44b2abaa56..6826ecfb731 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -270,10 +270,7 @@ jobs: src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - src/main/terminal-history-fish-session.node-pty.test.ts \ - src/main/zsh-scoped-histfile.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts test: @@ -312,10 +309,7 @@ jobs: --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 536059fe4cc..e934d1d836d 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -15,7 +15,6 @@ const shellContractFiles = [ 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', - 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] const patchedNodePtyContractFiles = [ diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 7d2063e2a7d..aad29987053 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -66,9 +66,6 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { resolveUnixShellPath } from '../providers/local-pty-utils' -import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { addWslEnvKeys } from '../wsl-env' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache' import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' @@ -428,8 +425,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - const spawnOpts = this.withHistoryIsolation(opts) - const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) + const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) const operation = { exitsBySessionId: new Map(), ignoredExitIncarnationIds: new Set(), @@ -448,9 +444,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } try { return await this.withHistorySpawnLock(sessionId, () => - this.withDaemonRetry(() => - this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) - ) + this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation, historyRecovery)) ) } finally { if (historyRecovery.freeze) { @@ -464,44 +458,6 @@ export class DaemonPtyAdapter implements IPtyProvider { } } - private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { - const wslContext = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId: opts.sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - }) - if ( - opts.attachOnly === true || - (opts.sessionId !== undefined && opts.isNewSession !== true) || - !opts.worktreeId || - opts.historyIsolationEnabled !== true || - (process.platform === 'win32' && !wslContext) - ) { - return opts - } - const env = { ...opts.env } - const preferredShell = wslContext - ? 'bash' - : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' - const shellPath = resolveUnixShellPath(preferredShell) - const historyArgs = [ - env, - opts.worktreeId, - shellPath, - opts.cwd ?? resolveSafePtyDefaultCwd() - ] as const - const result = wslContext - ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) - : injectHistoryEnv(...historyArgs) - if (wslContext) { - injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) - addWslEnvKeys(env, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(opts.worktreeId, result) - return { ...opts, env } - } - private async doSpawn( opts: PtySpawnOptions, operation: PendingDaemonSpawnOperation, diff --git a/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts b/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts index f9517b67d80..4e5d4242d81 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-rcfile.ts @@ -1,8 +1,6 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' -import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock, - ZSH_HISTFILE_RESTORE_BLOCK -} from '../shell-templates' +import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock } from '../shell-templates' export function getDaemonZshShellReadyRcfileContent(): string { return `# Orca daemon zsh shell-ready wrapper @@ -25,7 +23,6 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} fi __orca_osc133_precmd() { diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 0f9b2d65885..7989401e4d0 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -16,8 +16,7 @@ import { getZshEnvTemplate, getZshFinalZdotdirRestoreBlock, getZshShellReadyMarkerRegistrationBlock, - getZshStartupFileSourceBlock, - ZSH_HISTFILE_RESTORE_BLOCK + getZshStartupFileSourceBlock } from '../shell-templates' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' @@ -122,7 +121,6 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/fish-history-session.test.ts b/src/main/fish-history-session.test.ts deleted file mode 100644 index d1a8a7a632c..00000000000 --- a/src/main/fish-history-session.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - deleteFishHistoryFile, - fishHistorySessionName, - isSafeFishHistorySession, - resolveFishHistoryDir, - sweepOrphanedFishHistoryFiles -} from './fish-history-session' - -const HASH_A = 'a1b2c3d4e5f60718' -const HASH_B = '00112233445566ff' -const SESSION_A = fishHistorySessionName(HASH_A) - -describe('fish history session naming', () => { - it('mints a session name that is a valid fish variable value', () => { - // fish falls back to the shared default for anything that is not a valid - // variable name, which would silently un-isolate the worktree. - expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718') - expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/) - }) - - it.each([ - ['orca_deadbeef', true], - ['orca_', false], - ['fish_history', false], - ['orca_../../etc/passwd', false], - ['orca_DEADBEEF', false], - ['', false], - [undefined, false], - [42, false] - ])('accepts %s as a safe session name: %s', (value, expected) => { - expect(isSafeFishHistorySession(value)).toBe(expected) - }) -}) - -describe('fish history directory resolution', () => { - // Why join rather than literals: the separator is platform-specific, and on - // Windows `isAbsolute('/data')` is also true, so a hardcoded '/data/fish' - // fails there for a reason that has nothing to do with the behavior tested. - it('follows XDG_DATA_HOME when it is absolute', () => { - expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe( - join('/data', 'fish') - ) - }) - - it('ignores a relative XDG_DATA_HOME the way fish does', () => { - expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe( - join('/home/me', '.local', 'share', 'fish') - ) - }) - - it('falls back to HOME when XDG_DATA_HOME is unset', () => { - expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe( - join('/home/me', '.local', 'share', 'fish') - ) - }) -}) - -describe('fish history deletion', () => { - let root: string - let fishDir: string - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-fish-history-')) - fishDir = join(root, 'fish') - mkdirSync(fishDir, { recursive: true }) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - const historyFile = (session: string): string => join(fishDir, `${session}_history`) - - it('deletes the session file rather than truncating it', () => { - writeFileSync(historyFile(SESSION_A), '- cmd: ls\n') - - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true) - expect(existsSync(historyFile(SESSION_A))).toBe(false) - }) - - it('tries every candidate directory, since the PTY may not share this env', () => { - const other = join(root, 'other-xdg', 'fish') - mkdirSync(other, { recursive: true }) - writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n') - - expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true) - expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false) - }) - - it('refuses a session name it did not mint', () => { - const foreign = join(fishDir, 'fish_history') - writeFileSync(foreign, 'the user’s real history\n') - - expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false) - expect(existsSync(foreign)).toBe(true) - }) - - // Why skipped on Windows: symlinkSync needs Developer Mode or elevation there. - it.skipIf(process.platform === 'win32')( - 'refuses to follow a symlink out of the fish data dir', - () => { - const outside = join(root, 'precious.txt') - writeFileSync(outside, 'keep me\n') - symlinkSync(outside, historyFile(SESSION_A)) - - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false) - expect(existsSync(outside)).toBe(true) - } - ) - - it('reports false when there is nothing to delete', () => { - expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false) - }) -}) - -describe('orphaned fish history sweep', () => { - let root: string - let fishDir: string - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-fish-sweep-')) - fishDir = join(root, 'fish') - mkdirSync(fishDir, { recursive: true }) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('removes files for dead worktrees and keeps live ones', () => { - writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`), 'live\n') - writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(1) - expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true) - expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false) - }) - - it("never touches the user's own history files", () => { - // The whole safety of the sweep rests on the orca__ prefix. - const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history'] - for (const name of untouched) { - writeFileSync(join(fishDir, name), 'mine\n') - } - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0) - for (const name of untouched) { - expect(existsSync(join(fishDir, name))).toBe(true) - } - }) - - it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => { - writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - - expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0) - expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true) - }) - - it('spares a file younger than the age guard, since the live set is a snapshot', () => { - // The reachable race: a worktree created after the snapshot but before the - // sweep looks dead while it is actively writing the history it owns. - const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`) - writeFileSync(fresh, 'just created\n') - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0) - expect(existsSync(fresh)).toBe(true) - }) - - it('sweeps a file older than the age guard', () => { - const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`) - writeFileSync(stale, 'long dead\n') - const wellPast = Date.now() + 60 * 60 * 1000 - - expect( - sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast) - ).toBe(1) - expect(existsSync(stale)).toBe(false) - }) - - it('tolerates a directory that does not exist', () => { - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0) - }) - - it('sweeps every candidate directory once', () => { - const other = join(root, 'other', 'fish') - mkdirSync(other, { recursive: true }) - for (const dir of [fishDir, other]) { - writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n') - } - - expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2) - }) -}) diff --git a/src/main/fish-history-session.ts b/src/main/fish-history-session.ts deleted file mode 100644 index 7ab56a59490..00000000000 --- a/src/main/fish-history-session.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { lstatSync, readdirSync, rmSync } from 'node:fs' -import { homedir } from 'node:os' -import { isAbsolute, join } from 'node:path' - -/** - * Per-worktree fish history. - * - * fish ignores HISTFILE entirely — history lives at `/_history` - * where `` comes only from the `fish_history` variable, and there is no - * custom-directory knob. So the session NAME is the only isolation lever, and - * the file necessarily lands in the user's fish data dir rather than inside - * Orca's history tree. That is why deletion needs its own path here instead of - * riding the tree tombstone the way bash/zsh history does. - * - * `XDG_DATA_HOME` is deliberately not redirected: that would move every other - * tool's data for the session, not just fish's history. - */ -const SESSION_PREFIX = 'orca_' -const SAFE_SESSION_NAME = /^orca_[0-9a-f]{1,64}$/ -const ORCA_HISTORY_FILE = /^orca_([0-9a-f]{1,64})_history$/ - -export function isSafeFishHistorySession(session: unknown): session is string { - return typeof session === 'string' && SAFE_SESSION_NAME.test(session) -} - -export function fishHistorySessionName(worktreeHash: string): string { - return `${SESSION_PREFIX}${worktreeHash}` -} - -/** The directory fish will write history into, for a given environment. - * Resolve this from the PTY's SPAWN env: Orca can be launched with a different - * `XDG_DATA_HOME`/`HOME` than the shells it spawns, and fish follows its own. */ -export function resolveFishHistoryDir(env: NodeJS.ProcessEnv = process.env): string { - const xdg = env.XDG_DATA_HOME?.trim() - const dataHome = - xdg && isAbsolute(xdg) ? xdg : join(env.HOME?.trim() || homedir(), '.local', 'share') - return join(dataHome, 'fish') -} - -/** Removes one history file, refusing anything that is not a regular file. - * Why lstat: a symlink here would take `rmSync` out of the fish data dir. */ -function removeHistoryFile(path: string): boolean { - try { - if (!lstatSync(path).isFile()) { - return false - } - rmSync(path) - return true - } catch { - return false - } -} - -/** Delete one worktree's fish history file, trying every directory it could be in. - * Callers pass the directory recorded at spawn time plus this process's own, since - * the two disagree whenever Orca's environment differs from the PTY's. */ -export function deleteFishHistoryFile(session: string, dirs: Iterable): boolean { - if (!isSafeFishHistorySession(session)) { - return false - } - let removed = false - for (const dir of new Set(dirs)) { - removed = removeHistoryFile(join(dir, `${session}_history`)) || removed - } - return removed -} - -/** - * Delete every Orca-minted fish history file whose worktree is gone. - * - * Why a sweep and not just per-worktree deletion: the history file outlives the - * directory that names it. A crash between tombstone and removal, a hand-deleted - * history dir, or a file written by an older build all orphan one, and only a - * scan of the data dir can find those. Matching on Orca's own `orca__` - * prefix means the user's real `fish_history` is never a candidate. - * - * `minAgeMs` mirrors the age guard the history-tree GC already applies: the - * live-worktree set is a snapshot, so a worktree created between the snapshot - * and this sweep would otherwise look dead and lose the history it is actively - * writing. A live session's file keeps a recent mtime, so this doubles as a - * second check on the set itself. - * - * Refuses to run at all on an empty live set: that cannot be told apart from a - * store that failed to hydrate, and unlike the tree GC this deletes files - * outside Orca's own directory. - */ -export function sweepOrphanedFishHistoryFiles( - liveWorktreeHashes: ReadonlySet, - dirs: Iterable, - minAgeMs = 0, - now = Date.now() -): number { - if (liveWorktreeHashes.size === 0) { - return 0 - } - let removed = 0 - for (const dir of new Set(dirs)) { - let entries: string[] - try { - entries = readdirSync(dir) - } catch { - continue - } - for (const entry of entries) { - const hash = ORCA_HISTORY_FILE.exec(entry)?.[1] - if (!hash || liveWorktreeHashes.has(hash)) { - continue - } - const path = join(dir, entry) - if (minAgeMs > 0 && !isOlderThan(path, minAgeMs, now)) { - continue - } - if (removeHistoryFile(path)) { - removed += 1 - } - } - } - return removed -} - -/** Treats an unreadable file as too young: never delete on a failed stat. */ -function isOlderThan(path: string, minAgeMs: number, now: number): boolean { - try { - return now - lstatSync(path).mtimeMs >= minAgeMs - } catch { - return false - } -} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 81ed2eb307d..2a6b3ebed84 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4741,8 +4741,7 @@ export function registerPtyHandlers( rows: args.rows, cwd, env, - ...(isNewDaemonSession ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true + ...(isNewDaemonSession ? { isNewSession: true } : {}) } if (!args.connectionId && !isDaemonHostSpawn) { spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } @@ -6462,8 +6461,7 @@ export function registerPtyHandlers( cwd, ...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}), env: spawnEnv, - ...(isMintedSessionId ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true + ...(isMintedSessionId ? { isNewSession: true } : {}) } if (!args.connectionId && !isDaemonHostSpawn) { spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c7ec109e2e6..beebaf70058 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -265,7 +265,6 @@ import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval, recoverLocalWindowsWorktreeRemoval } from '../local-worktree-removal-recovery' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() @@ -2494,15 +2493,15 @@ export function registerWorktreeHandlers( 'Cannot delete the project root workspace. Remove the folder project instead.' ) } - const ownerHost = parseExecutionHostId(removalHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly // like the git paths — the local inventory must never reach a remote workspace's id. // The resolved repo is authoritative here: path-derived metadata is shared by // same-id host copies and can describe a different owner's workspace. + const ownerHost = parseExecutionHostId(removalHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' await killAllProcessesForWorktree(args.worktreeId, { runtime, @@ -2525,7 +2524,6 @@ export function registerWorktreeHandlers( }) }) await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) removeWorktreeMetadataAndTransientState( store, args.worktreeId, @@ -2614,14 +2612,6 @@ export function registerWorktreeHandlers( } finally { await removalGate.finish(removalCompleted) } - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -2730,14 +2720,6 @@ export function registerWorktreeHandlers( } // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. if (repo.connectionId) { - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, @@ -2916,10 +2898,6 @@ export function registerWorktreeHandlers( removedPushTarget, store ) - await deleteRemoteWorktreeHistory( - getSshPtyProvider(remoteConnectionId), - args.worktreeId - ) rememberPreservedBranchCleanupTarget( args.worktreeId, removalHostId, diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index cf98ca7cf0b..af0e17656bd 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -19,10 +19,8 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' import { injectHistoryEnv, - injectWslFishHistoryEnv, - updateHistoryEnvForFallback, - logHistoryInjection, - type HistoryInjectionResult + updateHistFileForFallback, + logHistoryInjection } from '../terminal-history' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { @@ -874,15 +872,7 @@ export class LocalPtyProvider implements IPtyProvider { historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, { wslDistro: launchWslDistro }) - if (isWslTerminal && launchWslDistro) { - injectWslFishHistoryEnv(finalEnv, worktreeId, launchWslDistro) - addWslEnvKeys(finalEnv, ['HISTFILE', 'fish_history']) - } logHistoryInjection(worktreeId, historyResult) - } else { - // Why: injectHistoryEnv is what normally clears it, so when history is off - // an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146. - delete finalEnv.ORCA_HISTFILE } await prepareLocalPtySpawn(id) @@ -905,9 +895,8 @@ export class LocalPtyProvider implements IPtyProvider { ptySpawn: pty.spawn, getShellReadyConfig: getFallbackShellReadyConfig, // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). - onBeforeFallbackSpawn: historyResult?.historyDir - ? (env, fallbackShell) => - updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult) + onBeforeFallbackSpawn: historyResult?.histFile + ? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell) : undefined, windowsFallbackAttempts }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts index 7c32a283aa2..29863d0a169 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts @@ -11,8 +11,7 @@ import { getZshEnvTemplate, getZshFinalZdotdirRestoreBlock, getZshShellReadyMarkerRegistrationBlock, - getZshStartupFileSourceBlock, - ZSH_HISTFILE_RESTORE_BLOCK + getZshStartupFileSourceBlock } from '../shell-templates' import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' import { @@ -45,7 +44,6 @@ if [[ ! -o login ]]; then ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} fi __orca_osc133_precmd() { @@ -98,7 +96,6 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" -${ZSH_HISTFILE_RESTORE_BLOCK} ${getPosixCodexShellLaunchPreflight()} ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 3ebdb6827fb..43bbb15a125 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -70,8 +70,6 @@ export type PtySpawnOptions = { * Existing-session attach paths must stay false so recovery checks do not * replace the daemon out from under a still-live PTY. */ isNewSession?: boolean - /** Host setting forwarded additively to the process owner; old owners ignore it. */ - historyIsolationEnabled?: boolean /** Attach the named session atomically or fail without creating a process. */ attachOnly?: boolean /** Exact persisted owner expected by an attach-only routing decision. */ @@ -121,8 +119,6 @@ export type IPtyProvider = { /** Re-probes a degraded durable host before main commits to fallback spawn semantics. */ recoverFreshSpawnRouting?: () => Promise spawn(opts: PtySpawnOptions): Promise - /** Process-owner cleanup for history stored outside the workspace tree. */ - deleteWorktreeHistory?: (worktreeId: string) => Promise /** Whether this spawn target can append the Git guard after its final env merge. */ supportsGitCredentialGuardHost?: (sessionId?: string) => boolean /** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */ diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index b3b64be7a71..40301906f52 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -369,16 +369,14 @@ describe('SshPtyProvider', () => { }) }) - it('forwards shell, WSL, and scoped-history options to the relay mux', async () => { + it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => { mux.request.mockResolvedValue({ id: 'pty-2' }) await provider.spawn({ cols: 120, rows: 40, shellOverride: 'powershell.exe', - terminalWindowsWslDistro: 'Ubuntu', - worktreeId: 'repo-1::/remote/wt', - historyIsolationEnabled: true + terminalWindowsWslDistro: 'Ubuntu' }) expectRequest(mux.request, 'pty.spawn', { @@ -387,9 +385,7 @@ describe('SshPtyProvider', () => { cwd: undefined, env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }, shellOverride: 'powershell.exe', - terminalWindowsWslDistro: 'Ubuntu', - worktreeId: 'repo-1::/remote/wt', - historyIsolationEnabled: true + terminalWindowsWslDistro: 'Ubuntu' }) }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 99df854f4d0..eb24ffdbb8b 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -14,7 +14,7 @@ import { spawnFreshSshPty } from './ssh-agent-session-create-operation' import { mapSshPtyProcessList } from './ssh-agent-session-process-list' import { requestSshPtyAttach, - reattachSshPtySessionForSpawn, + reattachSshPtySessionWithExitFence, type PtySourceRecoveryRequest, type SshPtyAttachResult } from './ssh-pty-session-reattach' @@ -22,6 +22,7 @@ import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' +import { SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors' import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. @@ -90,18 +91,36 @@ export class SshPtyProvider implements IPtyProvider { } } if (opts.sessionId) { - return await reattachSshPtySessionForSpawn({ - mux: this.mux, - connectionId: this.connectionId, - sessionId: opts.sessionId, - options: opts, - exitRaceTracker: this.spawnExitRaces, - installSourceActivation: (relayPtyId, activation) => - this.outputState.installReceivingActivation(relayPtyId, activation), - rememberPtyIncarnation: (relayPtyId, incarnationId) => - this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId), - acceptLivePty: (relayPtyId) => this.livePtyIds.add(relayPtyId) - }) + let result: Awaited> | undefined + try { + result = await reattachSshPtySessionWithExitFence({ + mux: this.mux, + connectionId: this.connectionId, + sessionId: opts.sessionId, + options: opts, + exitRaceTracker: this.spawnExitRaces, + installSourceActivation: (relayPtyId, activation) => + this.outputState.installReceivingActivation(relayPtyId, activation), + rememberPtyIncarnation: (relayPtyId, incarnationId) => + this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId) + }) + if (result.sourceRecovery?.status === 'restoreRequired') { + throw new Error( + `${SSH_SESSION_EXPIRED_ERROR}: ${toRelaySshPtyId(this.connectionId, result.id)}` + ) + } + this.livePtyIds.add(result.id) + result.sourceActivationLease?.commit() + const { + sourceActivationLease: _lease, + sourceRecovery: _sourceRecovery, + ...spawnResult + } = result + return spawnResult + } catch (error) { + result?.sourceActivationLease?.rollback() + throw error + } } const supportsCreateOperation = opts.agentSessionCreateOperationId @@ -132,10 +151,6 @@ export class SshPtyProvider implements IPtyProvider { }) } - async deleteWorktreeHistory(worktreeId: string): Promise { - await this.mux.request('pty.deleteWorktreeHistory', { worktreeId }) - } - async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise { return await this.agentSessionCapabilities.supportsClaims(options) } diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index 06e4c9ae7c5..34974940caf 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -255,39 +255,3 @@ export async function reattachSshPtySessionWithExitFence( args.exitRaceTracker.finish(operation) } } - -/** - * The full reattach path a spawn takes when it carries a sessionId: fence the - * exit race, reject a session the relay can no longer restore, and commit or - * roll back the source-activation lease. - * - * Lives here rather than in SshPtyProvider.spawn so the lease's commit and - * rollback stay in one place — a caller that only wrapped the fence could - * return without committing and silently leak the activation. - */ -export async function reattachSshPtySessionForSpawn( - args: Parameters[0] & { - acceptLivePty: (relayPtyId: string) => void - } -): Promise { - let result: SshPtyReattachResult | undefined - try { - result = await reattachSshPtySessionWithExitFence(args) - if (result.sourceRecovery?.status === 'restoreRequired') { - throw new Error( - `${SSH_SESSION_EXPIRED_ERROR}: ${toRelaySshPtyId(args.connectionId, result.id)}` - ) - } - args.acceptLivePty(result.id) - result.sourceActivationLease?.commit() - const { - sourceActivationLease: _lease, - sourceRecovery: _sourceRecovery, - ...spawnResult - } = result - return spawnResult - } catch (error) { - result?.sourceActivationLease?.rollback() - throw error - } -} diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 9f493d9e778..889c0813ee9 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -22,10 +22,6 @@ export function buildSshPtySpawnRequest(args: { // Why: the relay needs launch identity for plugin env overlays and provider-side delivery. ...(options.command ? { command: options.command } : {}), ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), - ...(options.worktreeId ? { worktreeId: options.worktreeId } : {}), - ...(options.historyIsolationEnabled !== undefined - ? { historyIsolationEnabled: options.historyIsolationEnabled } - : {}), ...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}), ...(options.terminalWindowsWslDistro !== undefined ? { terminalWindowsWslDistro: options.terminalWindowsWslDistro } diff --git a/src/main/remote-worktree-history-cleanup.test.ts b/src/main/remote-worktree-history-cleanup.test.ts deleted file mode 100644 index ce0f23a60f9..00000000000 --- a/src/main/remote-worktree-history-cleanup.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { deleteRemoteWorktreeHistory } from './remote-worktree-history-cleanup' - -describe('deleteRemoteWorktreeHistory', () => { - it('repeats idempotent cleanup through the PTY owner', async () => { - const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined) - const provider = { deleteWorktreeHistory } as never - - await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt') - await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt') - - expect(deleteWorktreeHistory).toHaveBeenCalledTimes(2) - }) - - it('degrades safely when an old relay does not expose cleanup', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const provider = { - deleteWorktreeHistory: vi.fn().mockRejectedValue(new Error('unknown request method')) - } as never - - try { - await expect( - deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt') - ).resolves.toBeUndefined() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('Remote cleanup unavailable')) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/remote-worktree-history-cleanup.ts b/src/main/remote-worktree-history-cleanup.ts deleted file mode 100644 index 8842758d15e..00000000000 --- a/src/main/remote-worktree-history-cleanup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IPtyProvider } from './providers/pty-provider-contract' - -/** Best-effort cleanup through the PTY owner; older relays may not expose the method. */ -export async function deleteRemoteWorktreeHistory( - provider: IPtyProvider | undefined, - worktreeId: string -): Promise { - try { - await provider?.deleteWorktreeHistory?.(worktreeId) - } catch (error) { - console.warn( - `[pty:history] Remote cleanup unavailable: ${error instanceof Error ? error.message : String(error)}` - ) - } -} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3938f426b6d..4c2dd2fbdd7 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -6455,8 +6455,7 @@ describe('OrcaRuntimeService', () => { worktreeId: `${TEST_REPO_ID}::/remote/feature` } ]), - shutdown: vi.fn().mockResolvedValue(undefined), - deleteWorktreeHistory: vi.fn().mockResolvedValue(undefined) + shutdown: vi.fn().mockResolvedValue(undefined) } const runtime = new OrcaRuntimeService(remoteStore as never, undefined, { getSshProvider: () => ptyProvider as never @@ -6473,9 +6472,6 @@ describe('OrcaRuntimeService', () => { 'pty-remote', expect.objectContaining({ immediate: true }) ) - expect(ptyProvider.deleteWorktreeHistory).toHaveBeenCalledWith( - `${TEST_REPO_ID}::/remote/feature` - ) expect(ptyProvider.shutdown.mock.invocationCallOrder[0]).toBeLessThan( gitProvider.removeWorktree.mock.invocationCallOrder[0] ) @@ -33144,7 +33140,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).not.toHaveBeenCalled() }) - it('uses portable Unix quoting for mobile agent launch commands in WSL project runtimes', async () => { + it('uses POSIX quoting for mobile agent launch commands in WSL project runtimes', async () => { await withPlatform('win32', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) const runtime = new OrcaRuntimeService({ @@ -33182,7 +33178,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - command: `command-code --profile mobile '--note' 'can'"'"'t'`, + command: "command-code --profile mobile '--note' 'can'\\''t'", cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID }) @@ -48421,145 +48417,6 @@ describe('OrcaRuntimeService', () => { } }) - it('routes already-missing SSH runtime history cleanup through the PTY owner', async () => { - const repo = { - id: 'repo-runtime-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const worktreeId = `${repo.id}::/remote/already-deleted` - const metaById: Record = { - [worktreeId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', orcaCreationSource: 'ssh' }) - } - const removeWorktreeMeta = vi.fn((id: string) => { - delete metaById[id] - }) - const runtimeStore = { - ...store, - getRepos: () => [repo], - getRepo: (id: string) => (id === repo.id ? repo : undefined), - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (id: string) => metaById[id], - removeWorktreeMeta - } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - } - const fsProvider = { - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - } - const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined) - const ptyProvider = { deleteWorktreeHistory } as never - registerSshGitProvider(repo.connectionId, gitProvider as never) - registerSshFilesystemProvider(repo.connectionId, fsProvider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { - getSshProvider: () => ptyProvider - }) - - try { - await expect(runtime.removeManagedWorktree(`id:${worktreeId}`)).resolves.toEqual({}) - } finally { - unregisterSshGitProvider(repo.connectionId) - unregisterSshFilesystemProvider(repo.connectionId) - } - - expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId) - expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan( - removeWorktreeMeta.mock.invocationCallOrder[0] - ) - expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-1') - }) - - it('routes SSH runtime orphan-directory history cleanup through the PTY owner', async () => { - const repo = { - id: 'repo-runtime-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const worktreePath = '/remote/orphan' - const worktreeId = `${repo.id}::${worktreePath}` - const metaById: Record = { - [worktreeId]: makeWorktreeMeta({ - hostId: 'ssh:ssh-1', - orcaCreatedAt: Date.now(), - orcaCreationSource: 'ssh' - }) - } - const removeWorktreeMeta = vi.fn((id: string) => { - delete metaById[id] - }) - const runtimeStore = { - ...store, - getRepos: () => [repo], - getRepo: (id: string) => (id === repo.id ? repo : undefined), - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (id: string) => metaById[id], - removeWorktreeMeta - } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: repo.path, - head: 'main', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - } - const fsProvider = { - lstat: vi.fn(async (path: string) => ({ - type: path === `${worktreePath}/.git` ? 'file' : 'directory' - })), - readFile: vi.fn(async (path: string) => ({ - isBinary: false, - content: - path === `${worktreePath}/.git` - ? `gitdir: ${repo.path}/.git/worktrees/orphan\n` - : `${worktreePath}/.git\n` - })), - deletePath: vi.fn().mockResolvedValue(undefined) - } - const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined) - const ptyProvider = { - listProcesses: vi.fn().mockResolvedValue([]), - shutdown: vi.fn().mockResolvedValue(undefined), - deleteWorktreeHistory - } - registerSshGitProvider(repo.connectionId, gitProvider as never) - registerSshFilesystemProvider(repo.connectionId, fsProvider as never) - const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { - getSshProvider: () => ptyProvider as never - }) - - try { - await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).resolves.toEqual({}) - } finally { - unregisterSshGitProvider(repo.connectionId) - unregisterSshFilesystemProvider(repo.connectionId) - } - - expect(fsProvider.deletePath).toHaveBeenCalledWith(worktreePath, true) - expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId) - expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan( - removeWorktreeMeta.mock.invocationCallOrder[0] - ) - }) - it('force-removes a legacy Orca-created runtime orphaned worktree directory after Git tracking is gone', async () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-')) const repoPath = join(parentDir, 'repo') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6276266127e..69b7c342438 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1032,7 +1032,6 @@ import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' import { cleanupUnusedWorktreePushTargetRemote, cleanupUnusedWorktreePushTargetRemoteSsh, @@ -25794,7 +25793,6 @@ export class OrcaRuntimeService { .then((gate) => gate.finish(false)) .catch(() => {}) } - await deleteRemoteWorktreeHistory(sshPtyProvider, removalTarget.id) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) @@ -25839,7 +25837,6 @@ export class OrcaRuntimeService { console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) } - await deleteRemoteWorktreeHistory(folderSshPtyProvider, removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() @@ -25924,10 +25921,6 @@ export class OrcaRuntimeService { removedPushTarget, store ) - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) } else { const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) let removalCompleted = false @@ -26033,12 +26026,6 @@ export class OrcaRuntimeService { store, localWorktreeGitOptions )) - if (repo.connectionId) { - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) - } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByScope.delete(cleanupScopeKey) @@ -26132,10 +26119,6 @@ export class OrcaRuntimeService { removedPushTarget, store ) - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, cleanupHostId, diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 0b6bced3760..97288a2b971 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -1,7 +1,6 @@ // Why: local PTYs and the daemon/SSH path must use identical ZDOTDIR discovery; // small drift here breaks different terminal transports in different ways. - function quotePosixSingle(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } @@ -104,29 +103,6 @@ unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_ ` } -/** - * Restores the worktree-scoped HISTFILE that macOS `/etc/zshrc` destroys. - * - * That file assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no - * check-before-set, and it runs before any wrapper file Orca controls — so the - * injected value is already gone, and because ZDOTDIR still points at Orca's - * wrapper dir the replacement lands INSIDE it. Per-worktree history was a - * silent no-op on the primary platform as a result (#11044). - * - * Emitted after the user's own startup file has been sourced, so it is the last - * word, exactly like the CODEX_HOME/OPENCODE_CONFIG_DIR restores beside it. - */ -export const ZSH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" -elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="\${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" -fi` - export function getZshStartupFileSourceBlock(options: { fileName: '.zprofile' | '.zshrc' | '.zlogin' homeExpression?: string @@ -192,13 +168,11 @@ fi ` } -// Why: fish has no ZDOTDIR-style wrapper dir, so the marker rides `--init-command`, -// which fish runs AFTER config.fish (verified on 4.7.1) — the same last-word -// guarantee the zsh/bash wrapper files rely on. It fires on fish_prompt, the -// earliest event fish exposes (STA-3417). Unlike zsh's zle-line-init this lands -// just *before* fish arms `?2004h`, which PostReadyFlushGate absorbs. `builtin -// printf` so a user-defined printf can't silently swallow the marker and send -// every launch to the ready timeout. +// Why: fish has no ZDOTDIR-style wrapper dir, so the marker rides `--init-command` +// and fires on fish_prompt — the earliest event fish exposes (STA-3417). Unlike zsh's +// zle-line-init this lands just *before* fish arms `?2004h`, which PostReadyFlushGate +// absorbs. `builtin printf` so a user-defined printf can't silently swallow the marker +// and send every launch to the ready timeout. export function getFishShellReadyInitCommand(escapedMarker: string): string { return `if test "$ORCA_SHELL_READY_MARKER" = 1 function __orca_shell_ready_marker --on-event fish_prompt diff --git a/src/main/terminal-history-deletion.ts b/src/main/terminal-history-deletion.ts index 1fea1128ebe..b17eaa7ba70 100644 --- a/src/main/terminal-history-deletion.ts +++ b/src/main/terminal-history-deletion.ts @@ -1,39 +1,24 @@ -import { basename, dirname, join } from 'node:path' +import { basename, join } from 'node:path' import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs' import { removeHostTree } from './host-tree-removal' -import { deleteFishHistoryFile, resolveFishHistoryDir } from './fish-history-session' -import { readHistoryMeta } from './terminal-history' import { getHistoryRoot, + hashWorktreeId, listWslHistoryRoots, PENDING_DELETE_DIR_NAME } from './terminal-history-paths' -import { hashWorktreeId } from './terminal-history-id' -import { deleteWslFishHistoryFile } from './wsl-fish-history-cleanup' const pendingHistoryTreeRemovals = new Map>() -export const MAX_PENDING_HISTORY_TREE_REMOVALS = 64 // Why: a tombstone that fails once (Windows EBUSY under AV) would otherwise sit on disk for the whole // desktop session — only the next launch re-queues it. Bounded so a genuinely stuck tree stops retrying. export const HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS = [30_000, 120_000] const historyTreeRemovalAttempts = new Map() const historyTreeRemovalRetryTimers = new Map>() -const wslDistroByTombstone = new Map() - -function wslDistroForHistoryRoot(historyRoot: string): string | undefined { - return basename(dirname(historyRoot)) === 'terminal-history-wsl' - ? basename(historyRoot) - : undefined -} function getPendingDeleteRoot(historyRoot: string): string { return join(historyRoot, PENDING_DELETE_DIR_NAME) } -function historyRootForTombstone(dir: string): string { - return dirname(dirname(dir)) -} - /** Move a history tree to a pending-delete tombstone (metadata-only) so the critical path never walks it. */ function tombstoneHistoryTree(dir: string, historyRoot: string): string | null { if (!existsSync(dir)) { @@ -66,7 +51,6 @@ function scheduleHistoryTreeRemovalRetry(dir: string): void { if (retryDelayMs === undefined) { // Out of in-process attempts: the tombstone stays on disk and the next startup drain re-queues it. historyTreeRemovalAttempts.delete(dir) - wslDistroByTombstone.delete(dir) return } historyTreeRemovalAttempts.set(dir, attempt + 1) @@ -78,46 +62,19 @@ function scheduleHistoryTreeRemovalRetry(dir: string): void { historyTreeRemovalRetryTimers.set(dir, timer) } -function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { +function scheduleHistoryTreeRemoval(dir: string): void { if (pendingHistoryTreeRemovals.has(dir)) { return } - // Leave excess tombstones on disk; admission is intentionally bounded. - if ( - pendingHistoryTreeRemovals.size + historyTreeRemovalRetryTimers.size >= - MAX_PENDING_HISTORY_TREE_REMOVALS - ) { - return - } - // A rescan must not cancel a delayed retry for a real failure. + // A rescan (GC / startup drain) hitting the same tombstone supersedes its pending retry. const pendingRetry = historyTreeRemovalRetryTimers.get(dir) if (pendingRetry) { - return + clearTimeout(pendingRetry) + historyTreeRemovalRetryTimers.delete(dir) } - if (wslDistro) { - wslDistroByTombstone.set(dir, wslDistro) - } - let removalSucceeded = false - const cleanupDistro = wslDistroByTombstone.get(dir) - const meta = cleanupDistro ? readHistoryMeta(dir) : null - const cleanup = - cleanupDistro && meta?.fishSession - ? // Why swallow rather than rethrow: fish history cleanup is best effort - // and runs `wsl.exe --exec fish`, which fails outright on a distro that - // has no fish — which is most of them. Rethrowing chained that failure - // into removeHostTree below, so those users' history trees were never - // reclaimed at all. - deleteWslFishHistoryFile(cleanupDistro, meta.fishSession).catch((err: unknown) => { - console.warn( - `[pty:history] Failed to delete WSL fish history: ${err instanceof Error ? err.message : String(err)}` - ) - }) - : null - const removal = (cleanup ? cleanup.then(() => removeHostTree(dir)) : removeHostTree(dir)) + const removal = removeHostTree(dir) .then(() => { - removalSucceeded = true historyTreeRemovalAttempts.delete(dir) - wslDistroByTombstone.delete(dir) }) .catch((err: unknown) => { console.warn( @@ -129,9 +86,6 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { if (pendingHistoryTreeRemovals.get(dir) === removal) { pendingHistoryTreeRemovals.delete(dir) } - if (removalSucceeded) { - schedulePendingHistoryTreeRemovals(historyRootForTombstone(dir)) - } }) pendingHistoryTreeRemovals.set(dir, removal) } @@ -139,23 +93,11 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { /** Tombstone one history tree and queue its recursive removal off the caller's critical path. * Returns false when the rename failed, leaving the tree for a later GC pass to reclaim. */ export function scheduleWorktreeHistoryTreeDeletion(dir: string, historyRoot: string): boolean { - // Why first: fish keeps its history in the user's fish data dir, outside this tree, - // so the meta.json naming the session must still be readable when we look it up. - const meta = readHistoryMeta(dir) - const wslDistro = wslDistroForHistoryRoot(historyRoot) - if (meta?.fishSession && !wslDistro) { - // Why both directories: the recorded one is what the PTY's fish saw, this - // process's own is the fallback when meta.json predates that field. - deleteFishHistoryFile(meta.fishSession, [ - ...(meta.fishHistoryDir ? [meta.fishHistoryDir] : []), - resolveFishHistoryDir() - ]) - } const tombstone = tombstoneHistoryTree(dir, historyRoot) if (!tombstone) { return false } - scheduleHistoryTreeRemoval(tombstone, wslDistro) + scheduleHistoryTreeRemoval(tombstone) return true } @@ -167,7 +109,7 @@ export function schedulePendingHistoryTreeRemovals(historyRoot: string): void { } try { for (const entry of readdirSync(pendingRoot)) { - scheduleHistoryTreeRemoval(join(pendingRoot, entry), wslDistroForHistoryRoot(historyRoot)) + scheduleHistoryTreeRemoval(join(pendingRoot, entry)) } } catch { // Non-fatal. @@ -189,7 +131,6 @@ export function cancelPendingHistoryTreeRemovalRetries(): void { } historyTreeRemovalRetryTimers.clear() historyTreeRemovalAttempts.clear() - wslDistroByTombstone.clear() } /** Drain every history root's tombstones and await the in-flight removals. Tests only: production diff --git a/src/main/terminal-history-fish-session.node-pty.test.ts b/src/main/terminal-history-fish-session.node-pty.test.ts deleted file mode 100644 index f4d7b3cf404..00000000000 --- a/src/main/terminal-history-fish-session.node-pty.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Real-fish proof for worktree-scoped fish history. - * - * fish IGNORES HISTFILE, so the directory+filename mechanism bash and zsh use does - * not transfer: history lives at `$XDG_DATA_HOME/fish/${fish_history}_history` and - * the only isolation knob is the session NAME. This suite pins the two facts - * `injectHistoryEnv` bets on — that fish picks up `fish_history` from the spawn - * environment (it imports env vars as global variables at startup), and that the - * file it then writes is the one `resolveFishHistoryDir` points at. - * - * Interactive is mandatory: fish writes no history in non-interactive mode, so the - * PTY and the typed line are the test, not scaffolding. DA1/CPR/OSC-11 probes are - * answered here because no real xterm is attached — without them fish stalls ~10s - * on its DA1 read sentinel before painting a prompt. - */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { fishRequirementViolation, resolveFishBinary } from '../shared/fish-binary-requirement' -import { fishHistorySessionName, resolveFishHistoryDir } from './fish-history-session' - -const FISH = resolveFishBinary(4) -const itWithFish = FISH.available ? it : it.skip - -const PROMPT_MARK = 'ORCAHIST> ' -const WORKTREE_HASH = 'deadbeefdeadbeef' -const MARKER = 'echo orca-worktree-scoped-history' - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (predicate()) { - return true - } - await sleep(20) - } - return false -} - -describe('fish keeps per-worktree history under the session Orca names', () => { - let home: string | null = null - - // Always runs, so the CI lane cannot report green with the regression below skipped. - it('has the fish this suite needs when CI requires one', () => { - expect(fishRequirementViolation(FISH)).toBeNull() - }) - - afterEach(() => { - if (home) { - rmSync(home, { recursive: true, force: true }) - home = null - } - }) - - itWithFish( - 'writes an interactive command to $XDG_DATA_HOME/fish/_history, not the shared file', - async () => { - const nodePty = await import('node-pty') - - home = mkdtempSync(path.join(tmpdir(), 'orca-fish-history-')) - const dataHome = path.join(home, 'data') - mkdirSync(path.join(home, 'fish'), { recursive: true }) - writeFileSync( - path.join(home, 'fish/config.fish'), - [ - 'set -g fish_greeting ""', - `function fish_prompt; printf '${PROMPT_MARK}'; end`, - 'function fish_right_prompt; end', - '' - ].join('\n') - ) - - const session = fishHistorySessionName(WORKTREE_HASH) - const term = nodePty.spawn(FISH.path as string, ['-l', '-i'], { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: home, - // Fully pinned: no ambient HOME/XDG_* reaches fish, so this cannot pass - // only on a machine whose real fish config happens to cooperate. - env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - HOME: home, - TERM: 'xterm-256color', - // LC_ALL wins over any LANG/LC_* a host might contribute, pinning fish's locale. - LANG: 'en_US.UTF-8', - LC_ALL: 'en_US.UTF-8', - XDG_CONFIG_HOME: home, - XDG_DATA_HOME: dataHome, - // The production injection under test (terminal-history.ts). - fish_history: session - } - }) - - let rendered = '' - term.onData((chunk) => { - rendered += chunk - if (chunk.includes('\x1b[0c') || chunk.includes('\x1b[c')) { - term.write('\x1b[?62;4;6;22c') - } - if (chunk.includes('\x1b[6n')) { - term.write('\x1b[1;1R') - } - if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) { - term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\') - } - }) - let exited = false - term.onExit(() => { - exited = true - }) - - expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true) - term.write(`${MARKER}\r`) - expect(await waitUntil(() => rendered.includes('orca-worktree-scoped-history'), 5_000)).toBe( - true - ) - // fish flushes history on exit, so the read must wait for the process to go. - term.write('exit\r') - expect(await waitUntil(() => exited, 10_000)).toBe(true) - try { - term.kill() - } catch { - // already gone - } - - const scopedPath = path.join( - resolveFishHistoryDir({ XDG_DATA_HOME: dataHome }), - `${session}_history` - ) - expect(scopedPath).toBe(path.join(dataHome, 'fish', `${session}_history`)) - const scoped = readFileSync(scopedPath as string, 'utf8') - // YAML-ish records, not one line per command — any reader must handle this shape. - expect(scoped).toContain(`- cmd: ${MARKER}`) - expect(scoped).toMatch(/^ {2}when: \d+$/m) - - // Isolation: the default session file fish would otherwise have used is absent. - expect(() => readFileSync(path.join(dataHome, 'fish', 'fish_history'), 'utf8')).toThrow() - }, - 40_000 - ) -}) diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts index 2f3fdabaff1..2a6d2280945 100644 --- a/src/main/terminal-history-gc.ts +++ b/src/main/terminal-history-gc.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { existsSync, readdirSync, statSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { getHistoryRoot, listWslHistoryRoots, @@ -9,9 +9,6 @@ import { schedulePendingHistoryTreeRemovals, scheduleWorktreeHistoryTreeDeletion } from './terminal-history-deletion' -import { readHistoryMeta } from './terminal-history' -import { resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session' -import { hashWorktreeId } from './terminal-history-id' // Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is // taken just before. A worktree created between the snapshot and GC execution @@ -28,21 +25,8 @@ let historyGcRunning = false function gcScanRoot( root: string, liveWorktreeIds: Set -): { - totalDirs: number - orphaned: number - pruned: number - totalSizeKB: number - /** Every fish data dir a meta.json in this root names, for the orphan sweep. */ - fishHistoryDirs: Set -} { - const result = { - totalDirs: 0, - orphaned: 0, - pruned: 0, - totalSizeKB: 0, - fishHistoryDirs: new Set() - } +): { totalDirs: number; orphaned: number; pruned: number; totalSizeKB: number } { + const result = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } if (!existsSync(root)) { return result } @@ -77,11 +61,11 @@ function gcScanRoot( continue } - const meta = readHistoryMeta(entryPath) - if (meta?.fishHistoryDir) { - result.fishHistoryDirs.add(meta.fishHistoryDir) + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as { + worktreeId?: string + createdAt?: string } - if (!meta?.worktreeId) { + if (!meta.worktreeId) { continue } @@ -121,7 +105,6 @@ export function runHistoryGc(liveWorktreeIds: Set): void { // Also scan WSL history directories (each distro has its own subdirectory). const wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } - const liveFishHistoryDirs = new Set(main.fishHistoryDirs) for (const distroRoot of listWslHistoryRoots()) { schedulePendingHistoryTreeRemovals(distroRoot) const r = gcScanRoot(distroRoot, liveWorktreeIds) @@ -129,27 +112,6 @@ export function runHistoryGc(liveWorktreeIds: Set): void { wslTotals.orphaned += r.orphaned wslTotals.pruned += r.pruned wslTotals.totalSizeKB += r.totalSizeKB - for (const dir of r.fishHistoryDirs) { - liveFishHistoryDirs.add(dir) - } - } - - // Why a sweep on top of per-worktree deletion: a fish history file lives in - // the user's fish data dir, so it outlives the directory that names it. A - // crash between tombstone and removal, or a hand-deleted history dir, leaves - // one with nothing left to point at it. Collecting the dirs the live meta - // files name covers a machine whose XDG_DATA_HOME changed between runs. - const fishDirs = new Set([resolveFishHistoryDir()]) - for (const dir of liveFishHistoryDirs) { - fishDirs.add(dir) - } - const fishOrphans = sweepOrphanedFishHistoryFiles( - new Set([...liveWorktreeIds].map(hashWorktreeId)), - fishDirs, - GC_MIN_AGE_MS - ) - if (fishOrphans > 0) { - console.log(`[pty:history:gc] Swept ${fishOrphans} orphaned fish history file(s)`) } const totalDirs = main.totalDirs + wslTotals.totalDirs diff --git a/src/main/terminal-history-id.ts b/src/main/terminal-history-id.ts deleted file mode 100644 index 38454baa29e..00000000000 --- a/src/main/terminal-history-id.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createHash } from 'node:crypto' - -/** First 16 hex chars of SHA-256 of the worktreeId. */ -export function hashWorktreeId(worktreeId: string): string { - return createHash('sha256').update(worktreeId).digest('hex').slice(0, 16) -} diff --git a/src/main/terminal-history-paths.ts b/src/main/terminal-history-paths.ts index 263f39c8c35..e942dd8bc3f 100644 --- a/src/main/terminal-history-paths.ts +++ b/src/main/terminal-history-paths.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' @@ -7,7 +8,10 @@ const HISTORY_DIR_NAME_WSL = 'terminal-history-wsl' // Why: rename live history out of the way first so a quit mid-rm still leaves a durable tombstone GC can finish. export const PENDING_DELETE_DIR_NAME = '.pending-delete' -export { hashWorktreeId } from './terminal-history-id' +/** First 16 hex chars of SHA-256 of the worktreeId. */ +export function hashWorktreeId(worktreeId: string): string { + return createHash('sha256').update(worktreeId).digest('hex').slice(0, 16) +} export function getHistoryRoot(): string { return join(app.getPath('userData'), HISTORY_DIR_NAME) diff --git a/src/main/terminal-history-tombstone-retry.test.ts b/src/main/terminal-history-tombstone-retry.test.ts index 67cb9926322..195ee82d9f3 100644 --- a/src/main/terminal-history-tombstone-retry.test.ts +++ b/src/main/terminal-history-tombstone-retry.test.ts @@ -1,13 +1,12 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' let userDataDir: string -const { removeHostTreeMock, deleteWslFishHistoryFileMock } = vi.hoisted(() => ({ - removeHostTreeMock: vi.fn<(dir: string) => Promise>(), - deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise>() +const { removeHostTreeMock } = vi.hoisted(() => ({ + removeHostTreeMock: vi.fn<(dir: string) => Promise>() })) vi.mock('electron', () => ({ @@ -20,19 +19,11 @@ vi.mock('./host-tree-removal', () => ({ removeHostTree: removeHostTreeMock })) -vi.mock('./wsl-fish-history-cleanup', () => ({ - deleteWslFishHistoryFile: deleteWslFishHistoryFileMock -})) - import { hashWorktreeId } from './terminal-history-paths' -import { fishHistorySessionName } from './fish-history-session' import { cancelPendingHistoryTreeRemovalRetries, deleteWorktreeHistoryDir, - flushPendingWorktreeHistoryDeletions, - MAX_PENDING_HISTORY_TREE_REMOVALS, - HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS, - schedulePendingHistoryTreeRemovals + HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS } from './terminal-history-deletion' /** A tombstone whose rm fails once used to sit on disk for the rest of the session — only the next @@ -41,11 +32,6 @@ describe('tombstoned history removal retries', () => { beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-retry-')) removeHostTreeMock.mockReset() - deleteWslFishHistoryFileMock.mockReset() - deleteWslFishHistoryFileMock.mockResolvedValue(undefined) - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) vi.useFakeTimers() }) @@ -85,6 +71,7 @@ describe('tombstoned history removal retries', () => { it('does not re-arm a retry after the removal succeeds', async () => { seedWorktreeHistory('repo-1::/path/clean-wt') + removeHostTreeMock.mockResolvedValue(undefined) deleteWorktreeHistoryDir('repo-1::/path/clean-wt') await vi.advanceTimersByTimeAsync(0) @@ -92,118 +79,4 @@ describe('tombstoned history removal retries', () => { expect(removeHostTreeMock).toHaveBeenCalledTimes(1) }) - - it('admits a bounded tombstone batch and drains the rest from disk', async () => { - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - const releases: (() => void)[] = [] - removeHostTreeMock.mockImplementation( - (dir) => - new Promise((resolve) => - releases.push(() => { - rmSync(dir, { recursive: true, force: true }) - resolve() - }) - ) - ) - - for (let index = 0; index < 1_000; index++) { - const worktreeId = `repo-1::/path/wsl-${index}` - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - } - - for (let index = 0; index < 1_000; index++) { - deleteWorktreeHistoryDir(`repo-1::/path/wsl-${index}`) - } - await vi.advanceTimersByTimeAsync(0) - expect(removeHostTreeMock).toHaveBeenCalledTimes(64) - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(64) - expect(releases).toHaveLength(64) - - while (releases.length > 0) { - releases.splice(0).forEach((release) => release()) - await vi.advanceTimersByTimeAsync(0) - } - expect(removeHostTreeMock).toHaveBeenCalledTimes(1_000) - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(1_000) - }) - - it('caps persistent failures and leaves excess tombstones for a later disk batch', async () => { - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - // Why removeHostTree and not the fish cleanup: a failing fish cleanup no - // longer blocks tree removal (most distros have no fish), so the tree - // removal itself has to be the thing that fails to exercise the cap. - removeHostTreeMock.mockRejectedValue(new Error('EBUSY')) - for (let index = 0; index < 1_000; index++) { - const worktreeId = `repo-1::/path/fail-${index}` - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - deleteWorktreeHistoryDir(worktreeId) - } - await vi.advanceTimersByTimeAsync(0) - expect(vi.getTimerCount()).toBe(64) - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(1_000) - - for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) { - await vi.advanceTimersByTimeAsync(delay) - expect(vi.getTimerCount()).toBeLessThanOrEqual(64) - } - await vi.advanceTimersByTimeAsync(0) - removeHostTreeMock.mockReset() - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) - schedulePendingHistoryTreeRemovals(distroRoot) - await flushPendingWorktreeHistoryDeletions() - expect(removeHostTreeMock.mock.calls.length).toBeGreaterThan( - MAX_PENDING_HISTORY_TREE_REMOVALS * (HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1) - ) - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) - }) - - it('still removes the history tree when WSL fish cleanup fails', async () => { - // Why this matters: `injectWslFishHistoryEnv` records a fishSession for EVERY - // WSL terminal, and cleanup shells out to `wsl.exe --exec fish`. On a distro - // with no fish that always fails — which used to chain into removeHostTree and - // leak the bash/zsh history tree for those users permanently. - const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') - mkdirSync(distroRoot, { recursive: true }) - deleteWslFishHistoryFileMock.mockRejectedValue(new Error('fish: command not found')) - removeHostTreeMock.mockImplementation(async (dir) => { - rmSync(dir, { recursive: true, force: true }) - }) - const worktreeId = 'repo-1::/path/no-fish' - const dir = join(distroRoot, hashWorktreeId(worktreeId)) - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'meta.json'), - JSON.stringify({ - worktreeId, - fishSession: fishHistorySessionName(hashWorktreeId(worktreeId)) - }) - ) - - deleteWorktreeHistoryDir(worktreeId) - await flushPendingWorktreeHistoryDeletions() - - expect(deleteWslFishHistoryFileMock).toHaveBeenCalled() - expect(removeHostTreeMock).toHaveBeenCalled() - expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) - }) }) diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index c1f76aa0c44..c1900e3d0b5 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -7,39 +7,23 @@ const { mkdirSyncMock, writeFileSyncMock, readFileSyncMock, - lstatSyncMock, rmSyncMock, renameSyncMock, readdirSyncMock, statSyncMock, - openSyncMock, - fstatSyncMock, - closeSyncMock, getPathMock, - rmAsyncMock, - deleteWslFishHistoryFileMock + rmAsyncMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), readFileSyncMock: vi.fn(), - lstatSyncMock: vi.fn(), rmSyncMock: vi.fn(), renameSyncMock: vi.fn(), readdirSyncMock: vi.fn(), statSyncMock: vi.fn(), - openSyncMock: vi.fn((_path?: string | Buffer) => 1), - fstatSyncMock: vi.fn(() => ({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - isFile: () => true, - isDirectory: () => true - })), - closeSyncMock: vi.fn(), getPathMock: vi.fn(), - rmAsyncMock: vi.fn(async () => undefined), - deleteWslFishHistoryFileMock: vi.fn(async () => undefined) + rmAsyncMock: vi.fn(async () => undefined) })) vi.mock('fs', () => ({ @@ -47,14 +31,10 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, readFileSync: readFileSyncMock, - lstatSync: lstatSyncMock, rmSync: rmSyncMock, renameSync: renameSyncMock, readdirSync: readdirSyncMock, - statSync: statSyncMock, - openSync: openSyncMock, - fstatSync: fstatSyncMock, - closeSync: closeSyncMock + statSync: statSyncMock })) // Spread the real module: this factory replaces node:fs/promises for the whole import graph, so a @@ -80,31 +60,21 @@ vi.mock('./wsl', () => ({ toLinuxPath: toLinuxPathMock })) -vi.mock('./wsl-fish-history-cleanup', () => ({ - deleteWslFishHistoryFile: deleteWslFishHistoryFileMock -})) - import { resolveShellKind, ensureHistoryDir, injectHistoryEnv, - MAX_HISTORY_META_BYTES, - updateHistoryEnvForFallback, - type HistoryInjectionResult + updateHistFileForFallback } from './terminal-history' -import { fishHistorySessionName } from './fish-history-session' import { hashWorktreeId } from './terminal-history-paths' import { - cancelPendingHistoryTreeRemovalRetries, deleteWorktreeHistoryDir, flushPendingWorktreeHistoryDeletions } from './terminal-history-deletion' - import { runHistoryGc, scheduleHistoryGc } from './terminal-history-gc' describe('terminal-history', () => { afterEach(() => { - cancelPendingHistoryTreeRemovalRetries() vi.useRealTimers() }) @@ -112,30 +82,9 @@ describe('terminal-history', () => { vi.clearAllMocks() // Why: clearAllMocks keeps implementations, so a throwing rename from one test would leak forward. renameSyncMock.mockReset() - readFileSyncMock.mockReset() - readdirSyncMock.mockReset() - rmAsyncMock.mockReset() - rmAsyncMock.mockResolvedValue(undefined) getPathMock.mockReturnValue('/fake/userData') existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - openSyncMock.mockImplementation(() => 1) - fstatSyncMock.mockReturnValue({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - isFile: () => true, - isDirectory: () => true - }) - lstatSyncMock.mockReturnValue({ - dev: 1n, - ino: 2n, - birthtimeNs: 3n, - size: 100, - isDirectory: () => true, - isFile: () => true, - isSymbolicLink: () => false - }) }) describe('resolveShellKind', () => { @@ -279,101 +228,12 @@ describe('terminal-history', () => { expect(result.histFile).toBeNull() }) - it('scopes fish by session name instead of HISTFILE, which fish ignores', () => { + it('does not inject HISTFILE for fish (Phase 2)', () => { const env: Record = {} const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') expect(env.HISTFILE).toBeUndefined() expect(result.shell).toBe('fish') - expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt'))) - expect(env.fish_history).toBe(result.fishSession) - // Deletion can only find the out-of-tree fish file through this record. - expect(writeFileSyncMock).toHaveBeenCalledWith( - expect.stringContaining('meta.json'), - expect.stringContaining(`"fishSession":"${result.fishSession}"`), - expect.anything() - ) - }) - - it('records the history DIRECTORY from the SPAWN env, not this process env', () => { - // The file is written by the PTY's fish, and the two envs disagree whenever - // Orca was launched with a different XDG_DATA_HOME than the shells it spawns. - // Pinned so the assertion cannot accidentally read the developer's own value. - const originalDataHome = process.env.XDG_DATA_HOME - process.env.XDG_DATA_HOME = ['', 'main', 'process', 'data'].join(sep) - try { - const env: Record = { XDG_DATA_HOME: ['', 'spawn', 'data'].join(sep) } - injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record< - string, - string - > - - expect(meta.fishHistoryDir).toBe(['', 'spawn', 'data', 'fish'].join(sep)) - } finally { - if (originalDataHome === undefined) { - delete process.env.XDG_DATA_HOME - } else { - process.env.XDG_DATA_HOME = originalDataHome - } - } - }) - - it('replaces oversized metadata without parsing it', () => { - statSyncMock.mockReturnValue({ isDirectory: () => true, size: MAX_HISTORY_META_BYTES + 1 }) - const env = { XDG_DATA_HOME: ['', 'active'].join(sep) } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(readFileSyncMock).not.toHaveBeenCalled() - const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record< - string, - string - > - expect(meta.fishHistoryDir).toBe(['', 'active', 'fish'].join(sep)) - }) - - it('gives two fish worktrees different history sessions', () => { - const envA: Record = {} - injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/usr/bin/fish', '/path/wt-a') - const envB: Record = {} - injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/usr/bin/fish', '/path/wt-b') - - expect(envA.fish_history).not.toBe(envB.fish_history) - }) - - it('drops an ORCA_HISTFILE inherited from a parent Orca PTY', () => { - // Why: an Orca terminal opened from inside another Orca terminal inherits - // it, and the zsh wrapper would then re-export the PARENT worktree's - // history path here. Credit: caught by @innocarpe in #11146. - const env: Record = { ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep) } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.ORCA_HISTFILE).toBe(env.HISTFILE) - expect(env.ORCA_HISTFILE).not.toContain('other') - }) - - it('drops an inherited ORCA_HISTFILE even when it injects nothing', () => { - // The dangerous variant: the early return would otherwise leave the stale - // value pointing the wrapper at another worktree, overriding HISTFILE. - const env: Record = { - HISTFILE: ['', 'mine', 'zsh_history'].join(sep), - ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep) - } - - injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt') - - expect(env.HISTFILE).toBe(['', 'mine', 'zsh_history'].join(sep)) - expect(env.ORCA_HISTFILE).toBeUndefined() - }) - - it('preserves a caller-supplied fish_history', () => { - const env: Record = { fish_history: 'mine' } - const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt') - - expect(env.fish_history).toBe('mine') - expect(result.fishSession).toBeNull() }) it('degrades gracefully when directory creation fails', () => { @@ -389,19 +249,12 @@ describe('terminal-history', () => { }) }) - describe('updateHistoryEnvForFallback', () => { - const zshInjection = (): HistoryInjectionResult => ({ - shell: 'zsh', - histFile: '/fake/userData/terminal-history/abc123/zsh_history', - fishSession: null, - historyDir: '/fake/userData/terminal-history/abc123' - }) - + describe('updateHistFileForFallback', () => { it('updates HISTFILE to match fallback shell', () => { const env: Record = { HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history' } - updateHistoryEnvForFallback(env, '/bin/bash', zshInjection()) + updateHistFileForFallback(env, '/bin/bash') expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history') }) @@ -409,45 +262,18 @@ describe('terminal-history', () => { const env: Record = { HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history' } - updateHistoryEnvForFallback(env, '/bin/sh', zshInjection()) + updateHistFileForFallback(env, '/bin/sh') expect(env.HISTFILE).toBeUndefined() }) - it('is a no-op when nothing was injected', () => { + it('is a no-op when HISTFILE is not set', () => { const env: Record = {} - updateHistoryEnvForFallback(env, '/bin/bash', { - shell: 'unknown', - histFile: null, - fishSession: null, - historyDir: null - }) + updateHistFileForFallback(env, '/bin/bash') expect(env.HISTFILE).toBeUndefined() }) - - it('swaps an injected fish session for the fallback shell HISTFILE', () => { - const env: Record = { fish_history: 'orca_abc123' } - updateHistoryEnvForFallback(env, '/bin/bash', { - shell: 'fish', - histFile: null, - fishSession: 'orca_abc123', - historyDir: '/fake/userData/terminal-history/abc123' - }) - expect(env.fish_history).toBeUndefined() - expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history') - }) - - it('keeps a caller-supplied fish_history the injection never claimed', () => { - const env: Record = { fish_history: 'mine' } - updateHistoryEnvForFallback(env, '/bin/bash', zshInjection()) - expect(env.fish_history).toBe('mine') - }) }) describe('deleteWorktreeHistoryDir', () => { - const worktreeId = 'repo-1::/path/wt' - const session = fishHistorySessionName(hashWorktreeId(worktreeId)) - const historyFilename = `${session}_history` - it('tombstones then async-removes the history directory without recursive rmSync', async () => { existsSyncMock.mockReturnValue(true) deleteWorktreeHistoryDir('repo-1::/path/wt') @@ -460,84 +286,6 @@ describe('terminal-history', () => { await flushPendingWorktreeHistoryDeletions() }) - it('deletes the fish history file in the directory meta.json recorded', async () => { - const recordedDir = ['', 'spawn', 'data', 'fish'].join(sep) - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId, fishSession: session, fishHistoryDir: recordedDir }) - ) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).toHaveBeenCalledWith([recordedDir, historyFilename].join(sep)) - await flushPendingWorktreeHistoryDeletions() - }) - - it('also tries this process fish dir, for meta written before that field', async () => { - const originalDataHome = process.env.XDG_DATA_HOME - process.env.XDG_DATA_HOME = ['', 'main', 'data'].join(sep) - try { - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).toHaveBeenCalledWith( - ['', 'main', 'data', 'fish', historyFilename].join(sep) - ) - } finally { - if (originalDataHome === undefined) { - delete process.env.XDG_DATA_HOME - } else { - process.env.XDG_DATA_HOME = originalDataHome - } - } - await flushPendingWorktreeHistoryDeletions() - }) - - it('ignores a fishSession that does not belong to this history directory', async () => { - // Why: the session name is re-derived from the directory's own hash, so a - // tampered meta.json naming someone else's session cannot steer the delete. - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => true }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId, fishSession: 'orca_deadbeefdeadbeef' }) - ) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).not.toHaveBeenCalledWith(expect.stringContaining('orca_deadbeefdeadbeef')) - await flushPendingWorktreeHistoryDeletions() - }) - - it('refuses to unlink a fish history path that is not a regular file', async () => { - existsSyncMock.mockReturnValue(true) - lstatSyncMock.mockReturnValue({ isFile: () => false }) - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - - deleteWorktreeHistoryDir('repo-1::/path/wt') - - expect(rmSyncMock).not.toHaveBeenCalled() - await flushPendingWorktreeHistoryDeletions() - }) - - it('cleans WSL Fish history through the owning distro before removing metadata', async () => { - readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session })) - readdirSyncMock.mockImplementation((path: string) => { - if (path.endsWith('terminal-history-wsl')) { - return ['Ubuntu'] - } - return [] - }) - - deleteWorktreeHistoryDir(worktreeId) - await flushPendingWorktreeHistoryDeletions() - - expect(deleteWslFishHistoryFileMock).toHaveBeenCalledWith('Ubuntu', session) - }) - it('leaves the live directory alone when the tombstone rename fails', async () => { existsSyncMock.mockReturnValue(true) renameSyncMock.mockImplementation(() => { @@ -561,7 +309,6 @@ describe('terminal-history', () => { }) it('retries pending tombstones on flush after a failed async rm (app-quit durability)', async () => { - let leftoverTombstonePresent = true existsSyncMock.mockImplementation((p: string) => { const path = String(p) if (path.includes('.pending-delete') && !path.endsWith('.pending-delete')) { @@ -574,13 +321,11 @@ describe('terminal-history', () => { }) readdirSyncMock.mockImplementation((p: string) => { if (String(p).endsWith('.pending-delete')) { - return leftoverTombstonePresent ? ['leftover-tombstone'] : [] + return ['leftover-tombstone'] } return [] }) - rmAsyncMock.mockImplementation(async () => { - leftoverTombstonePresent = false - }) + rmAsyncMock.mockResolvedValue(undefined) await flushPendingWorktreeHistoryDeletions() expect(rmAsyncMock).toHaveBeenCalledWith( expect.stringContaining('leftover-tombstone'), @@ -643,34 +388,6 @@ describe('terminal-history', () => { ) }) - it('continues GC after one orphan tombstone fails', async () => { - existsSyncMock.mockImplementation((path: string) => !path.includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('.pending-delete')) { - return [] - } - if (dir.endsWith('terminal-history')) { - return ['broken', 'healthy'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ - worktreeId: 'orphan', - createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() - }) - ) - renameSyncMock.mockImplementationOnce(() => { - throw new Error('busy') - }) - - expect(() => runHistoryGc(new Set())).not.toThrow() - expect(renameSyncMock).toHaveBeenCalledTimes(2) - expect(rmAsyncMock).toHaveBeenCalledTimes(1) - await flushPendingWorktreeHistoryDeletions() - }) - it('skips recently-created directories to avoid TOCTOU race', () => { existsSyncMock.mockImplementation((p: string) => { if (p.includes('terminal-history-wsl')) { @@ -700,15 +417,14 @@ describe('terminal-history', () => { it('does not throw when history root does not exist', () => { existsSyncMock.mockReturnValue(false) expect(() => runHistoryGc(new Set())).not.toThrow() - expect(readdirSyncMock).not.toHaveBeenCalledWith('/fake/userData/terminal-history') + expect(readdirSyncMock).not.toHaveBeenCalled() }) it('drains delete tombstones asynchronously instead of scanning them as worktrees', async () => { - let tombstonePresent = true existsSyncMock.mockImplementation((p: string) => !String(p).includes('terminal-history-wsl')) readdirSyncMock.mockImplementation((dir: string) => { if (String(dir).endsWith('.pending-delete')) { - return tombstonePresent ? ['abc123.1700000000000.deadbeef'] : [] + return ['abc123.1700000000000.deadbeef'] } if (String(dir).endsWith('terminal-history')) { return ['.pending-delete'] @@ -716,9 +432,6 @@ describe('terminal-history', () => { return ['meta.json'] }) statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - rmAsyncMock.mockImplementation(async () => { - tombstonePresent = false - }) runHistoryGc(new Set()) diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index c15f903c2de..38dd7968264 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -1,18 +1,10 @@ import { join, basename } from 'node:path' -import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { - fishHistorySessionName, - isSafeFishHistorySession, - resolveFishHistoryDir -} from './fish-history-session' +import { mkdirSync, existsSync, writeFileSync } from 'node:fs' import { parseWslPath, toLinuxPath } from './wsl' -import { getHistoryRoot, getHistoryRootWsl } from './terminal-history-paths' -import { hashWorktreeId } from './terminal-history-id' +import { getHistoryRoot, getHistoryRootWsl, hashWorktreeId } from './terminal-history-paths' type ShellKind = 'zsh' | 'bash' | 'fish' | 'pwsh' | 'powershell' | 'cmd' | 'unknown' -export const MAX_HISTORY_META_BYTES = 32 * 1024 - // ─── Shell Detection ─────────────────────────────────────────────── /** Resolve the shell kind from a shell binary path. @@ -41,16 +33,14 @@ export function resolveShellKind(shellPath: string): ShellKind { return 'unknown' } -/** Map shell kind to the filename used inside the history directory. - * fish is absent on purpose: it ignores HISTFILE and keeps history in its own - * data dir keyed by session name (see fish-history-session.ts). */ +/** Map shell kind to the filename used inside the history directory. */ function historyFilename(shell: ShellKind): string | null { switch (shell) { case 'zsh': return 'zsh_history' case 'bash': return 'bash_history' - // Phase 2: PowerShell and cmd use different mechanisms + // Phase 2: fish and PowerShell use different mechanisms case 'fish': case 'pwsh': case 'powershell': @@ -78,81 +68,17 @@ export function ensureHistoryDir(worktreeHash: string, wslDistro?: string): stri } } -/** Write meta.json alongside history files, for debuggability and for GC. - * `fishSession` is load-bearing, not diagnostic: fish history lives outside - * this directory, so deletion can only find it by the name recorded here. - * `fishHistoryDir` is resolved from the SPAWN env, which is the one fish - * follows — this process's own may differ. */ -function writeMetaFile( - dir: string, - worktreeId: string, - fish?: { session: string; historyDir?: string } -): void { +/** Write meta.json alongside history files for debuggability. */ +function writeMetaFile(dir: string, worktreeId: string): void { try { const metaPath = join(dir, 'meta.json') - const existing = existsSync(metaPath) ? readHistoryMeta(dir) : null - if ( - existing && - (!fish || - (existing.fishSession === fish.session && existing.fishHistoryDir === fish.historyDir)) - ) { - return - } - writeFileSync( - metaPath, - JSON.stringify({ - worktreeId, - createdAt: existing?.createdAt ?? new Date().toISOString(), - ...(fish ? { fishSession: fish.session } : {}), - ...(fish?.historyDir ? { fishHistoryDir: fish.historyDir } : {}) - }), - { mode: 0o600 } - ) - } catch { - // Non-fatal — a missing meta.json only costs GC attribution. - } -} - -export type HistoryDirMeta = { - worktreeId?: string - createdAt?: string - /** fish session name whose history file lives in the user's fish data dir. */ - fishSession?: string - /** Directory that session's history file was written to, as the PTY saw it. */ - fishHistoryDir?: string -} - -/** Read one history directory's meta.json, or null when it is absent or unparseable. */ -export function readHistoryMeta(dir: string): HistoryDirMeta | null { - try { - const metaPath = join(dir, 'meta.json') - if (statSync(metaPath).size > MAX_HISTORY_META_BYTES) { - return null - } - const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8')) - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return null - } - const record = raw as Record - // Why re-derive: the session name is a pure function of this directory's own - // hash, so a meta.json naming someone else's session cannot steer deletion. - const expectedFishSession = fishHistorySessionName(basename(dir).split('.')[0]) - const fishSession = - isSafeFishHistorySession(record.fishSession) && record.fishSession === expectedFishSession - ? record.fishSession - : undefined - const fishHistoryDir = - fishSession && typeof record.fishHistoryDir === 'string' && record.fishHistoryDir - ? record.fishHistoryDir - : undefined - return { - ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), - ...(typeof record.createdAt === 'string' ? { createdAt: record.createdAt } : {}), - ...(fishSession ? { fishSession } : {}), - ...(fishHistoryDir ? { fishHistoryDir } : {}) + if (!existsSync(metaPath)) { + writeFileSync(metaPath, JSON.stringify({ worktreeId, createdAt: new Date().toISOString() }), { + mode: 0o600 + }) } } catch { - return null + // Non-fatal — meta.json is purely for diagnostics. } } @@ -161,10 +87,6 @@ export function readHistoryMeta(dir: string): HistoryDirMeta | null { export type HistoryInjectionResult = { shell: ShellKind histFile: string | null - /** fish session name exported as `fish_history`; null when fish is not the shell. */ - fishSession: string | null - /** Worktree history dir as the spawned shell sees it (Linux-visible under WSL). */ - historyDir: string | null } /** Build shell-specific history env overrides for a PTY spawn. @@ -181,31 +103,18 @@ export function injectHistoryEnv( cwd: string, options: { wslDistro?: string | null } = {} ): HistoryInjectionResult { - // Why unconditionally first: ORCA_HISTFILE is Orca-owned, and an Orca PTY - // launched from inside another Orca PTY inherits the parent's. Left in place, - // the zsh wrapper would re-export a PREVIOUS worktree's history path into this - // shell — the cross-worktree leak this feature exists to prevent — and it would - // also override a caller-supplied HISTFILE on the early return below. - // Credit: caught by @innocarpe in #11146. - delete spawnEnv.ORCA_HISTFILE - const shell = resolveShellKind(shellPath) - const result: HistoryInjectionResult = { - shell, - histFile: null, - fishSession: null, - historyDir: null - } + const result: HistoryInjectionResult = { shell, histFile: null } const filename = historyFilename(shell) - if (!filename && shell !== 'fish') { - // Unknown shell or Phase 2 shell (pwsh, cmd) — leave unchanged. + if (!filename) { + // Unknown shell or Phase 2 shell (fish, pwsh, cmd) — leave unchanged. return result } - // Check-before-set: if the caller already provided the shell's history knob, - // preserve it. Same pattern Ghostty, Kitty, and VS Code use for HISTFILE (§6). - if (shell === 'fish' ? spawnEnv.fish_history : spawnEnv.HISTFILE) { + // Check-before-set: if the caller already provided HISTFILE, preserve it. + // This follows the pattern used by Ghostty, Kitty, and VS Code (§6). + if (spawnEnv.HISTFILE) { return result } @@ -221,92 +130,46 @@ export function injectHistoryEnv( return result } - if (!filename) { - // fish: the directory holds no history, only the meta.json that lets deletion - // find the session file fish keeps in its own data dir. fish never runs as the - // inner WSL shell, so histDir needs no /mnt conversion here. - const session = fishHistorySessionName(worktreeHash) - // Resolve from the SPAWN env: that is the XDG_DATA_HOME/HOME fish will see, - // which need not match the one this process was launched with. - writeMetaFile(histDir, worktreeId, { session, historyDir: resolveFishHistoryDir(spawnEnv) }) - spawnEnv.fish_history = session - result.fishSession = session - result.historyDir = histDir - return result - } - writeMetaFile(histDir, worktreeId) const histFilePath = join(histDir, filename) // For WSL, convert the Windows path to a Linux-visible path. spawnEnv.HISTFILE = wslDistro ? toLinuxPath(histFilePath) : histFilePath - // Why a second variable: macOS `/etc/zshrc` assigns HISTFILE unconditionally - // (`HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history`) and runs before Orca's wrapper - // .zshrc, so by then the injected value is gone from HISTFILE itself. The - // wrapper restores it from here once the user's own config has loaded (#11044). - spawnEnv.ORCA_HISTFILE = spawnEnv.HISTFILE result.histFile = spawnEnv.HISTFILE - result.historyDir = spawnEnv.HISTFILE.replace(/[/\\][^/\\]+$/, '') return result } -/** WSL's outer executable hides the login shell, so carry both history knobs. */ -export function injectWslFishHistoryEnv( +/** Update HISTFILE in spawnEnv when shell fallback changes the shell kind. + * For example, if zsh fails and bash takes over, the HISTFILE should point + * to bash_history instead of zsh_history. */ +export function updateHistFileForFallback( spawnEnv: Record, - worktreeId: string, - wslDistro: string -): string | null { - if (spawnEnv.fish_history) { - return null - } - const worktreeHash = hashWorktreeId(worktreeId) - const historyDir = ensureHistoryDir(worktreeHash, wslDistro) - if (!historyDir) { - return null - } - const session = fishHistorySessionName(worktreeHash) - // Why no historyDir: this session's file lives inside the WSL distro, so a - // path resolved from THIS process's Windows environment names an unrelated - // host directory — which the host GC sweep would then scan. WSL cleanup goes - // through `deleteWslFishHistoryFile`, which resolves the path in the distro. - writeMetaFile(historyDir, worktreeId, { session }) - spawnEnv.fish_history = session - return session -} - -/** Re-point the history env when shell fallback changes the shell kind — e.g. zsh - * fails and bash takes over, so HISTFILE must name bash_history. A fish primary - * injected `fish_history` instead, which the fallback shell cannot use. */ -export function updateHistoryEnvForFallback( - spawnEnv: Record, - fallbackShellPath: string, - injected: HistoryInjectionResult + fallbackShellPath: string ): void { - // Only ever undo what this spawn injected; a caller-supplied value stays. - if (injected.fishSession && spawnEnv.fish_history === injected.fishSession) { - delete spawnEnv.fish_history - } - if (!injected.historyDir) { + if (!spawnEnv.HISTFILE) { return } - const newFilename = historyFilename(resolveShellKind(fallbackShellPath)) + const newShell = resolveShellKind(fallbackShellPath) + const newFilename = historyFilename(newShell) if (!newFilename) { - // Fallback to an unknown shell — drop the override so it uses its own default. + // Fallback to an unknown shell — remove HISTFILE override entirely + // so the shell uses its own default. delete spawnEnv.HISTFILE - delete spawnEnv.ORCA_HISTFILE return } - spawnEnv.HISTFILE = `${injected.historyDir}/${newFilename}` - spawnEnv.ORCA_HISTFILE = spawnEnv.HISTFILE + + // Replace the filename portion of the HISTFILE path. + const dir = spawnEnv.HISTFILE.replace(/[/\\][^/\\]+$/, '') + spawnEnv.HISTFILE = `${dir}/${newFilename}` } /** Log the history injection result for diagnostics. */ export function logHistoryInjection(worktreeId: string, result: HistoryInjectionResult): void { const truncatedId = worktreeId.length > 60 ? `${worktreeId.slice(0, 60)}...` : worktreeId console.log( - `[pty:history] worktreeId=${truncatedId} shell=${result.shell} histFile=${result.histFile ?? 'none'} fishSession=${result.fishSession ?? 'none'}` + `[pty:history] worktreeId=${truncatedId} shell=${result.shell} histFile=${result.histFile ?? 'none'}` ) } diff --git a/src/main/wsl-fish-history-cleanup.test.ts b/src/main/wsl-fish-history-cleanup.test.ts deleted file mode 100644 index 3893093c01a..00000000000 --- a/src/main/wsl-fish-history-cleanup.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - __resetWslFishHistoryCleanups, - deleteWslFishHistoryFile, - flushWslFishHistoryCleanups -} from './wsl-fish-history-cleanup' - -// Why reset on BOTH sides: the cleanup queue is module state, so one test's -// pending work would otherwise serialize behind — or deadlock — the next. -beforeEach(() => { - __resetWslFishHistoryCleanups() -}) - -afterEach(async () => { - await flushWslFishHistoryCleanups() - __resetWslFishHistoryCleanups() -}) - -describe('deleteWslFishHistoryFile', () => { - it('uses direct argv and bounds a distro cleanup subprocess', async () => { - const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - - await deleteWslFishHistoryFile('Ubuntu Test', 'orca_0123456789abcdef', run) - - expect(run).toHaveBeenCalledWith( - 'wsl.exe', - [ - '--distribution', - 'Ubuntu Test', - '--exec', - 'fish', - '--command', - expect.stringContaining('orca_0123456789abcdef_history') - ], - { timeout: 5_000, windowsHide: true } - ) - }) - - it('rejects an unsafe session before spawning', async () => { - const run = vi.fn() - - await deleteWslFishHistoryFile('Ubuntu', '../../user-history', run) - - expect(run).not.toHaveBeenCalled() - }) - - it('coalesces concurrent requests for the same distro and session', async () => { - // Why: the GC sweep and an explicit worktree delete can queue the same - // tombstone, and one wsl.exe launch per cleanup is enough. - let settle!: () => void - const run = vi.fn().mockReturnValue( - new Promise((resolve) => { - settle = () => resolve({ stdout: '', stderr: '' }) - }) - ) - const session = 'orca_0123456789abcdef' - - const first = deleteWslFishHistoryFile('Ubuntu', session, run) - const second = deleteWslFishHistoryFile('Ubuntu', session, run) - - expect(second).toBe(first) - expect(run).toHaveBeenCalledTimes(1) - settle() - await Promise.all([first, second]) - }) - - - it('keeps distinct distros independent of each other', async () => { - const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - - await Promise.all([ - deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run), - deleteWslFishHistoryFile('Debian', 'orca_0123456789abcdef', run) - ]) - - expect(run).toHaveBeenCalledTimes(2) - }) - - it('permits a retry once a failed cleanup settles', async () => { - const run = vi.fn().mockRejectedValueOnce(new Error('distro offline')).mockResolvedValue({ - stdout: '', - stderr: '' - }) - const session = 'orca_0123456789abcdef' - - await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).rejects.toThrow('distro offline') - await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).resolves.toBeUndefined() - expect(run).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/wsl-fish-history-cleanup.ts b/src/main/wsl-fish-history-cleanup.ts deleted file mode 100644 index 18aebb650a2..00000000000 --- a/src/main/wsl-fish-history-cleanup.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import { isSafeFishHistorySession } from './fish-history-session' - -const execFileAsync = promisify(execFile) - -/** Deduped by distro+session, so a rescan that re-queues the same tombstone - * joins the running call instead of spawning a second `wsl.exe`. - * - * Not otherwise rate-limited: the caller admits at most - * MAX_PENDING_HISTORY_TREE_REMOVALS tombstones at a time, so fanout is bounded - * but can still be dozens of `wsl.exe` launches during a startup GC drain on a - * machine with many dead WSL fish worktrees. Narrowing that belongs with the - * tombstone scheduler that owns the admission, not here. */ -const cleanupsInFlight = new Map>() - -/** Why run this INSIDE the distro: the history file lives on the distro's own - * filesystem under its `$XDG_DATA_HOME`/`$HOME`, which only a shell in there - * can resolve. `string match -qr '^/'` rejects a relative XDG_DATA_HOME the - * same way fish itself does before falling back. */ -function fishCleanupScript(session: string): string { - return [ - 'set -l data_home $XDG_DATA_HOME', - 'string match -qr "^/" -- $data_home; or set data_home "$HOME/.local/share"', - `command rm -f -- "$data_home/fish/${session}_history"` - ].join('; ') -} - -export function deleteWslFishHistoryFile( - distro: string, - session: string, - run: typeof execFileAsync = execFileAsync -): Promise { - if (!distro.trim() || !isSafeFishHistorySession(session)) { - return Promise.resolve() - } - const key = `${distro}\0${session}` - const existing = cleanupsInFlight.get(key) - if (existing) { - return existing - } - const cleanup = runCleanup(distro, session, run).finally(() => { - cleanupsInFlight.delete(key) - }) - cleanupsInFlight.set(key, cleanup) - return cleanup -} - -async function runCleanup( - distro: string, - session: string, - run: typeof execFileAsync -): Promise { - await run( - 'wsl.exe', - ['--distribution', distro, '--exec', 'fish', '--command', fishCleanupScript(session)], - { timeout: 5_000, windowsHide: true } - ) -} - -/** Test-only: drop in-flight state so one test's pending work cannot reach the next. */ -export function __resetWslFishHistoryCleanups(): void { - cleanupsInFlight.clear() -} - -/** Await every in-flight cleanup; for deterministic shutdown and tests. - * Why snapshot-and-drain rather than re-reading the map in the loop condition: - * an entry that has settled but whose `.finally` has not yet removed it would - * make `while (size > 0)` re-await a resolved promise forever, starving the - * event loop instead of returning. */ -export async function flushWslFishHistoryCleanups(): Promise { - const awaited = new Set>() - let pending = [...cleanupsInFlight.values()] - while (pending.length > 0) { - for (const cleanup of pending) { - awaited.add(cleanup) - } - await Promise.allSettled(pending) - pending = [...cleanupsInFlight.values()].filter((cleanup) => !awaited.has(cleanup)) - } -} diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts deleted file mode 100644 index 98af8914ec6..00000000000 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup. - * - * macOS `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no - * check-before-set, and it runs before every wrapper file Orca controls. So the - * value `injectHistoryEnv` put in the spawn env is already gone by the time the - * user reaches a prompt — and because ZDOTDIR still points at Orca's wrapper - * dir, the replacement lands inside it. Per-worktree history was therefore a - * silent no-op on the primary platform (#11044). - * - * Only a real zsh can show this: the string the wrapper emits looks correct - * either way, and the whole bug lives in what /etc/zshrc does between the spawn - * env and the first prompt. - */ -import { execFileSync, spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { getZshShellReadyRcfileContent } from './providers/local-pty-shell-ready-wrapper-generation' -import { getZshEnvTemplate, ZSH_HISTFILE_RESTORE_BLOCK } from './shell-templates' - -// Why probe and execute the same binary: guarding on `zsh` from PATH but then -// running a hardcoded `/bin/zsh` lets the guard pass on a host that installs zsh -// elsewhere, and the test fails for a missing binary rather than a wrapper -// defect. The absolute path is resolved once so the sandboxed PATH below cannot -// lose it. -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' -const itWithZsh = hasZsh ? it : it.skip - -function runLoginZsh(home: string, zdotdir: string, env: Record): string { - // -o noglobalrcs is deliberately NOT passed: /etc/zshrc is the thing under test. - return execFileSync(ZSH_PATH, ['-li', '-c', 'echo "RESULT=$HISTFILE"'], { - encoding: 'utf8', - timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - HOME: home, - ZDOTDIR: zdotdir, - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home, - ...env - } - }) -} - -describe('worktree-scoped HISTFILE survives zsh startup', () => { - const withWrapper = (run: (home: string, zdotdir: string) => void): void => { - const home = mkdtempSync(join(tmpdir(), 'orca-scoped-histfile-')) - const zdotdir = join(home, 'shell-ready', 'zsh') - mkdirSync(zdotdir, { recursive: true }) - writeFileSync(join(zdotdir, '.zshenv'), getZshEnvTemplate(zdotdir)) - writeFileSync(join(zdotdir, '.zshrc'), getZshShellReadyRcfileContent()) - writeFileSync(join(zdotdir, '.zlogin'), `${ZSH_HISTFILE_RESTORE_BLOCK}\n`) - try { - run(home, zdotdir) - } finally { - rmSync(home, { recursive: true, force: true }) - } - } - - itWithZsh('keeps the injected path that a system zshrc would otherwise clobber', () => { - withWrapper((home, zdotdir) => { - const scoped = join(home, 'orca-history', 'zsh_history') - - const output = runLoginZsh(home, zdotdir, { HISTFILE: scoped, ORCA_HISTFILE: scoped }) - - expect(output).toContain(`RESULT=${scoped}`) - }) - }) - - itWithZsh('never leaves history inside Orca’s own wrapper directory', () => { - withWrapper((home, zdotdir) => { - const scoped = join(home, 'orca-history', 'zsh_history') - - const output = runLoginZsh(home, zdotdir, { HISTFILE: scoped, ORCA_HISTFILE: scoped }) - - // The exact failure mode of #11044: history written into shell-ready/zsh. - expect(output).not.toContain(zdotdir) - }) - }) - - itWithZsh('leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', () => { - // Why compared against an unwrapped run rather than asserted non-empty: what - // zsh defaults to is platform-specific. macOS `/etc/zshrc` assigns HISTFILE, - // so it is always set there; a stock Ubuntu zsh has no such file and leaves - // it EMPTY. The contract is that Orca's wrapper does not change it either - // way, which is the same assertion on both. - withWrapper((home, zdotdir) => { - const wrapped = runLoginZsh(home, zdotdir, {}) - const unwrapped = execFileSync(ZSH_PATH, ['-li', '-c', 'echo "RESULT=$HISTFILE"'], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home } - }) - - const histfileOf = (output: string): string => - /^RESULT=(.*)$/m.exec(output)?.[1]?.trim() ?? '' - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(wrapped).not.toContain('ORCA_HISTFILE') - }) - }) -}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 16d256e72a9..c077378ebda 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -2,7 +2,7 @@ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' import { existsSync } from 'node:fs' -import { basename, join } from 'node:path' +import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { resolveWindowsGitBashShellPath } from '../main/git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' @@ -79,12 +79,6 @@ import { type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' import { createPtySlaveEchoProbe, readPtySlavePath } from '../shared/pty-slave-line-discipline-echo' -import { - deleteRelayFishHistory, - deleteRelayHistory, - injectRelayFishHistoryEnv, - injectRelayHistoryEnv -} from './terminal-history' // Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap // there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never @@ -164,7 +158,6 @@ type ManagedPty = { shellPathEnv?: string envToDelete: string[] gitCredentialPromptGuarded: boolean - historyIsolationEnabled?: boolean startupCommand?: ManagedStartupCommand physicalExit?: PhysicalExitTracker forceKillSent?: boolean @@ -334,8 +327,6 @@ type SerializedPtyEntry = { envToDelete?: string[] /** Optional for state serialized by relays predating the credential guard. */ gitCredentialPromptGuarded?: boolean - /** Optional for state serialized by relays predating scoped history. */ - historyIsolationEnabled?: boolean agentSessionOwners?: AgentSessionOwnerBinding[] } @@ -869,13 +860,6 @@ export class PtyHandler { this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) this.dispatcher.onRequest('pty.revive', (p) => this.revive(p)) this.dispatcher.onRequest('pty.getProfiles', async () => listShellProfiles()) - this.dispatcher.onRequest('pty.deleteWorktreeHistory', async (p) => { - if (typeof p.worktreeId === 'string') { - deleteRelayFishHistory(p.worktreeId) - deleteRelayHistory(p.worktreeId) - } - return { ok: true } - }) this.dispatcher.onRequest('pty.closeStartupQueryAuthority', (p) => this.closeStartupQueryAuthority(p) ) @@ -1403,8 +1387,7 @@ export class PtyHandler { context?: RequestContext ): Promise { const env = params.env as Record | undefined - const worktreeId = - typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID + const worktreeId = env?.ORCA_WORKTREE_ID const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd() const finishCreation = this.beginPtyCreation([worktreePath, cwd]) @@ -1520,9 +1503,7 @@ export class PtyHandler { const shellOverride = typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' const resolvedShellOverride = resolvePtyShellOverride(shellOverride) - const requestedEnvShell = - process.platform !== 'win32' && typeof env?.SHELL === 'string' ? env.SHELL.trim() : '' - const shell = resolvedShellOverride || requestedEnvShell || resolveDefaultShell() + const shell = resolvedShellOverride || resolveDefaultShell() let id: string do { id = `pty-${this.nextId++}` @@ -1544,15 +1525,6 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) - const worktreeId = - typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID - const historyIsolationEnabled = params.historyIsolationEnabled === true - if (historyIsolationEnabled && worktreeId && basename(shell).toLowerCase().startsWith('fish')) { - injectRelayFishHistoryEnv(spawnEnv, worktreeId) - } - if (historyIsolationEnabled && worktreeId) { - injectRelayHistoryEnv(spawnEnv, worktreeId, shell) - } const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) // Why: SSH PTYs bypass main's host-env builder, so apply the guard after the relay merges its authoritative env. const gitCredentialPromptGuarded = applyTerminalGitCredentialPromptGuard(spawnEnv, { @@ -1619,6 +1591,7 @@ export class PtyHandler { paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, tabId: typeof params.tabId === 'string' ? params.tabId : tabId } + const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const startupIngressIntent = params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION ? parsePtyStartupIngressIntent(params.startupIngress) @@ -1639,7 +1612,6 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, - ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, shellCwd: cwd, shellPathEnv: spawnEnv.PATH, @@ -2062,7 +2034,6 @@ export class PtyHandler { ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), envToDelete: managed.envToDelete, gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, - ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } @@ -2126,22 +2097,11 @@ export class PtyHandler { // Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds. const envToDelete = sanitizeEnvToDelete(entry.envToDelete) const shell = resolveDefaultShell() - const historyIsolationEnabled = entry.historyIsolationEnabled === true const spawnEnv = this.buildSpawnEnv( revivedEnv, { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete ) - if ( - historyIsolationEnabled && - entry.worktreeId && - basename(shell).toLowerCase().startsWith('fish') - ) { - injectRelayFishHistoryEnv(spawnEnv, entry.worktreeId) - } - if (historyIsolationEnabled && entry.worktreeId) { - injectRelayHistoryEnv(spawnEnv, entry.worktreeId, shell) - } // Why: revive lacks the original launch command, so reuse the fresh-spawn guard decision (legacy defaults to unguarded). const gitCredentialPromptGuarded = entry.gitCredentialPromptGuarded === true if (gitCredentialPromptGuarded) { @@ -2177,7 +2137,6 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, - ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), ownerBackend: resolvePtyOwnerBackend({ platform: process.platform, shellPath: shell diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 1af77e87b7c..eaa9ac15e4c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -1,14 +1,28 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' -import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' +import { dirname, join } from 'node:path' +import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { + BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, + getZshFinalZdotdirRestoreBlock, + getZshShellReadyMarkerRegistrationBlock, + SHELL_STARTUP_IDENTITY_MARKER_BLOCK, + getZshStartupFileSourceBlock +} from '../main/shell-templates' + const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] +const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' export type RelayShellLaunchConfig = { args: string[] env: Record } +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + function shellBasename(shellPath: string): string { return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' } @@ -67,6 +81,189 @@ function resolveOriginalZdotdir(env: Record): string { ) } +function ensureOverlayRestoreWrappers(root: string): void { + const zshDir = join(root, 'zsh') + const bashDir = join(root, 'bash') + + const zshEnv = `# Orca relay zsh overlay wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} +export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +case "\${ORCA_ORIG_ZDOTDIR%/}" in + */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; +esac +[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" +export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" +case "\${ORCA_USER_ZDOTDIR%/}" in + */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;; +esac +export ZDOTDIR=${quotePosixSingle(zshDir)} +` + const zshProfile = `# Orca relay zsh overlay wrapper +${getZshStartupFileSourceBlock({ + fileName: '.zprofile', + homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' +})} +` + const zshRc = `# Orca relay zsh overlay wrapper +${getZshStartupFileSourceBlock({ + fileName: '.zshrc', + homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', + interactiveOnly: true +})} +if [[ ! -o login ]]; then + # Why: remote startup files can re-export user defaults after relay spawn. + [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" + [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" + [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac + ${getPosixOmpShellWrapper()} +fi +if [[ ! -o login ]]; then +${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} +fi +` + const zshLogin = `# Orca relay zsh overlay wrapper +${getZshStartupFileSourceBlock({ + fileName: '.zlogin', + homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', + interactiveOnly: true +})} +# Why: .zlogin is the final zsh login startup file before the prompt. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" +[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${getPosixOmpShellWrapper()} +${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} +${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} +` + const bashRc = `# Orca relay bash overlay wrapper +${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} +[[ -f /etc/profile ]] && source /etc/profile +if [[ -f "$HOME/.bash_profile" ]]; then + source "$HOME/.bash_profile" +elif [[ -f "$HOME/.bash_login" ]]; then + source "$HOME/.bash_login" +elif [[ -f "$HOME/.profile" ]]; then + source "$HOME/.profile" +fi +# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as +# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds +# treat each embedded newline as Enter and mangle the prompt into PS2 +# continuation. Modern readline defaults this on; force it for the rest. +[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null +# Why: remote startup files can re-export user defaults after relay spawn. +[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" +[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${getPosixOmpShellWrapper()} +# Why: SSH bash sessions need the same command lifecycle markers as local +# bash so agent rows stop showing "working" when the foreground command exits. +__orca_initializing_wrapper=1 +__orca_osc133_precmd() { + local exit_code=$? + __orca_in_prompt_command=1 + if [[ -n "\${__orca_in_command:-}" ]]; then + printf "\\033]133;D;%s\\007" "$exit_code" + unset __orca_in_command + fi + printf "\\033]133;A\\007" + return "$exit_code" +} +__orca_osc133_prompt_done() { + unset __orca_in_prompt_command; __orca_adopt_outer_debug_trap + trap '__orca_osc133_preexec' DEBUG +} +__orca_osc133_preexec() { + if [[ -n "\${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then + unset __orca_initial_prompt + __orca_in_legacy_prompt_wrapper=1 + return 0 + fi + if [[ -n "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}\${__orca_initial_prompt:-}\${__orca_in_prompt_dispatch:-}\${__orca_in_legacy_prompt_wrapper:-}\${__orca_in_prompt_command:-}" ]]; then + [[ -z "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}" ]] || return 0 + if [[ -n "\${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then + unset __orca_initial_prompt; return 0 + fi + if [[ -n "\${__orca_in_prompt_dispatch:-}" ]]; then + [[ -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 + if [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then + case "$BASH_COMMAND" in + '(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; + esac + fi + elif [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then + return 0 + fi + [[ -z "\${__orca_in_legacy_prompt_wrapper:-}" || -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 + if [[ -n "\${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then + return 0 + fi + fi + case "\${FUNCNAME[1]:-}" in __orca_osc133_*|__orca_prompt_mark|__orca_restore_prompt_status) return 0 ;; esac + case "$BASH_COMMAND" in __orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; esac + __orca_run_user_debug_trap + [[ -z "\${__orca_in_prompt_command:-}" ]] || return 0 + [[ -z "\${__orca_in_command:-}" ]] || return 0 + printf "\\033]133;C\\007" + __orca_in_command=1 +} +${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK} +__orca_prepend_prompt_command "__orca_osc133_precmd" +# Why: SSH startup commands are renderer-delivered; emit the same internal +# readiness marker as local shells only when that delivery mode asks for it. +if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then + __orca_prompt_mark() { + printf "${SHELL_READY_MARKER_ESCAPED}" + } + __orca_append_prompt_command "__orca_prompt_mark" +fi +__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture' +__orca_append_prompt_command "__orca_osc133_prompt_done" +__orca_had_functrace="" +[[ -o functrace ]] && __orca_had_functrace=1 +set +T +__orca_debug_trap_spec="$(trap -p DEBUG)" +[[ -z "$__orca_had_functrace" ]] || set -T +if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then + __orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }" + __orca_debug_trap_command="\${__orca_debug_trap_command% DEBUG}" + eval "__orca_user_debug_trap=$__orca_debug_trap_command" +fi +unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace +unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command +unset __orca_prompt_command_normalized +# Why: arm DEBUG after wrapper setup so the relay rcfile itself does not emit +# fake command-start/end markers before the first prompt. +__orca_initial_prompt=1 +trap '__orca_osc133_preexec' DEBUG +unset __orca_initializing_wrapper +` + + const files = [ + [join(zshDir, '.zshenv'), zshEnv], + [join(zshDir, '.zprofile'), zshProfile], + [join(zshDir, '.zshrc'), zshRc], + [join(zshDir, '.zlogin'), zshLogin], + [join(bashDir, 'rcfile'), bashRc] + ] as const + + for (const [path, content] of files) { + mkdirSync(dirname(path), { recursive: true }) + let existing: string | null = null + try { + existing = readFileSync(path, 'utf8') + } catch { + existing = null + } + // Why: relay wrapper files persist under ~/.orca-relay across app + // upgrades. Existence alone is not enough; stale wrappers would miss + // later fixes such as preserving post-.zshenv ZDOTDIR. + if (existing !== content) { + writeFileSync(path, content, 'utf8') + } + chmodSync(path, 0o644) + } +} + export function getRelayShellLaunchConfig( shellPath: string, env: Record, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts deleted file mode 100644 index 1fa2e9b821a..00000000000 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' -import { - BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, - getZshFinalZdotdirRestoreBlock, - getZshShellReadyMarkerRegistrationBlock, - SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, - getZshStartupFileSourceBlock -} from '../main/shell-templates' - -/** Writes the zsh/bash overlay wrapper files a relay-spawned shell sources. - * Split from pty-shell-launch.ts so the launch-config decisions stay readable - * next to each other rather than buried under ~150 lines of shell templates. */ - -const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' - -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - -export function ensureOverlayRestoreWrappers(root: string): void { - const zshDir = join(root, 'zsh') - const bashDir = join(root, 'bash') - - const zshEnv = `# Orca relay zsh overlay wrapper -${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} -export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" -case "\${ORCA_ORIG_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;; -esac -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" -case "\${ORCA_USER_ZDOTDIR%/}" in - */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;; -esac -export ZDOTDIR=${quotePosixSingle(zshDir)} -` - const zshProfile = `# Orca relay zsh overlay wrapper -${getZshStartupFileSourceBlock({ - fileName: '.zprofile', - homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' -})} -` - const zshRc = `# Orca relay zsh overlay wrapper -${getZshStartupFileSourceBlock({ - fileName: '.zshrc', - homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', - interactiveOnly: true -})} -if [[ ! -o login ]]; then - # Why: remote startup files can re-export user defaults after relay spawn. - [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" - [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" - [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac - ${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} -fi -if [[ ! -o login ]]; then -${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} -fi -` - const zshLogin = `# Orca relay zsh overlay wrapper -${getZshStartupFileSourceBlock({ - fileName: '.zlogin', - homeExpression: '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"', - interactiveOnly: true -})} -# Why: .zlogin is the final zsh login startup file before the prompt. -[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" -[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac -${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} -${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')} -${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} -` - const bashRc = `# Orca relay bash overlay wrapper -${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} -[[ -f /etc/profile ]] && source /etc/profile -if [[ -f "$HOME/.bash_profile" ]]; then - source "$HOME/.bash_profile" -elif [[ -f "$HOME/.bash_login" ]]; then - source "$HOME/.bash_login" -elif [[ -f "$HOME/.profile" ]]; then - source "$HOME/.profile" -fi -# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as -# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds -# treat each embedded newline as Enter and mangle the prompt into PS2 -# continuation. Modern readline defaults this on; force it for the rest. -[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null -# Why: remote startup files can re-export user defaults after relay spawn. -[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" -[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" -[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac -${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} -# Why: SSH bash sessions need the same command lifecycle markers as local -# bash so agent rows stop showing "working" when the foreground command exits. -__orca_initializing_wrapper=1 -__orca_osc133_precmd() { - local exit_code=$? - __orca_in_prompt_command=1 - if [[ -n "\${__orca_in_command:-}" ]]; then - printf "\\033]133;D;%s\\007" "$exit_code" - unset __orca_in_command - fi - printf "\\033]133;A\\007" - return "$exit_code" -} -__orca_osc133_prompt_done() { - unset __orca_in_prompt_command; __orca_adopt_outer_debug_trap - trap '__orca_osc133_preexec' DEBUG -} -__orca_osc133_preexec() { - if [[ -n "\${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then - unset __orca_initial_prompt - __orca_in_legacy_prompt_wrapper=1 - return 0 - fi - if [[ -n "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}\${__orca_initial_prompt:-}\${__orca_in_prompt_dispatch:-}\${__orca_in_legacy_prompt_wrapper:-}\${__orca_in_prompt_command:-}" ]]; then - [[ -z "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}" ]] || return 0 - if [[ -n "\${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then - unset __orca_initial_prompt; return 0 - fi - if [[ -n "\${__orca_in_prompt_dispatch:-}" ]]; then - [[ -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 - if [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then - case "$BASH_COMMAND" in - '(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; - esac - fi - elif [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then - return 0 - fi - [[ -z "\${__orca_in_legacy_prompt_wrapper:-}" || -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0 - if [[ -n "\${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then - return 0 - fi - fi - case "\${FUNCNAME[1]:-}" in __orca_osc133_*|__orca_prompt_mark|__orca_restore_prompt_status) return 0 ;; esac - case "$BASH_COMMAND" in __orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; esac - __orca_run_user_debug_trap - [[ -z "\${__orca_in_prompt_command:-}" ]] || return 0 - [[ -z "\${__orca_in_command:-}" ]] || return 0 - printf "\\033]133;C\\007" - __orca_in_command=1 -} -${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK} -__orca_prepend_prompt_command "__orca_osc133_precmd" -# Why: SSH startup commands are renderer-delivered; emit the same internal -# readiness marker as local shells only when that delivery mode asks for it. -if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then - __orca_prompt_mark() { - printf "${SHELL_READY_MARKER_ESCAPED}" - } - __orca_append_prompt_command "__orca_prompt_mark" -fi -__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture' -__orca_append_prompt_command "__orca_osc133_prompt_done" -__orca_had_functrace="" -[[ -o functrace ]] && __orca_had_functrace=1 -set +T -__orca_debug_trap_spec="$(trap -p DEBUG)" -[[ -z "$__orca_had_functrace" ]] || set -T -if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then - __orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }" - __orca_debug_trap_command="\${__orca_debug_trap_command% DEBUG}" - eval "__orca_user_debug_trap=$__orca_debug_trap_command" -fi -unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace -unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command -unset __orca_prompt_command_normalized -# Why: arm DEBUG after wrapper setup so the relay rcfile itself does not emit -# fake command-start/end markers before the first prompt. -__orca_initial_prompt=1 -trap '__orca_osc133_preexec' DEBUG -unset __orca_initializing_wrapper -` - - const files = [ - [join(zshDir, '.zshenv'), zshEnv], - [join(zshDir, '.zprofile'), zshProfile], - [join(zshDir, '.zshrc'), zshRc], - [join(zshDir, '.zlogin'), zshLogin], - [join(bashDir, 'rcfile'), bashRc] - ] as const - - for (const [path, content] of files) { - mkdirSync(dirname(path), { recursive: true }) - let existing: string | null = null - try { - existing = readFileSync(path, 'utf8') - } catch { - existing = null - } - // Why: relay wrapper files persist under ~/.orca-relay across app - // upgrades. Existence alone is not enough; stale wrappers would miss - // later fixes such as preserving post-.zshenv ZDOTDIR. - if (existing !== content) { - writeFileSync(path, content, 'utf8') - } - chmodSync(path, 0o644) - } -} diff --git a/src/relay/terminal-history.test.ts b/src/relay/terminal-history.test.ts deleted file mode 100644 index b6c59e332d3..00000000000 --- a/src/relay/terminal-history.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { hashWorktreeId } from '../main/terminal-history-id' -import { deleteRelayHistory, injectRelayHistoryEnv } from './terminal-history' - -const worktreeId = 'relay-test::/remote/worktree' -const historyDir = join(homedir(), '.orca-remote', 'terminal-history') -const historyPrefix = hashWorktreeId(worktreeId) - -afterEach(() => { - for (const filename of ['bash_history', 'zsh_history']) { - rmSync(join(historyDir, `${historyPrefix}-${filename}`), { force: true }) - } -}) - -describe('relay shell history', () => { - it.each(['/bin/bash', '/usr/bin/zsh'])('scopes %s without replacing caller HISTFILE', (shell) => { - const env: Record = {} - const dir = injectRelayHistoryEnv(env, worktreeId, shell) - expect(dir).toBe(historyDir) - expect(env.HISTFILE).toBe( - join( - historyDir, - `${historyPrefix}-${shell.endsWith('bash') ? 'bash_history' : 'zsh_history'}` - ) - ) - - const custom = { HISTFILE: '/custom/history' } - expect(injectRelayHistoryEnv(custom, worktreeId, shell)).toBeNull() - expect(custom.HISTFILE).toBe('/custom/history') - }) - - it('does not scope unsupported shells and cleans up idempotently', () => { - const env: Record = {} - expect(injectRelayHistoryEnv(env, worktreeId, '/bin/fish')).toBeNull() - deleteRelayHistory(worktreeId) - deleteRelayHistory(worktreeId) - expect(existsSync(join(historyDir, `${historyPrefix}-bash_history`))).toBe(false) - }) - - it.skipIf(process.platform === 'win32')('refuses a pre-existing final symlink', () => { - mkdirSync(historyDir, { recursive: true }) - const target = join(historyDir, 'relay-unrelated-history') - const path = join(historyDir, `${historyPrefix}-bash_history`) - writeFileSync(target, 'unrelated') - symlinkSync(target, path) - - expect(injectRelayHistoryEnv({}, worktreeId, '/bin/bash')).toBeNull() - expect(readFileSync(target, 'utf8')).toBe('unrelated') - rmSync(path) - rmSync(target) - }) -}) diff --git a/src/relay/terminal-history.ts b/src/relay/terminal-history.ts deleted file mode 100644 index 68c86737b41..00000000000 --- a/src/relay/terminal-history.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - closeSync, - constants as fsConstants, - fstatSync, - lstatSync, - mkdirSync, - openSync, - unlinkSync -} from 'node:fs' -import { homedir } from 'node:os' -import { basename, join } from 'node:path' -import { hashWorktreeId } from '../main/terminal-history-id' -import { - deleteFishHistoryFile, - fishHistorySessionName, - resolveFishHistoryDir -} from '../main/fish-history-session' - -const HISTORY_ROOT = join(homedir(), '.orca-remote', 'terminal-history') - -function historyFilename(shell: string): string | null { - const name = basename(shell).toLowerCase() - if (name.startsWith('bash')) { - return 'bash_history' - } - if (name.startsWith('zsh')) { - return 'zsh_history' - } - return null -} - -export function injectRelayHistoryEnv( - env: Record, - worktreeId: string, - shell: string -): string | null { - // Why first: same reason as the desktop path — an inherited ORCA_HISTFILE - // would otherwise survive every early return below and let the remote wrapper - // re-export another worktree's history path. - delete env.ORCA_HISTFILE - if (env.HISTFILE) { - return null - } - const filename = historyFilename(shell) - if (!filename) { - return null - } - try { - mkdirSync(HISTORY_ROOT, { recursive: true, mode: 0o700 }) - const rootStat = lstatSync(HISTORY_ROOT) - if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { - return null - } - const path = join(HISTORY_ROOT, `${hashWorktreeId(worktreeId)}-${filename}`) - let existing: ReturnType | null = null - try { - const stat = lstatSync(path, { bigint: true }) - if (stat.isSymbolicLink() || !stat.isFile()) { - return null - } - existing = stat - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - return null - } - } - const fd = openSync( - path, - fsConstants.O_RDWR | - fsConstants.O_CREAT | - (process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW), - 0o600 - ) - const actual = fstatSync(fd, { bigint: true }) - if ( - !actual.isFile() || - (existing && (actual.dev !== existing.dev || actual.ino !== existing.ino)) - ) { - closeSync(fd) - return null - } - closeSync(fd) - env.HISTFILE = path - // Why a second variable: a remote macOS `/etc/zshrc` assigns HISTFILE - // unconditionally before the wrapper runs, so the injected value is gone by - // the first prompt. The wrapper restores it from here (#11044) — the same - // contract the desktop PTY path uses. - env.ORCA_HISTFILE = path - return HISTORY_ROOT - } catch { - return null - } -} - -export function deleteRelayHistory(worktreeId: string): void { - try { - const rootStat = lstatSync(HISTORY_ROOT) - if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { - return - } - for (const filename of ['bash_history', 'zsh_history']) { - const path = join(HISTORY_ROOT, `${hashWorktreeId(worktreeId)}-${filename}`) - try { - if (!lstatSync(path).isSymbolicLink()) { - unlinkSync(path) - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - } - } catch (error) { - console.warn( - `[pty:history] Failed to delete relay shell history: ${error instanceof Error ? error.message : String(error)}` - ) - } -} - -/** fish keeps history in its own data dir under a session NAME, so the relay - * isolates it the same way the desktop app does and deletes by that name. - * No metadata file is needed: the name is a pure function of the worktree id. */ -export function injectRelayFishHistoryEnv(env: Record, worktreeId: string): void { - if (env.fish_history) { - return - } - env.fish_history = fishHistorySessionName(hashWorktreeId(worktreeId)) -} - -export function deleteRelayFishHistory(worktreeId: string, env?: NodeJS.ProcessEnv): void { - deleteFishHistoryFile(fishHistorySessionName(hashWorktreeId(worktreeId)), [ - resolveFishHistoryDir(env) - ]) -} diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 4206fb46c87..913b6356322 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -229,8 +229,9 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( } function isPosixFamilyWindowsShellConfigured(): boolean { - return ['posix', 'unix'].includes( - resolveWindowsShellStartupFamily(useAppStore.getState().settings?.terminalWindowsShell) + return ( + resolveWindowsShellStartupFamily(useAppStore.getState().settings?.terminalWindowsShell) === + 'posix' ) } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index a96564b6652..749038df3e4 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -588,7 +588,7 @@ describe('submitFolderWorkspaceCreate', () => { 'folder-workspace-1', expect.objectContaining({ startup: expect.objectContaining({ - command: `claude 'Use Bob'"'"'s POSIX startup'` + command: "claude 'Use Bob'\\''s POSIX startup'" }) }) ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index 9bdada930ef..debbd7f46be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -292,7 +292,7 @@ describe('connectPanePty', () => { expect.objectContaining({ sessionId: 'lost-pty', command: - `codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'"'"'s'`, + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\\''s'", env: expect.objectContaining({ ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-1', diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 8b7da8315de..f89f4054bf4 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -306,9 +306,7 @@ describe('ai vault resume command runtime', () => { } }) ).toBe( - `command test -n "$fish_pid" && set --erase -g CODEX_HOME ORCA_CODEX_HOME; ` + - `command test -z "$fish_pid" && unset CODEX_HOME ORCA_CODEX_HOME; true; ` + - `cd '/home/alice/repo' && codex 'resume' 'session one'` + "unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'" ) }) @@ -600,9 +598,7 @@ describe('ai vault resume command runtime', () => { }) expect(command).toBe( - `command test -n "$fish_pid" && set --erase -g CODEX_HOME ORCA_CODEX_HOME; ` + - `command test -z "$fish_pid" && unset CODEX_HOME ORCA_CODEX_HOME; true; ` + - `cd '/home/alice/repo' && codex 'resume' 'session one'` + "unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'" ) expect(command).not.toContain('/retired/shared-home') }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 31d3bdef784..92e826f83a9 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -70,7 +70,9 @@ export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWork } const shell = resolveAiVaultResumeShell(args) const separator = commandSeparator(shell) - const clearHomes = clearEnvCommand(['CODEX_HOME', 'ORCA_CODEX_HOME'], shell) + const clearHomes = ['CODEX_HOME', 'ORCA_CODEX_HOME'] + .map((name) => clearEnvCommand(name, shell)) + .join(separator) return `${clearHomes}${separator}${command}` } @@ -239,10 +241,32 @@ function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartu state: args.state, worktreeId: args.worktreeId, platform, - isLocalSession + isLocalSession, + parsedByClientLoginShell: isLocalSession && runsOnClientLoginShell(args, platform) }) } +/** + * Whether the resume line is handed to THIS machine's login shell. + * + * Why not `isLocalSession`: that only says the session file was scanned locally + * (no executionHostId), which is still true when the worktree lives on an SSH + * or runtime host — the command then goes to that host's shell. The WSL case is + * caught by the platform mismatch (a WSL worktree resolves to 'linux' on win32). + */ +function runsOnClientLoginShell( + args: AiVaultResumeWorktreeArgs, + platform: NodeJS.Platform +): boolean { + const executionHost = parseExecutionHostId( + getExecutionHostIdForWorktree(args.state, args.worktreeId ?? args.state.activeWorktreeId) + ) + if (executionHost?.kind === 'ssh' || executionHost?.kind === 'runtime') { + return false + } + return platform === CLIENT_PLATFORM +} + export function getAiVaultAgentProviderSession( session: Pick & { filePath?: string } ): AgentProviderSessionMetadata | null { diff --git a/src/renderer/src/lib/ai-vault-resume-shell.test.ts b/src/renderer/src/lib/ai-vault-resume-shell.test.ts index d11caa92084..e2ab4b9a173 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.test.ts @@ -51,33 +51,45 @@ function withLoginShell(shell: string, run: () => T): T { } describe('resolveAiVaultResumeStartupShell', () => { - // Why no login-shell cases: the Unix branch emits quoting and env clearing that - // are correct in sh and fish alike, so it no longer probes $SHELL at all — see - // startup-shell-portability.live-shell.test.ts for the proof it holds. - it.each(['/opt/homebrew/bin/fish', '/bin/zsh', '/bin/bash'])( - 'reports one Unix dialect regardless of the login shell (%s)', - (loginShell) => { - expect( - withLoginShell(loginShell, () => - resolveAiVaultResumeStartupShell({ - state: makeState(), - worktreeId: 'repo-1::worktree-1', - platform: 'darwin', - isLocalSession: true - }) - ) - ).toBe('posix') - } - ) + it('reports the fish dialect for a local session under a fish login shell', () => { + expect( + withLoginShell('/opt/homebrew/bin/fish', () => + resolveAiVaultResumeStartupShell({ + state: makeState(), + worktreeId: 'repo-1::worktree-1', + platform: 'darwin', + isLocalSession: true, + parsedByClientLoginShell: true + }) + ) + ).toBe('fish') + }) - it('stays on the Unix dialect for a LOCAL session whose command a remote host parses', () => { + it('stays on sh for zsh users', () => { + expect( + withLoginShell('/bin/zsh', () => + resolveAiVaultResumeStartupShell({ + state: makeState(), + worktreeId: 'repo-1::worktree-1', + platform: 'darwin', + isLocalSession: true, + parsedByClientLoginShell: true + }) + ) + ).toBe('posix') + }) + + it('stays on sh for a LOCAL session whose command a remote host parses', () => { + // The reachable case: a locally scanned session has no executionHostId, so + // isLocalSession stays true while the command is bound for an SSH host. expect( withLoginShell('/opt/homebrew/bin/fish', () => resolveAiVaultResumeStartupShell({ state: makeState(), worktreeId: 'repo-1::worktree-1', platform: 'linux', - isLocalSession: true + isLocalSession: true, + parsedByClientLoginShell: false }) ) ).toBe('posix') @@ -92,7 +104,23 @@ describe('copied real-home Codex resume command', () => { codexHome: null } - it('clears inherited Codex homes for a worktree on an SSH host', () => { + it('clears inherited Codex homes with fish syntax under a fish login shell', () => { + expect( + withLoginShell('/opt/homebrew/bin/fish', () => + buildAiVaultResumeCopyCommandForWorktree({ + state: makeState(), + worktreeId: 'repo-1::worktree-1', + session + }) + ) + ).toBe( + "set -e CODEX_HOME; set -e ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'" + ) + }) + + it('keeps `unset` when a fish client targets an SSH worktree', () => { + // The session was scanned locally (no executionHostId), but the worktree lives + // on an SSH host: `set -e CODEX_HOME` would enable errexit there, not clear it. expect( withLoginShell('/opt/homebrew/bin/fish', () => buildAiVaultResumeCopyCommandForWorktree({ @@ -102,15 +130,11 @@ describe('copied real-home Codex resume command', () => { }) ) ).toBe( - `command test -n "$fish_pid" && set --erase -g CODEX_HOME ORCA_CODEX_HOME; ` + - `command test -z "$fish_pid" && unset CODEX_HOME ORCA_CODEX_HOME; true; ` + - `cd '/home/alice/repo' && codex 'resume' 'session one'` + "unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'" ) }) - it('emits the same self-contained teardown under an sh-family login shell', () => { - // Why identical to the fish case: this text is COPIED, so it may be pasted - // into any shell — it carries its own fish/sh branch instead of guessing. + it('keeps `unset` for sh-family login shells', () => { expect( withLoginShell('/bin/bash', () => buildAiVaultResumeCopyCommandForWorktree({ @@ -120,9 +144,7 @@ describe('copied real-home Codex resume command', () => { }) ) ).toBe( - `command test -n "$fish_pid" && set --erase -g CODEX_HOME ORCA_CODEX_HOME; ` + - `command test -z "$fish_pid" && unset CODEX_HOME ORCA_CODEX_HOME; true; ` + - `cd '/home/alice/repo' && codex 'resume' 'session one'` + "unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'" ) }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts index 3d36b28c07f..9b6214fb247 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -4,9 +4,11 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime' import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' import { + resolveLoginShellStartupDialect, resolveStartupShell, type AgentStartupShell } from '../../../shared/tui-agent-startup-shell' +import { getClientLoginShell } from '@/lib/client-login-shell' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { parseWslUncPath } from '../../../shared/wsl-paths' @@ -26,12 +28,19 @@ export function resolveAiVaultResumeStartupShell(args: { worktreeId?: string | null platform: NodeJS.Platform isLocalSession: boolean + /** + * True only when this machine's own login shell parses the command. A local + * session is NOT enough: a locally scanned session carries no executionHostId + * yet can target an SSH/runtime/WSL worktree, whose shell is unrelated. + */ + parsedByClientLoginShell?: boolean }): AgentStartupShell { - // Why no login-shell probe: everything this command is built from — quoting - // and env clearing — is emitted in a form that is correct in sh and fish - // alike, so the Unix branch never has to know which one reads the line. + // Why: fish rejects `unset`, so the client's login shell decides the dialect — + // but only when it is the shell that reads the line; otherwise it stays sh. if (args.platform !== 'win32') { - return 'posix' + return args.parsedByClientLoginShell + ? resolveLoginShellStartupDialect(getClientLoginShell()) + : 'posix' } const projectRuntime = args.isLocalSession ? getLocalProjectExecutionRuntimeContext(args.state, args.worktreeId, CLIENT_PLATFORM) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 5dc076afc1b..78fd3a91f6e 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -332,7 +332,7 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: 'C:\\Users\\jinwo\\repo\\feature', - command: `claude '--dangerously-skip-permissions' 'don'"'"'t use powershell quoting'`, + command: "claude '--dangerously-skip-permissions' 'don'\\''t use powershell quoting'", connectionId: null, worktreeId: 'wt-1', tabId: expect.stringMatching(UUID_RE) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts index 4d91f86a632..c4034ba5bee 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -238,7 +238,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: `claude '--dangerously-skip-permissions' --prefill 'review Bob'"'"'s change'` + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -293,7 +293,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: `claude '--dangerously-skip-permissions' --prefill 'review Bob'"'"'s change'` + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -326,7 +326,7 @@ describe('launchAgentInNewTab Windows shell quoting', () => { launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as { command: string } - expect(queued.command).toContain(`'don'"'"'t'`) + expect(queued.command).toContain("'don'\\''t'") expect(queued.command).not.toContain("'don''t'") }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 80e41dd3366..52243fcc679 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -688,7 +688,7 @@ describe('launchWorkItemDirect', () => { expect(mocks.activateAndRevealWorktree).toHaveBeenCalled() const activationOptions = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1] - expect(activationOptions.startup.command).toContain(`command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true`) + expect(activationOptions.startup.command).toContain('unset ORCA_PI_PREFILL') expect(activationOptions.startup.command).not.toContain('Remove-Item Env:ORCA_PI_PREFILL') }) @@ -729,7 +729,7 @@ describe('launchWorkItemDirect', () => { expect(mocks.ensureRemoteDetectedAgents).toHaveBeenCalledWith('ssh-1') expect(mocks.ensureDetectedAgents).not.toHaveBeenCalled() const activationOptions = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1] - expect(activationOptions.startup.command).toContain(`command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true`) + expect(activationOptions.startup.command).toContain('unset ORCA_PI_PREFILL') }) it('plans direct local Windows-path launches with POSIX startup for WSL project runtime', async () => { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index c90aff730c4..c4f219f1524 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -885,7 +885,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const resumedTab = state.tabsByWorktree['wt-1']?.[0] expect(resumedTab?.launchAgent).toBe('claude') expect(state.pendingStartupByTabId[resumedTab!.id]?.command).toContain( - `'--resume' 'sess-1'"'"'s'` + "'--resume' 'sess-1'\\''s'" ) }) }) diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 768a5363c85..74dba64f02c 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -352,7 +352,7 @@ describe('buildAgentDraftLaunchPlan', () => { }) ).toEqual({ agent: 'pi', - launchCommand: `pi; command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true`, + launchCommand: 'pi; unset ORCA_PI_PREFILL', expectedProcess: 'pi', env: { ORCA_PI_PREFILL: 'https://github.com/acme/repo/issues/42' }, launchConfig: emptyLaunchConfig('pi') diff --git a/src/shared/agent-resume-launch-command.ts b/src/shared/agent-resume-launch-command.ts index cda40878c28..1093735d266 100644 --- a/src/shared/agent-resume-launch-command.ts +++ b/src/shared/agent-resume-launch-command.ts @@ -1,6 +1,5 @@ import type { ResumableTuiAgent } from './agent-session-resume' import { - isPosixStartupShell, quoteStartupArg, tokenizeStartupCommand, type AgentStartupShell @@ -38,9 +37,9 @@ function findClaudeExecutableIndex(tokens: readonly string[], shell: AgentStartu return i } if ( - // Why: `NAME=value cmd` is sh-family syntax (fish included, 3.1+); on - // cmd/PowerShell such a token is a bogus executable name, not a prefix. - (isPosixStartupShell(shell) && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) || + // Why: `NAME=value cmd` is posix-only syntax; on cmd/PowerShell such a + // token is just a bogus executable name, not a prefix to skip. + (shell === 'posix' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) || (shell === 'powershell' && token === '&' && i === 0) ) { continue diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index 632de5aaac3..13c33b8145e 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -72,7 +72,7 @@ export function buildAiVaultResumeShellCommand(args: { }) } - const resumeCommand = `${codexHomeEnvPrefix(codexHome?.trim() || null, platform, shell)}${ + const resumeCommand = `${codexHomeEnvPrefix(codexHome?.trim() || null, platform)}${ args.resumeCommand }` if (platform === 'win32' && shell === 'cmd') { @@ -89,7 +89,7 @@ export function buildAiVaultResumeShellCommand(args: { return `cmd /d /s /c ${quoteWindowsCmdArg(inner)}` } - return `cd ${quoteResumeArg(cwd, platform, shell)} && ${resumeCommand}` + return `cd ${quoteShellArg(cwd, platform)} && ${resumeCommand}` } function buildResumeShellCommandForShell(args: { @@ -184,37 +184,21 @@ function buildAgentResumeInvocation( } } -function codexHomeEnvPrefix( - codexHome: string | null, - platform: NodeJS.Platform, - shell?: AgentStartupShell -): string { +function codexHomeEnvPrefix(codexHome: string | null, platform: NodeJS.Platform): string { if (!codexHome) { return '' } if (platform === 'win32') { return `set ${quoteWindowsCmdArg(`CODEX_HOME=${codexHome}`)} && ` } - // fish accepts the `NAME=value cmd` prefix (3.1+), but not sh's quoting. - return `CODEX_HOME=${quoteResumeArg(codexHome, platform, shell)} ` + return `CODEX_HOME=${quoteShellArg(codexHome, platform)} ` } -/** Quotes for the live shell when one is known, else for the platform's default. */ -function quoteResumeArg( - value: string, - platform: NodeJS.Platform, - shell?: AgentStartupShell -): string { - return shell ? quoteStartupArg(value, shell) : quoteShellArg(value, platform) -} - -/** Why not the sh `'\''` idiom here: this is the same resume command the shell - * branch above builds, and fish reads that idiom differently — it would halve - * backslashes in a path and reject a trailing one. Deferring to - * `quoteStartupArg` keeps one spelling regardless of whether a caller happened - * to pass a shell. */ function quoteShellArg(value: string, platform: NodeJS.Platform): string { - return platform === 'win32' ? quoteWindowsCmdArg(value) : quoteStartupArg(value, 'posix') + if (platform === 'win32') { + return quoteWindowsCmdArg(value) + } + return `'${value.replace(/'/g, `'\\''`)}'` } function quoteWindowsCmdArg(value: string): string { diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 6c331594e37..1e41228f37e 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -274,8 +274,7 @@ export type GlobalSettings = { claudeManagedAccounts: ClaudeManagedAccount[] activeClaudeManagedAccountId: string | null activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection - /** Per-worktree shell history so ArrowUp doesn't surface other worktrees' commands (a HISTFILE for - * bash/zsh, a `fish_history` session name for fish). Defaults to true. */ + /** Per-worktree shell history file so ArrowUp doesn't surface other worktrees' commands. Defaults to true. */ terminalScopeHistoryByWorktree: boolean /** Kill switch for hidden terminal view parking: unmount long-hidden panes while a pane-less watcher keeps PTY side effects alive. */ terminalHiddenViewParking?: boolean diff --git a/src/shared/hermes-startup-query.ts b/src/shared/hermes-startup-query.ts index a8536f91601..a120305e1ee 100644 --- a/src/shared/hermes-startup-query.ts +++ b/src/shared/hermes-startup-query.ts @@ -159,11 +159,7 @@ function buildQueryCommand(argv: string[], shell: AgentStartupShell): string { // Why: a fixed single-quote-safe wrapper parses in POSIX shells and pwsh; // the dynamic argv is decoded only after entering the known `sh` grammar. const script = `${POSIX_QUERY_VARIABLE}="\${${ORCA_HERMES_STARTUP_QUERY_ENV}}"; unset ${ORCA_HERMES_STARTUP_QUERY_ENV}; eval "$(printf %b "${encodedInvocation}")"` - // Why `shell` and not 'posix': the body runs under `sh`, but the quoting around - // it is parsed by the shell that types the line. Today's payload survives sh - // quoting in fish only because its escapes happen to be `\0NNN` and never `\\` - // — quote for the real dialect so that stays an implementation detail. - return `sh -c ${quoteStartupArg(script, shell)}` + return `sh -c ${quoteStartupArg(script, 'posix')}` } export function planHermesStartupQuery(args: { @@ -185,10 +181,7 @@ export function planHermesStartupQuery(args: { return null } const command = buildQueryCommand(argv, args.shell) - const env = { - ...args.agentEnv, - [ORCA_HERMES_STARTUP_QUERY_ENV]: args.prompt - } + const env = { ...args.agentEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]: args.prompt } const envSize = Object.entries(env).reduce((total, [key, value]) => { if (args.platform === 'win32') { return total + key.length + value.length + 2 diff --git a/src/shared/startup-shell-portability.live-shell.test.ts b/src/shared/startup-shell-portability.live-shell.test.ts deleted file mode 100644 index c97a28c3045..00000000000 --- a/src/shared/startup-shell-portability.live-shell.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Proves the central claim of the POSIX startup dialect against real shells: - * ONE emitted string is correct in sh, bash, zsh, dash and fish alike, so Orca - * never has to detect which shell will parse a queued command line. - * - * The shells are the oracle. Each case is handed to a real shell, which echoes - * the value back through `printf`, and the test compares bytes — a hand-written - * expectation would only re-state the implementation's own assumptions. - * - * Two of the quoting cases are regression pins with teeth: `\\server\share` and - * a trailing backslash are exactly what plain sh `'\''` quoting gets wrong when - * fish reads it (silently dropped backslashes, and a hard syntax error), and - * `command eval` is what zsh gets wrong — its `command` resolves external - * binaries only, so it cannot run a builtin. - */ -import { execFileSync } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { afterAll, describe, expect, it } from 'vitest' -import { fishRequirementViolation, resolveFishBinary } from './fish-binary-requirement' -import { clearEnvCommand, quoteStartupArg } from './tui-agent-startup-shell' - -const FISH = resolveFishBinary(4) - -type LiveShell = { name: string; path: string } - -/** Every Unix shell on this machine a queued Orca command line could land in. */ -function discoverShells(): LiveShell[] { - const shells: LiveShell[] = [] - for (const path of ['/bin/sh', '/bin/bash', '/bin/zsh', '/bin/dash', '/usr/bin/dash', '/bin/ksh']) { - if (existsSync(path) && !shells.some((shell) => shell.name === basename(path))) { - shells.push({ name: basename(path), path }) - } - } - if (FISH.available) { - // Why absolute: runInShell scrubs PATH, so a bare `fish` would not resolve. - const path = FISH.path.includes('/') - ? FISH.path - : execFileSync('command', ['-v', FISH.path], { encoding: 'utf8', shell: true }).trim() - shells.push({ name: 'fish', path }) - } - return shells -} - -function basename(path: string): string { - return path.split('/').pop() ?? path -} - -// Why a real (empty) HOME rather than a bogus one: fish needs a writable config -// dir to hold universal variables, and warns loudly on every launch without it. -const SANDBOX_HOME = mkdtempSync(path.join(tmpdir(), 'orca-shell-portability-')) - -/** Env with no user shell config reachable, so only Orca's own text is exercised. */ -function sandboxEnv(): NodeJS.ProcessEnv { - return { - PATH: '/usr/bin:/bin:/usr/sbin:/sbin', - HOME: SANDBOX_HOME, - XDG_CONFIG_HOME: path.join(SANDBOX_HOME, 'config'), - XDG_DATA_HOME: path.join(SANDBOX_HOME, 'data') - } -} - -/** Runs one line in a real shell with no user config reachable. */ -function runInShell(shell: LiveShell, script: string): string { - return execFileSync(shell.path, ['-c', script], { - encoding: 'utf8', - timeout: 20_000, - env: sandboxEnv() - }) -} - -const SHELLS = discoverShells() - -const QUOTING_CASES: readonly string[] = [ - 'plain', - '', - 'with space', - "it's", - 'a\\b', - '\\d+', - '\\\\server\\share', - 'C:\\Users\\foo', - 'ends\\', - '\\', - '$HOME', - '`whoami`', - '$(whoami)', - '${HOME}', - '(paren)', - '{a,b}', - '*glob?', - '[bracket]', - '#hash', - 'a\nb', - 'a\tb', - 'semi;colon', - 'pipe|and&', - 'redirect>out { - rmSync(SANDBOX_HOME, { recursive: true, force: true }) -}) - -// Why skipIf: on Windows discoverShells() finds nothing, so the suite would -// fail for want of a POSIX shell rather than for any defect it tests. -describe.skipIf(process.platform === 'win32')( - 'one POSIX startup dialect is correct in every Unix shell', - () => { - // Always runs, so a CI lane cannot report green with the shell cases skipped. - it('has the fish this suite needs when CI requires one', () => { - expect(fishRequirementViolation(FISH)).toBeNull() - }) - - it('found shells to test against', () => { - expect(SHELLS.map((shell) => shell.name)).toContain('sh') - }) - - describe.each(SHELLS)('$name', (shell) => { - it.each(QUOTING_CASES)('quotes %j so the shell yields it back verbatim', (value) => { - expect(runInShell(shell, `printf '%s' ${quoteStartupArg(value, 'posix')}`)).toBe(value) - }) - - it('quotes a whole argv so every argument survives independently', () => { - const argv = QUOTING_CASES.filter((value) => value !== '') - const quoted = argv.map((value) => quoteStartupArg(value, 'posix')).join(' ') - // Why NUL: it is the one byte no case above can contain, so it cannot be - // forged by a value that was mis-split into two arguments. - const output = runInShell(shell, `printf '%s\\0' ${quoted}`) - expect(output.split('\0').slice(0, -1)).toEqual(argv) - }) - - it('clears an exported variable with no wrapper installed', () => { - const clear = clearEnvCommand('ORCA_PI_PREFILL', 'posix') - const probe = - shell.name === 'fish' - ? `set -gx ORCA_PI_PREFILL draft; ${clear}; set -q ORCA_PI_PREFILL; and echo STILL; or echo CLEARED` - : `ORCA_PI_PREFILL=draft; export ORCA_PI_PREFILL; ${clear}; echo "\${ORCA_PI_PREFILL:+STILL}\${ORCA_PI_PREFILL:-CLEARED}"` - expect(runInShell(shell, probe).trim()).toBe('CLEARED') - }) - - it('clears several variables in one statement', () => { - const clear = clearEnvCommand(['ORCA_A', 'ORCA_B'], 'posix') - const probe = - shell.name === 'fish' - ? `set -gx ORCA_A 1; set -gx ORCA_B 2; ${clear}; set -q ORCA_A; or set -q ORCA_B; and echo STILL; or echo CLEARED` - : `ORCA_A=1 ORCA_B=2; export ORCA_A ORCA_B; ${clear}; echo "\${ORCA_A:+STILL}\${ORCA_B:+STILL}\${ORCA_A:-CLEARED}"` - expect(runInShell(shell, probe).trim()).toBe('CLEARED') - }) - - // Why this case: fish's `set -e` returns non-zero for a variable that is - // already unset. An `A && B || C` spelling would fall through to the sh - // branch and print `Unknown command: unset` — the exact bug being fixed. - it.each([ - ['already set', true], - ['already unset', false] - ])('exits 0 and writes nothing to stderr when the variable is %s', (_label, preset) => { - const clear = clearEnvCommand('ORCA_PI_PREFILL', 'posix') - const setUp = preset - ? shell.name === 'fish' - ? 'set -gx ORCA_PI_PREFILL draft; ' - : 'ORCA_PI_PREFILL=draft; export ORCA_PI_PREFILL; ' - : '' - const stderr = execFileSync(shell.path, ['-c', `${setUp}${clear} 2>&1 1>/dev/null`], { - encoding: 'utf8', - timeout: 20_000, - env: sandboxEnv() - }) - expect(stderr).toBe('') - }) - - // Why INTERACTIVE (-i, script on stdin): aliases are only expanded by an - // interactive shell, which is the mode Orca types into — a `-c` run cannot - // see this class of bug at all. A user with `alias test=…` would otherwise - // silently skip both branches and keep the prefill exported. - it.runIf(['bash', 'zsh'].includes(shell.name))( - 'clears even when `test` is aliased away in an interactive shell', - () => { - const clear = clearEnvCommand('ORCA_PI_PREFILL', 'posix') - const script = [ - 'alias test=false', - 'ORCA_PI_PREFILL=draft; export ORCA_PI_PREFILL', - clear, - 'echo "RESULT=${ORCA_PI_PREFILL:+STILL}${ORCA_PI_PREFILL:-CLEARED}"' - ].join('\n') - const out = execFileSync(shell.path, ['-i'], { - input: `${script}\n`, - encoding: 'utf8', - timeout: 20_000, - env: sandboxEnv(), - stdio: ['pipe', 'pipe', 'ignore'] - }) - expect(out).toContain('RESULT=CLEARED') - } - ) - - // Why: `$fish_pid` is a heuristic — any non-empty value takes the fish - // branch. `set -e NAME` there would enable errexit, silently changing the - // semantics of every command after it for the rest of the session. - // `set --erase` cannot: `--` ends option parsing, so the worst a misfire - // costs is a usage message and the positional parameters (which an - // interactive shell does not meaningfully use). - // Scoped to bash/zsh because `set` is a POSIX special builtin: in sh, dash - // and ksh a misfire aborts a NON-interactive shell before anything can be - // observed. Interactively — the mode Orca types into — they survive without - // errexit too, but that is not scriptable here. bash and zsh report `$-` - // either way, and both DID silently enable errexit under the old `set -e`. - // Why `-g` is not optional: without it, a name that exists ONLY as a - // universal — `set -Ux CODEX_HOME …`, a normal thing for a fish user to - // have — is permanently deleted from every future session. Reachable from - // the clipboard command, which may run with no injected value at all. - it.runIf(shell.name === 'fish')('never deletes a lone universal variable', () => { - const clear = clearEnvCommand('ORCA_PI_PREFILL', 'posix') - const probe = `set -Ux ORCA_PI_PREFILL persisted; ${clear}; set -q ORCA_PI_PREFILL; and echo "RESULT=$ORCA_PI_PREFILL"; or echo RESULT=DESTROYED; set -Ue ORCA_PI_PREFILL` - expect(runInShell(shell, probe)).toContain('RESULT=persisted') - }) - - // A universal shadowed by the injected global: the global goes, theirs - // comes back. That is the wanted outcome, not a missed erase. - it.runIf(shell.name === 'fish')('reveals a shadowed universal instead of deleting it', () => { - const clear = clearEnvCommand('ORCA_PI_PREFILL', 'posix') - const probe = `set -Ux ORCA_PI_PREFILL mine; set -gx ORCA_PI_PREFILL injected; ${clear}; echo "RESULT=$ORCA_PI_PREFILL"; set -Ue ORCA_PI_PREFILL` - expect(runInShell(shell, probe)).toContain('RESULT=mine') - }) - - it.runIf(['bash', 'zsh'].includes(shell.name))( - 'never enables errexit when $fish_pid misfires', - () => { - const clear = clearEnvCommand('ORCA_PI_PREFILL', 'posix') - const probe = `fish_pid=1; export fish_pid; ${clear} 2>/dev/null; printf '%s' "$-"` - expect(runInShell(shell, probe).trim()).not.toContain('e') - } - ) - }) - } -) diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index 45d83900b86..cd47d81b971 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -125,7 +125,7 @@ describe('tui agent startup session options', () => { allowEmptyPromptLaunch: true, sessionOptions: { model: "team's-model", effort: 'high' } }) - expect(plan?.launchCommand).toContain(`'team'"'"'s-model'`) + expect(plan?.launchCommand).toContain("'team'\\''s-model'") }) it('threads options through native draft launches', () => { diff --git a/src/shared/tui-agent-startup-shell.test.ts b/src/shared/tui-agent-startup-shell.test.ts index 1698e86b08b..2d155063462 100644 --- a/src/shared/tui-agent-startup-shell.test.ts +++ b/src/shared/tui-agent-startup-shell.test.ts @@ -5,6 +5,7 @@ import { commandSeparator, isPosixStartupShell, quoteStartupArg, + resolveLoginShellStartupDialect, tokenizeStartupCommand } from './tui-agent-startup-shell' import { buildAgentDraftLaunchPlan } from './tui-agent-startup' @@ -65,86 +66,49 @@ describe('tokenizeStartupCommand spans (windows shells)', () => { }) }) -describe('one Unix startup dialect', () => { - it('clears variables with a self-contained branch, not a per-shell builtin', () => { - // Why not `unset`/`set -e` alone, and why not a wrapper-defined helper: - // Orca only wraps zsh/bash/fish, so an `sh`/`dash`/`ksh` login shell — and - // any shell the user pastes copied text into — would not have the helper. - // startup-shell-portability.live-shell.test.ts proves this form works in - // real sh/bash/zsh/dash/ksh/fish. - expect(clearEnvCommand('CODEX_HOME', 'posix')).toBe( - `command test -n "$fish_pid" && set --erase -g CODEX_HOME; command test -z "$fish_pid" && unset CODEX_HOME; true` - ) - expect(clearEnvCommand(['A', 'B'], 'posix')).toBe( - `command test -n "$fish_pid" && set --erase -g A B; command test -z "$fish_pid" && unset A B; true` - ) +describe('fish startup shell dialect', () => { + it('clears variables with fish syntax instead of the sh `unset` fish rejects', () => { + expect(clearEnvCommand('CODEX_HOME', 'fish')).toBe('set -e CODEX_HOME') + expect(clearEnvCommand('CODEX_HOME', 'posix')).toBe('unset CODEX_HOME') expect(clearEnvCommand('CODEX_HOME', 'cmd')).toBe('set "CODEX_HOME="') expect(clearEnvCommand('CODEX_HOME', 'powershell')).toBe( 'Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue' ) }) - it.each([ - 'FOO; rm -rf /tmp/x', - 'FOO BAR', - '', - '1LEADING_DIGIT', - 'has-dash', - '$FOO', - 'PATH\nHOME' - ])('refuses %j as an environment variable name', (bad) => { - // Why: the name is interpolated straight into a shell line. Anything but an - // identifier is a command injection, and in fish `set --erase -g` would - // really delete whatever it names — PATH or HOME included. - expect(() => clearEnvCommand(bad, 'posix')).toThrow(/not an environment variable name/) - expect(() => clearEnvCommand(['OK', bad], 'posix')).toThrow(/not an environment variable name/) - }) - - it('quotes so fish reads back the same bytes sh does', () => { - // fish single quotes are NOT literal — `\\` and `\'` are escapes inside them — - // so the sh `'\''` idiom would halve these backslashes when fish read them, - // and a trailing backslash would be a hard syntax error. - expect(quoteStartupArg("it's", 'posix')).toBe(`'it'"'"'s'`) - expect(quoteStartupArg(String.raw`\\server\share`, 'posix')).toBe( - `"\\\\""\\\\"'server'"\\\\"'share'` + it('shares POSIX quoting, tokenizing and chaining', () => { + expect(quoteStartupArg("it's", 'fish')).toBe(quoteStartupArg("it's", 'posix')) + expect(buildShellCommandFromArgv(['codex', 'resume', 'a b'], 'fish')).toBe( + buildShellCommandFromArgv(['codex', 'resume', 'a b'], 'posix') ) - expect(quoteStartupArg('ends\\', 'posix')).toBe(`'ends'"\\\\"`) - expect(quoteStartupArg('', 'posix')).toBe(`''`) - }) - - it('keeps the sh-family grammar claims that do hold for fish', () => { - expect(commandSeparator('posix')).toBe('; ') - expect(isPosixStartupShell('posix')).toBe(true) - expect(isPosixStartupShell('powershell')).toBe(false) - expect(buildShellCommandFromArgv(['codex', 'resume', 'a b'], 'posix')).toBe( - `'codex' 'resume' 'a b'` + expect(tokenizeStartupCommand('codex --arg "a b"', 'fish')).toEqual( + tokenizeStartupCommand('codex --arg "a b"', 'posix') ) + expect(commandSeparator('fish')).toBe('; ') + expect(isPosixStartupShell('fish')).toBe(true) }) - it('round-trips an adversarial argument through quote then tokenize', () => { - for (const value of [ - String.raw`use \d+ and \\server\share`, - "it's mine", - 'ends\\', - 'a "b" c', - '$PATH *.ts' - ]) { - const tokenized = tokenizeStartupCommand(quoteStartupArg(value, 'posix'), 'posix') - expect(tokenized.ok && tokenized.tokens).toEqual([value]) - } + it('maps login shell paths to their dialect', () => { + expect(resolveLoginShellStartupDialect('/opt/homebrew/bin/fish')).toBe('fish') + expect(resolveLoginShellStartupDialect('/usr/local/bin/FISH')).toBe('fish') + expect(resolveLoginShellStartupDialect('/bin/zsh')).toBe('posix') + expect(resolveLoginShellStartupDialect('/bin/bash')).toBe('posix') + expect(resolveLoginShellStartupDialect('')).toBe('posix') + expect(resolveLoginShellStartupDialect(undefined)).toBe('posix') }) - it('clears an agent draft prefill variable with the portable teardown', () => { + // Contract-only: no POSIX caller threads a shell into buildAgentDraftLaunchPlan yet + // (`selectedRepoStartupShell` is Windows-only), so fish users still get `unset` here. + it('clears an agent draft prefill variable with fish syntax', () => { const plan = buildAgentDraftLaunchPlan({ agent: 'pi', draft: 'hello', cmdOverrides: {}, - platform: 'darwin' + platform: 'darwin', + shell: 'fish' }) - expect(plan?.launchCommand).toBe( - `pi; command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true` - ) + expect(plan?.launchCommand).toBe('pi; set -e ORCA_PI_PREFILL') expect(plan?.env?.ORCA_PI_PREFILL).toBe('hello') }) }) diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts index da168b5bdff..ef23b837e0b 100644 --- a/src/shared/tui-agent-startup-shell.ts +++ b/src/shared/tui-agent-startup-shell.ts @@ -1,26 +1,28 @@ import { tokenizeCustomCommandTemplate, type CommandTokenSpan } from './commit-message-prompt' -/** - * `'posix'` covers every Unix shell Orca can type into, fish included — not - * because they agree, but because everything this module emits for it is built - * to be correct in all of them (see quoteStartupArg and clearEnvCommand). There - * is deliberately no fish member: a dialect Orca has to detect is a dialect it - * can get wrong, and it cannot detect one reliably for a remote or WSL host. - */ -export type AgentStartupShell = 'posix' | 'powershell' | 'cmd' +// Why: fish shares POSIX word splitting, quoting and `;` chaining, so it is a +// separate dialect only where its grammar actually diverges (env clearing). +export type AgentStartupShell = 'posix' | 'fish' | 'powershell' | 'cmd' type WindowsStartupShell = Extract -/** True for the sh-family grammar: `NAME=value cmd` prefixes, `sh -c` wrapping - * and `;` chaining. fish parses all three, so this holds for fish too. */ +/** True for shells parsed with POSIX quoting/word rules (sh family + fish). */ export function isPosixStartupShell(shell: AgentStartupShell): boolean { - return shell === 'posix' + return shell === 'posix' || shell === 'fish' } function isWindowsStartupShell(shell: AgentStartupShell): shell is WindowsStartupShell { return shell === 'powershell' || shell === 'cmd' } +/** Maps a POSIX login-shell path (`$SHELL`) to the dialect that parses queued commands. */ +export function resolveLoginShellStartupDialect( + loginShell: string | null | undefined +): AgentStartupShell { + const basename = loginShell?.trim().replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' + return basename === 'fish' ? 'fish' : 'posix' +} + export type StartupCommandTokens = | { ok: true; tokens: string[]; spans: CommandTokenSpan[] } | { ok: false; error: string } @@ -152,11 +154,6 @@ export function tokenizeStartupCommand( value: string, shell: AgentStartupShell ): StartupCommandTokens { - // Why one Unix parse: the input is a string the user typed into an Orca - // settings field, and the shell never parses it — every token is re-quoted by - // quoteStartupArg before the line is built. Parsing it differently per shell - // would make the same setting mean different things in different workspaces. - // (Windows is genuinely different: cmd/PowerShell re-parse the built line.) return isWindowsStartupShell(shell) ? tokenizeWindowsStartupCommand(value, shell) : tokenizeCustomCommandTemplate(value) @@ -169,52 +166,6 @@ export function resolveStartupShell( return shell ?? (platform === 'win32' ? 'powershell' : 'posix') } -/** - * Quotes one argument so the SAME text is literal in every Unix shell Orca can - * be typing into — sh, bash, zsh, dash and fish. - * - * Why not plain sh quoting: fish's single quotes are not literal. Inside `'…'` - * it treats `\\` and `\'` as escapes, so the sh `'\''` idiom collapses every - * backslash (a `\d+` regex, a `\\server\share` UNC path) and a trailing - * backslash is a hard syntax error that kills the launch outright. - * - * Both shell families agree on two things: a single-quoted run is literal apart - * from those escapes, and `"\\"` is one backslash. So emitting backslashes as - * `"\\"` and apostrophes as `"'"` between single-quoted runs round-trips in all - * of them, and Orca never has to know which shell will read the line. - * - * Verified against sh, bash, zsh, dash and fish 4.7.1 over regex escapes, UNC - * and drive paths, trailing/lone backslashes, mixed quotes, `$`/backtick/`$()` - * expansions, globs, braces, operators, comments, newlines, tabs and non-BMP - * unicode — see fish-startup-arg-quoting.live-fish.test.ts. - */ -function quotePortableUnixArg(value: string): string { - if (!value) { - return "''" - } - const parts: string[] = [] - let literal = '' - const flushLiteral = (): void => { - if (literal) { - parts.push(`'${literal}'`) - literal = '' - } - } - for (const char of value) { - if (char === "'") { - flushLiteral() - parts.push(`"'"`) - } else if (char === '\\') { - flushLiteral() - parts.push(`"\\\\"`) - } else { - literal += char - } - } - flushLiteral() - return parts.join('') -} - export function quoteStartupArg(value: string, shell: AgentStartupShell): string { if (shell === 'powershell') { return `'${value.replace(/'/g, "''")}'` @@ -222,7 +173,7 @@ export function quoteStartupArg(value: string, shell: AgentStartupShell): string if (shell === 'cmd') { return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"` } - return quotePortableUnixArg(value) + return `'${value.replace(/'/g, `'\\''`)}'` } export function buildShellCommandFromArgv( @@ -236,94 +187,19 @@ export function buildShellCommandFromArgv( return command } -/** - * Clears one or more environment variables, in a form correct in every shell a - * queued command line can land in. - * - * Why it carries its own fish/sh branch rather than a single builtin: `unset` - * does not exist in fish, and `set -e` in bash enables errexit rather than - * clearing anything, so neither spelling is safe alone. - * - * Why not a helper function defined by Orca's shell wrappers: Orca only wraps - * zsh, bash and fish. A login shell of `sh`, `dash` or `ksh` launches - * UNWRAPPED, and this text is also copied to the clipboard and pasted into - * shells Orca never spawned — in all of those a helper would be `command not - * found`, which is the exact failure this exists to avoid. - * - * Why two statements rather than `A && B || C`: in fish, `set -e` on a variable - * that is already unset returns non-zero, so an `||` fallback would run the sh - * branch too and print `Unknown command: unset`. Each branch is guarded by its - * own test, so exactly one runs and the other is only parsed. - * - * The trailing `true` pins the exit status at 0: the guard that does NOT fire - * leaves a non-zero status behind, and this is the last statement of a launch - * line, so that status is what the user's prompt would render. - * - * `command test` rather than `test`, because an interactive shell expands - * aliases and `alias test=...` would otherwise skip both branches silently. - * - * `set --erase` rather than `set -e`, because `$fish_pid` is a heuristic: any - * non-empty value takes the fish branch. `set -e NAME` in the sh family enables - * errexit and replaces the positional parameters, silently changing the - * semantics of everything after it. `set --erase NAME` is the same erase in - * fish, but in sh `--` ends option parsing, so a misfire is a usage message on - * stderr and nothing else. - * - * `$fish_pid` is set by fish 3.0+ and by nothing in the sh family, and fish - * does not export it, so a fish parent cannot make a bash child misread itself. - * - * `-g` is not optional: it scopes the erase to fish's GLOBAL scope, which is - * where an inherited environment variable lands. Without it, a name that exists - * only as a UNIVERSAL — `set -Ux CODEX_HOME …`, a perfectly normal thing for a - * fish user to have — is permanently deleted from every future session. That is - * real data loss to undo one launch's injection, and it is reachable from the - * clipboard command, which a user may run with no Orca-injected value at all. - * With `-g`, a universal shadowed by an injected global is revealed again - * instead, which is the wanted outcome. - * - * KNOWN LIMIT: under `set -u` the sh side aborts on the unset `$fish_pid` and - * the variable survives. `${fish_pid-}` would be nounset-safe but is a fish - * parse error, and so is `set +u`. - * - * The one spelling that does satisfy both is a fish-builtin probe — e.g. - * `math 1 >/dev/null 2>&1 && … || …` — because it references no variable at - * all. Rejected deliberately: it EXECUTES whatever that name resolves to on - * the user's PATH, twice. `math` is a real binary (Wolfram Mathematica ships - * `/usr/local/bin/math`); measured with one on PATH, clearing a single variable - * took 10.4s and then did not clear it, because a probe that exits 0 is an - * unconditional false positive. Trading a rare uncleared prefill for an - * arbitrary program execution on the launch path is the wrong direction. - * - * Verified to clear the variables, write nothing to stderr and exit 0 — whether - * they were set or already unset — in sh, bash, zsh, dash, ksh and fish. - */ -const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ - -export function clearEnvCommand(name: string | readonly string[], shell: AgentStartupShell): string { - const names = typeof name === 'string' ? [name] : [...name] - // Why assert rather than escape: these names are interpolated straight into a - // shell line, so anything but an identifier is both a command injection and — - // in fish, where `-g` erases for real — a way to delete `PATH`/`HOME` out of - // the user's session. Every caller passes a literal from a fixed table, so - // this cannot fire today; it exists so it stays that way. - for (const each of names) { - if (!ENV_VAR_NAME.test(each)) { - throw new Error(`clearEnvCommand: ${JSON.stringify(each)} is not an environment variable name`) - } - } +export function clearEnvCommand(name: string, shell: AgentStartupShell): string { if (shell === 'powershell') { - return names - .map((each) => `Remove-Item Env:${each} -ErrorAction SilentlyContinue`) - .join('; ') + return `Remove-Item Env:${name} -ErrorAction SilentlyContinue` } if (shell === 'cmd') { - return names.map((each) => `set "${each}="`).join(' & ') + return `set "${name}="` } - const joined = names.join(' ') - return ( - `command test -n "$fish_pid" && set --erase -g ${joined}; ` + - `command test -z "$fish_pid" && unset ${joined}; true` - ) + // Why: fish has no `unset`; the sh spelling errors out and silently leaves + // the variable exported (e.g. an account-routed CODEX_HOME survives). + if (shell === 'fish') { + return `set -e ${name}` + } + return `unset ${name}` } export function commandSeparator(shell: AgentStartupShell): string { diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index cf917db4432..b3220956677 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -47,7 +47,7 @@ describe('tui agent startup plans', () => { platform: 'linux' }) - expect(plan?.launchCommand).toBe("claude 'fix Bob'\"'\"'s branch'") + expect(plan?.launchCommand).toBe("claude 'fix Bob'\\''s branch'") }) it('uses PowerShell quoting by default when the target shell is Windows', () => { @@ -184,12 +184,9 @@ describe('tui agent startup plans', () => { }) expect(plan?.launchCommand).toMatch(/^sh -c /) + expect(plan?.launchCommand).toMatch(/\\0[0-7]{3}/) expect(plan?.launchCommand).not.toContain("'sh' '-c'") - // Why parse rather than string-match: the octal escapes must survive the - // OUTER quoting to reach the inner sh, and portable quoting emits a - // backslash as `"\\"` rather than leaving it inside a single-quoted run. - const tokens = tokenizeStartupCommand(plan?.launchCommand ?? '', 'posix') - expect(tokens.ok && tokens.tokens.at(-1)).toMatch(/\\0[0-7]{3}/) + expect(plan?.launchCommand).not.toContain("'\\''") }) it('moves Hermes command override flags after the chat subcommand', () => { @@ -859,7 +856,7 @@ describe('tui agent startup plans', () => { expect(plan).not.toBeNull() expect(plan?.env).toEqual({ ORCA_OMP_PREFILL: 'fix the omp regression' }) expect(plan?.expectedProcess).toBe('omp') - expect(plan?.launchCommand).toBe(`omp; command test -n "$fish_pid" && set --erase -g ORCA_OMP_PREFILL; command test -z "$fish_pid" && unset ORCA_OMP_PREFILL; true`) + expect(plan?.launchCommand).toBe('omp; unset ORCA_OMP_PREFILL') }) it('returns null for oversized Windows flag drafts so callers paste after ready', () => { From 3f58d5cf9aec1ad1cdae86914c051c0aadf00f42 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:50:26 -0700 Subject: [PATCH 04/28] fix(daemon): bound cwd validation per UNC route (#14967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async cwd validation dedupes by exact path but had no concurrency bound, and a dead UNC share answers `stat` in ~21s while holding one of libuv's 4 default fs threads. Four distinct paths on one unreachable server therefore starved every other async fs read in the daemon — including the cold-restore history replay running alongside them — which moves the head-of-line stall #14848 removed from the event loop into the thread pool. Adds a per-route lane of 2, reusing PrioritySemaphore and matching the per-distro lane in rate-limits/auth-filesystem-operation.ts. Keyed by the host that has to answer (WSL distro, or the `\\server` prefix) so many dead subdirectories of one share fold into one lane. Local-disk paths bypass the lane entirely: a global cap would queue a healthy local spawn behind a dead share. Moves PrioritySemaphore to src/shared. It has no imports, and reaching into src/main/daemon from src/main/providers inverted the dependency direction that already runs daemon -> providers. Note this bounds pool occupancy, which cancellation cannot: an aborted `stat` still holds its libuv thread until the OS returns. STA-4543 --- src/main/daemon/history-reader.ts | 2 +- .../working-directory-validation.test.ts | 74 ++++++++++++++++++ .../providers/working-directory-validation.ts | 76 +++++++++++++++++-- .../priority-semaphore.test.ts | 0 .../daemon => shared}/priority-semaphore.ts | 0 5 files changed, 145 insertions(+), 7 deletions(-) rename src/{main/daemon => shared}/priority-semaphore.test.ts (100%) rename src/{main/daemon => shared}/priority-semaphore.ts (100%) diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index de0a98e3071..bcd19fbca78 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -6,7 +6,7 @@ import { getHistorySessionDirName } from './history-paths' import { decodeTerminalHistoryLog, LOG_HEADER_BYTES } from './terminal-history-log' import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' import { HeadlessEmulator } from './headless-emulator' -import { PrioritySemaphore } from './priority-semaphore' +import { PrioritySemaphore } from '../../shared/priority-semaphore' import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { readTerminalHistoryBufferAsync } from './terminal-history-file-reader' import { detectColdRestoreFromLegacyScrollback } from './terminal-history-legacy-scrollback-restore' diff --git a/src/main/providers/working-directory-validation.test.ts b/src/main/providers/working-directory-validation.test.ts index 6531176a3de..8c7d3867b11 100644 --- a/src/main/providers/working-directory-validation.test.ts +++ b/src/main/providers/working-directory-validation.test.ts @@ -11,12 +11,20 @@ vi.mock('../wsl', async (importOriginal) => { }) import { validateWorkingDirectoryAsync } from './local-pty-utils' +import { _resetWorkingDirectoryValidationStateForTest } from './working-directory-validation' let tempDir: string +/** UNC probes now start after a lane acquire, so let that microtask land. */ +const flushLaneAcquire = async (): Promise => { + await Promise.resolve() + await Promise.resolve() +} + beforeEach(async () => { wslUncDirectoryExistsAsyncMock.mockReset() wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) + _resetWorkingDirectoryValidationStateForTest() tempDir = await mkdtemp(path.join(os.tmpdir(), 'orca-cwd-validate-')) }) @@ -137,6 +145,71 @@ describe('validateWorkingDirectoryAsync', () => { }) }) + describe('UNC route concurrency', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('caps concurrent probes against one unreachable distro', async () => { + // Four dead paths would otherwise hold all four libuv fs threads. + let inFlight = 0 + let peak = 0 + wslUncDirectoryExistsAsyncMock.mockImplementation( + () => + new Promise(() => { + inFlight += 1 + peak = Math.max(peak, inFlight) + }) + ) + + for (let index = 0; index < 4; index += 1) { + void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\capped-${index}`).catch( + () => {} + ) + } + await flushLaneAcquire() + + expect(peak).toBe(2) + }) + + it('keeps a healthy local path out of a stalled share queue', async () => { + wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise(() => {})) + for (let index = 0; index < 4; index += 1) { + void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\blocking-${index}`).catch( + () => {} + ) + } + + // A local-disk path must not queue behind a dead server. + await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined() + }) + + it('gives separate servers separate lanes', async () => { + let inFlight = 0 + wslUncDirectoryExistsAsyncMock.mockImplementation( + () => + new Promise(() => { + inFlight += 1 + }) + ) + + for (const distro of ['AlphaDistro', 'BetaDistro']) { + for (let index = 0; index < 2; index += 1) { + void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\${distro}\\lane-${index}`).catch( + () => {} + ) + } + } + await flushLaneAcquire() + + expect(inFlight).toBe(4) + }) + }) + describe('WSL UNC paths', () => { const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' @@ -165,6 +238,7 @@ describe('validateWorkingDirectoryAsync', () => { const first = validateWorkingDirectoryAsync(wslPath) const second = validateWorkingDirectoryAsync(wslPath) + await flushLaneAcquire() expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce() releaseProbe() diff --git a/src/main/providers/working-directory-validation.ts b/src/main/providers/working-directory-validation.ts index 21775dc913a..e43a488443c 100644 --- a/src/main/providers/working-directory-validation.ts +++ b/src/main/providers/working-directory-validation.ts @@ -5,10 +5,65 @@ import { existsSync, statSync } from 'node:fs' import { stat } from 'node:fs/promises' import { release } from 'node:os' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { wslUncDirectoryExists, wslUncDirectoryExistsAsync } from '../wsl' +import { PrioritySemaphore } from '../../shared/priority-semaphore' const pendingWorkingDirectoryValidations = new Map>() +// Why: a dead UNC share answers `stat` in ~21s on Windows and holds one of +// libuv's 4 default fs threads the whole time, so a handful of distinct paths on +// one unreachable server would starve every other async fs read in the daemon — +// moving the head-of-line stall off the event loop and into the thread pool. +// Matches the per-distro lane in rate-limits/auth-filesystem-operation.ts. +const MAX_CONCURRENT_UNC_VALIDATIONS = 2 + +type UncRouteLane = { + readonly semaphore: PrioritySemaphore + waiters: number +} + +const uncRouteLanes = new Map() + +/** + * Groups paths by the host that must answer for them, so many dead + * subdirectories of one share share a lane. Returns null for local-disk paths, + * which never block long enough to be worth queueing. + */ +function uncRouteKey(cwd: string): string | null { + if (!cwd.startsWith('\\\\')) { + return null + } + const wslInfo = parseWslUncPath(cwd) + if (wslInfo) { + return `wsl:${wslInfo.distro.trim().toLowerCase()}` + } + const server = cwd.slice(2).split(/[\\/]/, 1)[0] + return `unc:${server.toLowerCase()}` +} + +async function withUncRouteLane(cwd: string, run: () => Promise): Promise { + const key = uncRouteKey(cwd) + if (key === null) { + return run() + } + let lane = uncRouteLanes.get(key) + if (!lane) { + lane = { semaphore: new PrioritySemaphore(MAX_CONCURRENT_UNC_VALIDATIONS), waiters: 0 } + uncRouteLanes.set(key, lane) + } + // Counted before acquiring so a queued caller keeps the lane alive. + lane.waiters += 1 + const release = await lane.semaphore.acquire(0) + try { + return await run() + } finally { + release() + lane.waiters -= 1 + if (lane.waiters === 0) { + uncRouteLanes.delete(key) + } + } +} /** Thrown when the caller gave up on a probe that is still running. */ export class WorkingDirectoryValidationAbortedError extends Error { @@ -18,6 +73,12 @@ export class WorkingDirectoryValidationAbortedError extends Error { } } +/** Test seam: both maps are module-level, so an unsettled probe would leak across tests. */ +export function _resetWorkingDirectoryValidationStateForTest(): void { + pendingWorkingDirectoryValidations.clear() + uncRouteLanes.clear() +} + export function formatLocalPtyEnvironmentDiag(extra: Record = {}): string { const systemVersion = (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || @@ -87,10 +148,9 @@ export function validateWorkingDirectoryAsync( const started = validation // Why: dropped only on settle. `fs.stat` is uninterruptible, so retiring a // still-running probe on a timer frees no libuv thread — it only lets the - // next caller pin a second one, and a few retries against one dead mount - // exhaust the default pool of 4 and stall every other async fs read in the - // daemon. Callers escape through `signal` instead, so sharing one hung probe - // no longer strands them. + // next caller pin a second one. Callers escape through `signal` instead, so + // sharing one hung probe no longer strands them, and the route lane below + // caps how many distinct paths on one dead host can block at once. const forget = (): void => { if (pendingWorkingDirectoryValidations.get(key) === started) { pendingWorkingDirectoryValidations.delete(key) @@ -116,7 +176,11 @@ export function validateWorkingDirectoryAsync( }) } -async function validateWorkingDirectoryUncached(cwd: string): Promise { +function validateWorkingDirectoryUncached(cwd: string): Promise { + return withUncRouteLane(cwd, () => probeWorkingDirectory(cwd)) +} + +async function probeWorkingDirectory(cwd: string): Promise { if (isWslUncPath(cwd)) { const existsInDistro = await wslUncDirectoryExistsAsync(cwd) if (existsInDistro === false) { diff --git a/src/main/daemon/priority-semaphore.test.ts b/src/shared/priority-semaphore.test.ts similarity index 100% rename from src/main/daemon/priority-semaphore.test.ts rename to src/shared/priority-semaphore.test.ts diff --git a/src/main/daemon/priority-semaphore.ts b/src/shared/priority-semaphore.ts similarity index 100% rename from src/main/daemon/priority-semaphore.ts rename to src/shared/priority-semaphore.ts From 85565a9302baa89c6d21bd51349d5e29f91a00a3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:50:58 -0700 Subject: [PATCH 05/28] reland(workspace): set project location from the create-worktree host picker (#14965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workspace): reland set project location from the create-worktree host picker Relands #14868 (reverted by #14912) with a fix for the regression that caused the revert: setting a project location could change the path before Orca used it. The retarget-after-setup path read the raw store record to find a just-created setup, because the memoized picker options had not refreshed yet: useAppStore.getState().projectHostSetups.find( (candidate) => candidate.id === setupId && candidate.setupState === 'ready' ) That hand-rolls a second selection path that skips every rule the option builder applies — repo eligibility, ephemeral-VM and runtime-owned SSH host exclusion, and the one-setup-per-host dedupe whose own comment notes that resolveWorkspaceCreationTarget takes the first project+host match and ignores the rest. So the composer could be retargeted at a setup other than the canonical one for that host, pointing creation at a different location than the one chosen. Resolves through buildProjectHostSetupOptions against fresh store state instead, so the fallback and the steady-state picker agree by construction. STA-4547 * fix(workspace): sanitize the clone prefill and drop an abandoned set-location Review follow-ups on this PR. The "Clone from URL" prefill seeded the field with the verbatim `git remote` URL, which can embed a PAT (`https://x-access-token:ghp_...@github.com/...`). The clone then runs on the *target* host, writing that token into its .git/config — a credential the user never typed into this flow, now readable by anyone on a shared host. Strip it with the same sanitizer `getProvisionedRootRecipeRepoUrl` already applies to the ephemeral-VM recipe URL. Extracted to resolveProjectCloneUrlPrefill so the rule is directly testable. The dialog also stays dismissable while a submit is in flight, and an SSH clone is unbounded. A clone the user backed out of minutes earlier still called onReady, silently moving the run target and resetting start-from under a form they had since pointed at another host. Drop the result if the dialog went away. * fix(workspace): re-arm the abandoned guard on mount StrictMode runs mount/cleanup/mount, so latching `abandoned` on the first cleanup left it true for the rest of the session and permanently suppressed onReady — the app wraps its root in StrictMode. Reset it on mount. --- .../project-host-compatibility.test.ts | 68 ++++ .../project-host-compatibility.ts | 9 + ...orkspaceComposerCard.set-location.test.tsx | 238 ++++++++++++++ .../NewWorkspaceComposerCard.test.tsx | 16 +- .../components/NewWorkspaceComposerCard.tsx | 42 ++- .../components/NewWorkspaceComposerModal.tsx | 6 +- .../new-workspace/RunTargetCombobox.tsx | 42 ++- .../new-workspace/RunTargetComboboxRow.tsx | 50 +++ .../SetProjectLocationDialog.test.tsx | 244 +++++++++++++++ .../SetProjectLocationDialog.tsx | 291 ++++++++++++++++++ .../new-workspace/SetProjectLocationForms.tsx | 224 ++++++++++++++ .../pick-local-project-folder.ts | 14 + .../useComposerState-host-retarget.test.ts | 17 +- src/renderer/src/hooks/useComposerState.ts | 28 +- src/renderer/src/i18n/locales/en.json | 12 + .../src/lib/project-clone-url-prefill.test.ts | 65 ++++ .../src/lib/project-clone-url-prefill.ts | 29 ++ .../lib/project-host-setup-options.test.ts | 36 ++- .../src/lib/project-host-setup-options.ts | 22 ++ src/shared/project-host-setup-projection.ts | 13 +- 20 files changed, 1438 insertions(+), 28 deletions(-) create mode 100644 src/main/persistence/tracking-repos/project-host-compatibility.test.ts create mode 100644 src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx create mode 100644 src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx create mode 100644 src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx create mode 100644 src/renderer/src/components/new-workspace/SetProjectLocationForms.tsx create mode 100644 src/renderer/src/components/new-workspace/pick-local-project-folder.ts create mode 100644 src/renderer/src/lib/project-clone-url-prefill.test.ts create mode 100644 src/renderer/src/lib/project-clone-url-prefill.ts diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.test.ts b/src/main/persistence/tracking-repos/project-host-compatibility.test.ts new file mode 100644 index 00000000000..540dc6955f7 --- /dev/null +++ b/src/main/persistence/tracking-repos/project-host-compatibility.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetup } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { mergeProjectHostSetupCompatibilityState } from './project-host-compatibility' + +const PROJECT_ID = 'github:acme/orca' + +function repo(overrides: Partial & Pick): Repo { + return { + path: `/src/${overrides.id}`, + displayName: 'orca', + addedAt: 1, + upstream: { owner: 'acme', repo: 'orca' }, + ...overrides + } as Repo +} + +function pendingSetup(overrides: Partial = {}): ProjectHostSetup { + return { + id: `${PROJECT_ID}::ssh:devbox`, + projectId: PROJECT_ID, + hostId: 'ssh:devbox', + repoId: '', + path: '', + displayName: 'orca', + setupState: 'not-set-up', + setupMethod: 'pending', + createdAt: 1, + updatedAt: 1, + ...overrides + } as ProjectHostSetup +} + +describe('mergeProjectHostSetupCompatibilityState', () => { + it('keeps a placeholder for a host with no repo yet', () => { + const merged = mergeProjectHostSetupCompatibilityState( + { projects: [], projectHostSetups: [pendingSetup()] }, + [repo({ id: 'local-repo' })] + ) + + expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual(['local', 'ssh:devbox']) + }) + + // Setting a location on a host that already had a placeholder projects a ready setup for + // the same project+host; the placeholder must not survive to shadow it. + it('drops a placeholder once a repo covers the same project and host', () => { + const merged = mergeProjectHostSetupCompatibilityState( + { projects: [], projectHostSetups: [pendingSetup()] }, + [repo({ id: 'local-repo' }), repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })] + ) + + expect(merged.projectHostSetups).toHaveLength(2) + expect(merged.projectHostSetups.every((setup) => setup.setupState === 'ready')).toBe(true) + expect(merged.projectHostSetups.map((setup) => setup.id)).toEqual(['local-repo', 'devbox-repo']) + }) + + it('keeps the placeholder when the repo covers a different host', () => { + const merged = mergeProjectHostSetupCompatibilityState( + { projects: [], projectHostSetups: [pendingSetup({ hostId: 'ssh:other' })] }, + [repo({ id: 'devbox-repo', executionHostId: 'ssh:devbox' })] + ) + + expect(merged.projectHostSetups.map((setup) => setup.hostId)).toEqual([ + 'ssh:devbox', + 'ssh:other' + ]) + }) +}) diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.ts b/src/main/persistence/tracking-repos/project-host-compatibility.ts index 88e80ecdb93..0b51c758652 100644 --- a/src/main/persistence/tracking-repos/project-host-compatibility.ts +++ b/src/main/persistence/tracking-repos/project-host-compatibility.ts @@ -23,6 +23,10 @@ export function isRepoBackedProjectHostSetup( return repoId.length > 0 && (currentRepoIds.has(repoId) || setup.id === repoId) } +function projectHostKey(setup: Pick): string { + return `${setup.projectId}\u0000${setup.hostId}` +} + export function mergeProjectHostSetupCompatibilityState( state: Pick, repos: readonly Repo[] @@ -35,6 +39,7 @@ export function mergeProjectHostSetupCompatibilityState( const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + const projectedHosts = new Set(projection.setups.map(projectHostKey)) // Why: legacy/repo-backed setup rows reuse the repo id; keep only independent rows so repo deletion leaves no ghosts. const independentSetups = (state.projectHostSetups ?? []) .filter((setup) => { @@ -48,6 +53,10 @@ export function mergeProjectHostSetupCompatibilityState( const remappedProjectId = succession.remappedProjectIds.get(setup.projectId) return remappedProjectId ? { ...setup, projectId: remappedProjectId } : setup }) + // Why: a project resolves to one setup per host. Once a repo projection covers that + // pair, a leftover placeholder is a ghost that shadows the ready row — it sorts first + // and reads back as "not set up". Runs after the remap so renamed rows are caught too. + .filter((setup) => !projectedHosts.has(projectHostKey(setup))) const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) const independentProjects = (state.projects ?? []) .filter( diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx new file mode 100644 index 00000000000..92c7e889075 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' + +const storeMocks = vi.hoisted(() => ({ + closeModal: vi.fn(), + openModal: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ + closeModal: storeMocks.closeModal, + openModal: storeMocks.openModal, + openSettingsPage: storeMocks.openSettingsPage, + openSettingsTarget: storeMocks.openSettingsTarget, + setRuntimeEnvironmentStatus: vi.fn(), + setupProjectExistingFolder: vi.fn(), + setupProjectClone: vi.fn(), + activeModal: 'new-workspace-composer', + settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, + updateSettings: vi.fn(), + projects: [], + repos: [] + }), + { getState: () => ({}) } + ) +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/agent/AgentCombobox', () => ({ + default: () => +})) + +vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ + default: () => null +})) + +vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ + default: () => +})) + +vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ + default: () =>
+})) + +vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ + SetProjectLocationDialog: ({ + option, + projectName + }: { + option: { label: string } | null + projectName: string + }) => + option ? ( +
+ ) : null +})) + +const projectOptions: NewWorkspaceProjectOption[] = [ + { + kind: 'project-group', + id: 'project-group:platform', + projectGroupId: 'platform', + displayName: 'Platform', + badgeColor: 'var(--muted-foreground)', + detail: '/workspace/platform', + parentPath: '/workspace/platform', + connectionId: null + } +] + +const hostOptions: ProjectHostSetupOption[] = [ + { + kind: 'ready', + id: 'setup-local', + projectId: 'project-group:platform', + hostId: 'local', + repoId: 'repo-a', + label: 'Local Mac', + detail: 'Orca', + path: '/Users/alice/orca' + }, + { + kind: 'needs-setup', + id: 'needs-setup:ssh:devbox', + projectId: 'project-group:platform', + hostId: 'ssh:devbox', + label: 'Devbox', + detail: 'Project location not set', + isAvailable: true, + attention: false, + canSetLocation: true + } +] + +function renderCard( + overrides: Partial> = {} +): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + {}} + eligibleRepos={[]} + repoId="repo-a" + projectOptions={projectOptions} + selectedProjectId="project-group:platform" + selectedRepoIsGit + onRepoChange={() => {}} + onProjectChange={() => {}} + primaryActionLabel="Create workspace" + name="" + onNameValueChange={() => {}} + onSmartGitHubItemSelect={() => {}} + onSmartGitLabItemSelect={() => {}} + onSmartBranchSelect={() => {}} + onSmartLinearIssueSelect={() => {}} + smartNameSelection={null} + onClearSmartNameSelection={() => {}} + canReuseSelectedBranch={false} + reuseSelectedBranch={false} + onReuseSelectedBranchChange={() => {}} + forkPushWarning={null} + detectedAgentIds={null} + onOpenAgentSettings={() => {}} + advancedOpen={false} + onToggleAdvanced={() => {}} + createDisabled={false} + projectError={null} + creating={false} + onCreate={() => {}} + note="" + onNoteChange={() => {}} + setupConfig={null} + requiresExplicitSetupChoice={false} + setupDecision={null} + onSetupDecisionChange={() => {}} + setupAgentStartupPolicy="start-immediately" + onSetupAgentStartupPolicyChange={() => {}} + shouldWaitForSetupCheck={false} + resolvedSetupDecision={null} + createError={null} + selectedRepoConnectionId={null} + selectedRepoSshStatus={null} + selectedRepoRequiresConnection={false} + selectedRepoConnectInProgress={false} + onConnectSelectedRepo={async () => {}} + canUseSparseCheckout={false} + sparsePresets={[]} + sparseSelectedPresetId={null} + onSparseSelectPreset={() => {}} + branchNameOverride={undefined} + onBranchNameOverrideChange={() => {}} + branchesEnabled={false} + setupControlsEnabled={false} + sparseControlsEnabled={false} + projectHostSetupOptions={hostOptions} + selectedProjectHostSetupId="setup-local" + {...overrides} + /> + ) + }) + return container +} + +describe('NewWorkspaceComposerCard set location', () => { + let container: HTMLDivElement | null = null + + beforeEach(() => { + storeMocks.closeModal.mockReset() + storeMocks.openModal.mockReset() + storeMocks.openSettingsPage.mockReset() + }) + + afterEach(() => { + container?.remove() + container = null + }) + + it('opens set-location over the composer without leaving the create dialog', () => { + const nestedOpenChanges: boolean[] = [] + container = renderCard({ + onNestedDialogOpenChange: (open) => nestedOpenChanges.push(open) + }) + + act(() => { + container?.querySelector('div[data-run-target-combobox-root="true"]')?.click() + }) + const setLocation = [...document.body.querySelectorAll('button')].find( + (button) => button.textContent?.includes('Set project location') + ) + expect(setLocation).toBeTruthy() + act(() => setLocation?.click()) + + const dialog = document.body.querySelector('[data-testid="set-project-location-dialog"]') + expect(dialog?.getAttribute('data-host')).toBe('Devbox') + expect(dialog?.getAttribute('data-project')).toBe('Platform') + expect(nestedOpenChanges).toEqual([true]) + expect(storeMocks.closeModal).not.toHaveBeenCalled() + expect(storeMocks.openModal).not.toHaveBeenCalled() + expect(storeMocks.openSettingsPage).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 33fc740980b..9775cd06dae 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -31,7 +31,9 @@ vi.mock('@/store', () => ({ setRuntimeEnvironmentStatus: storeMocks.setRuntimeEnvironmentStatus, activeModal: 'none', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, - updateSettings: vi.fn() + updateSettings: vi.fn(), + projects: [], + repos: [] }), { getState: () => ({ @@ -62,6 +64,10 @@ vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ mode ?
: null })) +vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ + SetProjectLocationDialog: () => null +})) + vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ default: () =>
})) @@ -155,7 +161,8 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = { label: 'Devbox', detail: 'Project location not set', isAvailable: true, - attention: false + attention: false, + canSetLocation: true } const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = { @@ -167,6 +174,7 @@ const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = { detail: 'Connect this host to set up projects', isAvailable: false, attention: false, + canSetLocation: false, connectAction: { kind: 'ssh', targetId: 'devbox' } } @@ -179,6 +187,7 @@ const disconnectedBastionNeedsSetupHostOption: ProjectHostSetupOption = { detail: 'Connect this host to set up projects', isAvailable: false, attention: false, + canSetLocation: false, connectAction: { kind: 'ssh', targetId: 'bastion' } } @@ -601,7 +610,8 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const devboxItem = findRunTargetItem('Devbox') - expect(devboxItem?.textContent).toContain('Project location not set') + expect(devboxItem?.textContent).not.toContain('Project location not set') + expect(devboxItem?.textContent).toContain('Set project location') // Not-connected rows stay highlightable (never `disabled`) so they hover like // the other rows; they're quieted visually instead. expect(devboxItem?.hasAttribute('data-disabled')).toBe(false) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 4606a625c07..2de214624e1 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -34,6 +34,7 @@ import { } from '@/lib/text-control-paste' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' +import { resolveProjectCloneUrlPrefill } from '@/lib/project-clone-url-prefill' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { GitLabWorkItem } from '../../../shared/gitlab-types' import type { JiraIssue } from '../../../shared/jira-types' @@ -48,6 +49,7 @@ import SmartWorkspaceNameField, { import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox' +import { SetProjectLocationDialog } from '@/components/new-workspace/SetProjectLocationDialog' import { AddRemoteHostDialog, type AddRemoteHostMode @@ -162,6 +164,8 @@ type NewWorkspaceComposerCardProps = { sparseControlsEnabled?: boolean /** When set, "Add project" opens a host-provided flow instead of swapping the store's active modal. */ onAddProjectOverride?: () => void + /** Fires as the nested Set-project-location dialog opens and closes, so the host can stand down its Escape/submit handling. */ + onNestedDialogOpenChange?: (open: boolean) => void } const SSH_STATUS_LABELS: Partial> = { @@ -371,7 +375,8 @@ export default function NewWorkspaceComposerCard({ sparseSelectedPresetId, onSparseSelectPreset, sparseControlsEnabled = true, - onAddProjectOverride + onAddProjectOverride, + onNestedDialogOpenChange }: NewWorkspaceComposerCardProps): React.JSX.Element { // Why: subscribe (form uses translate() directly) so an open create dialog repaints when the UI language changes. useTranslation() @@ -499,6 +504,32 @@ export default function NewWorkspaceComposerCard({ const handleAddRemoteServer = React.useCallback((): void => { setAddRemoteHostMode('server') }, []) + const [setLocationOption, setSetLocationOption] = + React.useState(null) + const handleSetLocation = React.useCallback( + (option: NeedsSetupProjectHostOption): void => { + setSetLocationOption(option) + onNestedDialogOpenChange?.(true) + }, + [onNestedDialogOpenChange] + ) + const handleSetLocationClose = React.useCallback((): void => { + setSetLocationOption(null) + onNestedDialogOpenChange?.(false) + }, [onNestedDialogOpenChange]) + const handleSetLocationReady = React.useCallback( + (setupId: string): void => { + handleSetLocationClose() + onProjectHostSetupChange?.(setupId) + }, + [handleSetLocationClose, onProjectHostSetupChange] + ) + const projects = useAppStore((state) => state.projects) + const repos = useAppStore((state) => state.repos) + const defaultCloneUrl = React.useMemo( + () => resolveProjectCloneUrlPrefill(projects, repos, selectedProjectId), + [projects, repos, selectedProjectId] + ) const handleConnectRunTargetHost = React.useCallback( async (option: NeedsSetupProjectHostOption): Promise => { const action = option.connectAction @@ -704,6 +735,7 @@ export default function NewWorkspaceComposerCard({ onAddSshHost={handleAddSshHost} onAddRemoteServer={handleAddRemoteServer} onConnectHost={handleConnectRunTargetHost} + onSetLocation={handleSetLocation} /> {ephemeralVmRecipeError ? (

@@ -1231,6 +1263,14 @@ export default function NewWorkspaceComposerCard({ the in-progress workspace form is preserved; on success the new host flows back into the run-target picker via the store. */} +

) } diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 77b9cf64e52..2309ef54b50 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -197,6 +197,7 @@ function QuickTabBody({ // outcomes still navigate away and tear the whole modal down.) const [addProjectOpen, setAddProjectOpen] = useState(false) const [addProjectMounted, setAddProjectMounted] = useState(false) + const [setLocationOpen, setSetLocationOpen] = useState(false) const handleOpenAddProject = useCallback((): void => { setAddProjectMounted(true) setAddProjectOpen(true) @@ -238,10 +239,10 @@ function QuickTabBody({ : translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace') // Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page). - const nestedDialogOpen = agentSettingsOpen || addProjectOpen + const nestedDialogOpen = agentSettingsOpen || addProjectOpen || setLocationOpen useEffect(() => { if (!active || nestedDialogOpen) { - // Why: while a nested dialog (Add Project / Agents) is layered on top, + // Why: while a nested dialog (Add Project / Agents / Set location) is layered on top, // this capture-phase handler must not steal its Escape (which should // close only the nested dialog) or fire composer submit underneath it. return @@ -324,6 +325,7 @@ function QuickTabBody({ onOpenAgentSettings={() => setAgentSettingsOpen(true)} onCreate={() => void handleCreate()} onAddProjectOverride={handleOpenAddProject} + onNestedDialogOpenChange={setSetLocationOpen} /> {addProjectMounted ? ( diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx index 4b3d0a07ea9..4ca978baa07 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -12,7 +12,8 @@ import { ConnectHostButton, HostRowIcon, NeedsSetupHostIcon, - RunTargetRow + RunTargetRow, + SetLocationButton } from './RunTargetComboboxRow' import { buildRunTargetRows, @@ -34,6 +35,7 @@ type RunTargetComboboxProps = { onAddRemoteServer?: () => void onAddSshHost?: () => void onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise | void + onSetLocation?: (option: NeedsSetupProjectHostOption) => void } const ROOT_ATTRIBUTE = 'data-run-target-combobox-root' @@ -56,7 +58,8 @@ export default function RunTargetCombobox({ onRecipeChange, onAddRemoteServer, onAddSshHost, - onConnectHost + onConnectHost, + onSetLocation }: RunTargetComboboxProps): React.JSX.Element { const [submenu, setSubmenu] = useState<'recipes' | 'add-host' | null>(null) // Track in-flight connects per host so one stalling connect never blocks the others. @@ -134,6 +137,19 @@ export default function RunTargetCombobox({ [connectingHostIds, onConnectHost] ) + // Why: reached from the row body, its inline button, and Enter — keep one path + // so all three close the picker before handing off to the nested dialog. + const setLocation = useCallback( + (option: NeedsSetupProjectHostOption): void => { + if (!option.canSetLocation || !onSetLocation) { + return + } + close() + onSetLocation(option) + }, + [close, onSetLocation] + ) + /** Commits a row, or opens its submenu when the row is a submenu row. */ const activate = useCallback( (key: string | null): void => { @@ -146,12 +162,13 @@ export default function RunTargetCombobox({ return } if (row.kind === 'needs-setup') { - // Not ready: selecting is a no-op, the Connect action is the way forward. + // Not ready: setting the location is the only way forward from the row itself. + setLocation(row.option) return } setSubmenu(row.kind === 'recipes' ? 'recipes' : 'add-host') }, - [rows, selectHost] + [rows, selectHost, setLocation] ) const handleKeyDown = useCallback( @@ -291,6 +308,7 @@ export default function RunTargetCombobox({ if (row.kind === 'needs-setup') { const connecting = connectingHostIds.has(row.option.hostId) const hasConnect = Boolean(row.option.connectAction && onConnectHost) + const hasSetLocation = Boolean(row.option.canSetLocation && onSetLocation) return ( } label={row.option.label} - // Why: a Connect button on the row already says the host - // isn't connected, so its detail line only repeats that. - // Rows without the action still need theirs to explain why. - detail={hasConnect ? '' : row.option.detail} + // Why: Connect / Set project location already say the next + // step, so the detail line would only repeat that. + detail={hasConnect || hasSetLocation ? '' : row.option.detail} armed={isArmed} current={false} dimmed @@ -314,13 +331,18 @@ export default function RunTargetCombobox({ arm(row.key) setSubmenu(null) }} - onCommit={() => {}} + onCommit={() => setLocation(row.option)} trailing={ - row.option.connectAction && onConnectHost ? ( + hasConnect ? ( void connectHost(row.option)} /> + ) : hasSetLocation ? ( + setLocation(row.option)} + /> ) : undefined } /> diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx index fb5fa811f07..4d7cb3df50f 100644 --- a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx @@ -1,6 +1,7 @@ import React from 'react' import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { ProjectOptionDetail } from './ProjectComboboxRow' @@ -173,3 +174,52 @@ export function ConnectHostButton({ ) } + +/** Inline Set project location action on a host that still needs a project path. */ +export function SetLocationButton({ + hostLabel, + onSetLocation +}: { + hostLabel: string + onSetLocation: () => void +}): React.JSX.Element { + const label = translate( + 'auto.components.NewWorkspaceComposerCard.setLocation', + 'Set project location' + ) + return ( + + + + + + {translate( + 'auto.components.NewWorkspaceComposerCard.setLocationTooltip', + 'Choose a folder or clone this project onto {{host}}.', + { host: hostLabel } + )} + + + ) +} diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx new file mode 100644 index 00000000000..bca91bf2e9f --- /dev/null +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { StrictMode, type ComponentProps } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NeedsSetupProjectHostOption } from '@/lib/project-host-setup-options' + +const storeMocks = vi.hoisted(() => ({ + setupProjectExistingFolder: vi.fn(), + setupProjectClone: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => selector(storeMocks) +})) + +vi.mock('@/components/sidebar/RemoteFileBrowser', () => ({ + RemoteFileBrowser: ({ + targetId, + onSelect, + onCancel + }: { + targetId?: string + onSelect: (path: string) => void + onCancel: () => void + }) => ( +
+ + +
+ ) +})) + +import { SetProjectLocationDialog } from './SetProjectLocationDialog' + +const option: NeedsSetupProjectHostOption = { + kind: 'needs-setup', + id: 'needs-setup:ssh:openclaw', + projectId: 'project-orca', + hostId: 'ssh:openclaw', + label: 'openclaw', + detail: 'Project location not set', + isAvailable: true, + attention: false, + canSetLocation: true +} + +function renderDialog( + overrides: Partial> = {} +): ReturnType { + const user = userEvent.setup() + render( + + ) + return user +} + +beforeEach(() => { + storeMocks.setupProjectExistingFolder.mockReset() + storeMocks.setupProjectClone.mockReset() + storeMocks.setupProjectExistingFolder.mockResolvedValue({ + setup: { id: 'setup-openclaw' } + }) + storeMocks.setupProjectClone.mockResolvedValue({ + setup: { id: 'setup-openclaw-clone' } + }) +}) + +afterEach(() => { + cleanup() +}) + +describe('SetProjectLocationDialog', () => { + it('keeps the parent caller in charge of close and saves an existing folder', async () => { + const onClose = vi.fn() + const onReady = vi.fn() + const user = renderDialog({ onClose, onReady }) + + expect(screen.getByTestId('set-project-location-dialog')).toBeTruthy() + expect(screen.getByText('Set project location')).toBeTruthy() + expect(screen.getByText('Choose where orca lives on openclaw.')).toBeTruthy() + + await user.click(screen.getByRole('button', { name: /Browse folder/ })) + await user.click(screen.getByRole('button', { name: 'Browse host filesystem' })) + await user.click(screen.getByRole('button', { name: 'Select remote folder' })) + await user.click(screen.getByRole('button', { name: 'Set location' })) + + expect(storeMocks.setupProjectExistingFolder).toHaveBeenCalledWith({ + projectId: 'project-orca', + hostId: 'ssh:openclaw', + path: '/remote/orca', + kind: 'git', + displayName: 'orca' + }) + expect(onReady).toHaveBeenCalledWith('setup-openclaw') + expect(onClose).not.toHaveBeenCalled() + }) + + it('clones onto the selected host', async () => { + const onReady = vi.fn() + const user = renderDialog({ onReady }) + + await user.click(screen.getByRole('button', { name: /Clone from URL/ })) + await user.click(screen.getByRole('button', { name: 'Browse host filesystem' })) + await user.click(screen.getByRole('button', { name: 'Select remote folder' })) + await user.click(screen.getByRole('button', { name: 'Clone' })) + + expect(storeMocks.setupProjectClone).toHaveBeenCalledWith({ + projectId: 'project-orca', + hostId: 'ssh:openclaw', + url: 'git@github.com:stablyai/orca.git', + destination: '/remote/orca', + displayName: 'orca' + }) + expect(onReady).toHaveBeenCalledWith('setup-openclaw-clone') + }) + + it('hides clone for folder projects', () => { + renderDialog({ projectKind: 'folder' }) + expect(screen.queryByRole('button', { name: /Clone from URL/ })).toBeNull() + }) + + it('does not render when no host is selected', () => { + render( + + ) + expect(screen.queryByTestId('set-project-location-dialog')).toBeNull() + }) + + it('backs out of the host browser on Escape instead of discarding the form', async () => { + const onClose = vi.fn() + const user = renderDialog({ onClose }) + + await user.click(screen.getByRole('button', { name: /Browse folder/ })) + await user.click(screen.getByRole('button', { name: 'Browse host filesystem' })) + expect(screen.getByTestId('remote-file-browser')).toBeTruthy() + + await user.keyboard('{Escape}') + + expect(onClose).not.toHaveBeenCalled() + expect(screen.queryByTestId('remote-file-browser')).toBeNull() + expect(screen.getByRole('button', { name: 'Set location' })).toBeTruthy() + + // A second Escape, now back on the form, dismisses the dialog as usual. + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('does not retarget the composer when a slow clone lands after dismissal', async () => { + // An SSH clone is unbounded and the dialog stays dismissable while it runs. + let finishClone: (result: { setup: { id: string } }) => void = () => {} + storeMocks.setupProjectClone.mockReturnValue( + new Promise((resolve) => { + finishClone = resolve + }) + ) + const onReady = vi.fn() + const onClose = vi.fn() + const { rerender } = render( + + ) + const user = userEvent.setup() + + await user.click(screen.getByRole('button', { name: /Clone from URL/ })) + await user.type(screen.getByPlaceholderText('/destination/on/host'), '/remote/orca') + await user.click(screen.getByRole('button', { name: 'Clone' })) + + // The user backs out and points the composer somewhere else. + rerender( + + ) + await act(async () => { + finishClone({ setup: { id: 'setup-openclaw-clone' } }) + }) + + expect(onReady).not.toHaveBeenCalled() + }) + + it('still reports success under StrictMode double-invoked effects', async () => { + const onReady = vi.fn() + render( + + + + ) + const user = userEvent.setup() + + await user.click(screen.getByRole('button', { name: /Browse folder/ })) + await user.type(screen.getByPlaceholderText('/path/to/project/on/host'), '/remote/orca') + await user.click(screen.getByRole('button', { name: 'Set location' })) + + expect(onReady).toHaveBeenCalledWith('setup-openclaw') + }) + + it('notifies the parent when dismissed', async () => { + const onClose = vi.fn() + renderDialog({ onClose }) + await act(async () => { + document.querySelector('[data-slot="dialog-close"]')?.click() + }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx new file mode 100644 index 00000000000..3b06ebd716d --- /dev/null +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.tsx @@ -0,0 +1,291 @@ +import { useEffect, useRef, useState, type RefObject } from 'react' +import { Download, FolderOpen } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { CreateProjectParentBrowser } from '@/components/sidebar/CreateProjectLocationField' +import type { NeedsSetupProjectHostOption } from '@/lib/project-host-setup-options' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { RepoKind } from '../../../../shared/repo-types' +import { pickLocalProjectLocationFolder } from './pick-local-project-folder' +import { CloneForm, ExistingFolderForm, LocationActionButton } from './SetProjectLocationForms' + +type DialogView = 'choose' | 'existing' | 'clone' | 'browse' +type BrowseField = 'existing' | 'clone' + +type SetProjectLocationDialogProps = { + option: NeedsSetupProjectHostOption | null + projectName: string + projectKind: RepoKind + defaultCloneUrl: string + onClose: () => void + onReady: (setupId: string) => void +} + +export function SetProjectLocationDialog({ + option, + projectName, + projectKind, + defaultCloneUrl, + onClose, + onReady +}: SetProjectLocationDialogProps): React.JSX.Element { + const open = option !== null + // Why: keep the last option rendered through the close animation, so the body + // doesn't blank out as the dialog slides away. + const [renderOption, setRenderOption] = useState(option) + if (option !== null && option !== renderOption) { + setRenderOption(option) + } + const activeOption = option ?? renderOption + // Why: Radix dismisses on Escape from a document-capture listener, so the host + // browser can never intercept it itself. The body parks a back-out here so + // Escape steps out of the browser instead of discarding the half-filled form. + const exitHostBrowser = useRef<(() => boolean) | null>(null) + + return ( + { + if (!next) { + onClose() + } + }} + > + { + if (exitHostBrowser.current?.()) { + // Radix only skips onDismiss when the escape was defaultPrevented. + event.preventDefault() + } + }} + > + {activeOption ? ( + + ) : null} + + + ) +} + +function SetProjectLocationDialogBody({ + option, + projectName, + projectKind, + defaultCloneUrl, + exitHostBrowser, + onReady +}: { + option: NeedsSetupProjectHostOption + projectName: string + projectKind: RepoKind + defaultCloneUrl: string + exitHostBrowser: RefObject<(() => boolean) | null> + onReady: (setupId: string) => void +}): React.JSX.Element { + const setupProjectExistingFolder = useAppStore((state) => state.setupProjectExistingFolder) + const setupProjectClone = useAppStore((state) => state.setupProjectClone) + const [view, setView] = useState('choose') + const [browseField, setBrowseField] = useState('existing') + const [setupPath, setSetupPath] = useState('') + const [setupKind, setSetupKind] = useState(projectKind) + const [cloneUrl, setCloneUrl] = useState(defaultCloneUrl) + const [cloneDestination, setCloneDestination] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + // Why: an SSH clone is unbounded and the dialog stays dismissable while it + // runs. Without this, a clone the user backed out of minutes ago still fires + // onReady, silently moving the run target (and resetting start-from) under a + // form they have since pointed at another host. + const abandoned = useRef(false) + useEffect(() => { + // Re-armed on mount: StrictMode runs mount/cleanup/mount, and latching this + // on the first cleanup would disable the success path for the whole session. + abandoned.current = false + return () => { + abandoned.current = true + } + }, []) + const parsedHost = parseExecutionHostId(option.hostId) + // Remote hosts browse in-dialog; the local host gets the native folder picker. + const remoteHost = + parsedHost?.kind === 'ssh' || parsedHost?.kind === 'runtime' ? parsedHost : null + const canClone = projectKind === 'git' + // Both views browse for a path; this is the field each one writes back to. + const pathFields: Record void }> = { + existing: { value: setupPath, set: setSetupPath }, + clone: { value: cloneDestination, set: setCloneDestination } + } + + const browsing = view === 'browse' && remoteHost !== null + useEffect(() => { + exitHostBrowser.current = browsing + ? () => { + setView(browseField) + return true + } + : null + return () => { + exitHostBrowser.current = null + } + }, [browseField, browsing, exitHostBrowser]) + + const openHostBrowser = (field: BrowseField): void => { + if (!remoteHost) { + void pickLocalProjectLocationFolder(pathFields[field].set) + return + } + setBrowseField(field) + setView('browse') + } + + const handleExistingSubmit = async (): Promise => { + if (!setupPath.trim()) { + return + } + setIsSubmitting(true) + try { + const result = await setupProjectExistingFolder({ + projectId: option.projectId, + hostId: option.hostId, + path: setupPath.trim(), + kind: setupKind, + displayName: projectName + }) + if (result && !abandoned.current) { + onReady(result.setup.id) + } + } finally { + setIsSubmitting(false) + } + } + + const handleCloneSubmit = async (): Promise => { + if (!cloneUrl.trim() || !cloneDestination.trim()) { + return + } + setIsSubmitting(true) + try { + const result = await setupProjectClone({ + projectId: option.projectId, + hostId: option.hostId, + url: cloneUrl.trim(), + destination: cloneDestination.trim(), + displayName: projectName + }) + if (result && !abandoned.current) { + onReady(result.setup.id) + } + } finally { + setIsSubmitting(false) + } + } + + if (browsing && remoteHost) { + return ( + setView(browseField)} + /> + ) + } + + return ( + <> + + + {translate( + 'auto.components.new.workspace.SetProjectLocationDialog.title', + 'Set project location' + )} + + + {translate( + 'auto.components.new.workspace.SetProjectLocationDialog.description', + 'Choose where {{project}} lives on {{host}}.', + { project: projectName, host: option.label } + )} + + + {view === 'choose' ? ( +
+ { + setView('existing') + // Local hosts get the native picker straight away — one click instead of two. + if (!remoteHost && !setupPath) { + void pickLocalProjectLocationFolder(setSetupPath) + } + }} + /> + {canClone ? ( + setView('clone')} + /> + ) : null} +
+ ) : null} + {view === 'existing' ? ( + setView('choose')} + onPathChange={setSetupPath} + onKindChange={setSetupKind} + onBrowse={() => openHostBrowser('existing')} + onSubmit={() => void handleExistingSubmit()} + /> + ) : null} + {view === 'clone' ? ( + setView('choose')} + onCloneUrlChange={setCloneUrl} + onCloneDestinationChange={setCloneDestination} + onBrowse={() => openHostBrowser('clone')} + onSubmit={() => void handleCloneSubmit()} + /> + ) : null} + + ) +} diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationForms.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationForms.tsx new file mode 100644 index 00000000000..1793e65a9cf --- /dev/null +++ b/src/renderer/src/components/new-workspace/SetProjectLocationForms.tsx @@ -0,0 +1,224 @@ +import { ArrowLeft, FolderOpen, LoaderCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { RepoKind } from '../../../../shared/repo-types' + +export function ExistingFolderForm({ + setupPath, + setupKind, + isSubmitting, + onBack, + onPathChange, + onKindChange, + onBrowse, + onSubmit +}: { + setupPath: string + setupKind: RepoKind + isSubmitting: boolean + onBack: () => void + onPathChange: (value: string) => void + onKindChange: (value: RepoKind) => void + onBrowse: () => void + onSubmit: () => void +}): React.JSX.Element { + return ( +
+ +
+ onPathChange(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.setupExistingFolderPathPlaceholder', + '/path/to/project/on/host' + )} + className="h-9 min-w-0 flex-1 font-mono text-sm" + spellCheck={false} + /> + +
+ +
+ +
+
+ ) +} + +export function CloneForm({ + cloneUrl, + cloneDestination, + isSubmitting, + onBack, + onCloneUrlChange, + onCloneDestinationChange, + onBrowse, + onSubmit +}: { + cloneUrl: string + cloneDestination: string + isSubmitting: boolean + onBack: () => void + onCloneUrlChange: (value: string) => void + onCloneDestinationChange: (value: string) => void + onBrowse: () => void + onSubmit: () => void +}): React.JSX.Element { + return ( +
+ + onCloneUrlChange(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.cloneUrlPlaceholder', + 'Repository URL' + )} + className="h-9 min-w-0" + spellCheck={false} + /> +
+ onCloneDestinationChange(event.target.value)} + placeholder={translate( + 'auto.components.settings.RepositoryPane.cloneDestinationPlaceholder', + '/destination/on/host' + )} + className="h-9 min-w-0 flex-1 font-mono text-sm" + spellCheck={false} + /> + +
+
+ +
+
+ ) +} + +export function LocationActionButton({ + icon: Icon, + title, + description, + onClick +}: { + icon: typeof FolderOpen + title: string + description: string + onClick: () => void +}): React.JSX.Element { + return ( + + ) +} + +function StepBackButton({ + onBack, + label +}: { + onBack: () => void + label: string +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/new-workspace/pick-local-project-folder.ts b/src/renderer/src/components/new-workspace/pick-local-project-folder.ts new file mode 100644 index 00000000000..c49f24b6b33 --- /dev/null +++ b/src/renderer/src/components/new-workspace/pick-local-project-folder.ts @@ -0,0 +1,14 @@ +import { toast } from 'sonner' + +export async function pickLocalProjectLocationFolder( + onPicked: (path: string) => void +): Promise { + try { + const path = await window.api.repos.pickFolder() + if (path) { + onPicked(path) + } + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts index 7e2c35cb75d..a2a53ad56ba 100644 --- a/src/renderer/src/hooks/useComposerState-host-retarget.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-retarget.test.ts @@ -55,8 +55,23 @@ describe('useComposerState host retarget', () => { 'const handleProjectHostSetupChange', 'const handleProjectChange' ) - expect(switchSection).toContain('setSelectedProjectHostSetupOverrideId(option.id)') + expect(switchSection).toContain('setSelectedProjectHostSetupOverrideId(target.id)') expect(switchSection).toContain('preserveStartFrom: true') expect(switchSection).toContain('forceResetStartFrom: true') }) + + it('resolves a just-created setup through the option builder, not the raw store record', () => { + const switchSection = sourceBetween( + HOOK_SOURCE, + 'const handleProjectHostSetupChange', + 'const handleProjectChange' + ) + // Reading projectHostSetups directly skips repo eligibility, ephemeral-VM and + // runtime-owned host exclusion, and the one-setup-per-host dedupe that decides + // which setup creation resolves to — so a set location could retarget the + // composer at a different location than the one just chosen (#14868/#14912). + expect(switchSection).not.toContain('.projectHostSetups.find(') + expect(switchSection).toContain('buildProjectHostSetupOptions({') + expect(switchSection).toContain("candidate.kind === 'ready'") + }) }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index ec91d1f969a..a56cd2163dc 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -128,7 +128,8 @@ import { } from '@/lib/project-host-workspace-target' import { buildProjectHostSetupOptions, - type ProjectHostSetupOption + type ProjectHostSetupOption, + type ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options' import { buildNewWorkspaceCreateTargetOptions, @@ -2796,17 +2797,34 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleProjectHostSetupChange = useCallback( (setupId: string): void => { const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId) - if (!option || option.kind !== 'ready') { + // Why: a just-created setup lands in the store before the memoized picker + // options refresh. Rebuild through the same builder rather than reading the + // raw record — repo eligibility, ephemeral-VM/runtime-owned host exclusion, + // and one-setup-per-host dedupe all decide which setup creation resolves to. + // Skipping them can retarget to a location other than the one just chosen. + const target = + option?.kind === 'ready' + ? option + : buildProjectHostSetupOptions({ + projectId: selectedRepoProjectId, + projectHostSetups: useAppStore.getState().projectHostSetups, + eligibleRepos: getComposerEligibleRepos(useAppStore.getState().repos), + hosts: hostOptions + }).find( + (candidate): candidate is ReadyProjectHostSetupOption => + candidate.id === setupId && candidate.kind === 'ready' + ) + if (!target) { return } // Why: switching run host for the same project must not erase the task/PR source the user is starting from. - setSelectedProjectHostSetupOverrideId(option.id) - handleRepoChange(option.repoId, { + setSelectedProjectHostSetupOverrideId(target.id) + handleRepoChange(target.repoId, { preserveStartFrom: true, forceResetStartFrom: true }) }, - [handleRepoChange, projectHostSetupOptions] + [handleRepoChange, hostOptions, projectHostSetupOptions, selectedRepoProjectId] ) const handleProjectChange = useCallback( (projectId: string): void => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dbb9d4dacaa..f5a4b142808 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1499,6 +1499,9 @@ "connectTimedOut": "Connection timed out. It may still be connecting in the background.", "connectingHost": "Connecting…", "connectHost": "Connect", + "setLocation": "Set project location", + "setLocationOnHost": "Set project location on {{host}}", + "setLocationTooltip": "Choose a folder or clone this project onto {{host}}.", "addHost": "Add host", "addHostHint": "Register another machine or Orca server", "addSshHost": "Add SSH host", @@ -12951,6 +12954,15 @@ }, "new": { "workspace": { + "SetProjectLocationDialog": { + "title": "Set project location", + "description": "Choose where {{project}} lives on {{host}}.", + "browseFolder": "Browse folder", + "browseFolderHelp": "Use an existing checkout or folder on this host.", + "cloneFromUrl": "Clone from URL", + "cloneFromUrlHelp": "Clone this repository onto {{host}}.", + "saveLocation": "Set location" + }, "SmartWorkspaceNameField": { "2a0d535f69": "Create new branch", "a44229ce4d": "as workspace name", diff --git a/src/renderer/src/lib/project-clone-url-prefill.test.ts b/src/renderer/src/lib/project-clone-url-prefill.test.ts new file mode 100644 index 00000000000..b6df45d2986 --- /dev/null +++ b/src/renderer/src/lib/project-clone-url-prefill.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { Project } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { resolveProjectCloneUrlPrefill } from './project-clone-url-prefill' + +function project(sourceRepoIds: string[]): Project { + return { id: 'project-orca', sourceRepoIds } as unknown as Project +} + +function repo(id: string, remoteUrl: string): Repo { + return { id, gitRemoteIdentity: { remoteUrl } } as unknown as Repo +} + +describe('resolveProjectCloneUrlPrefill', () => { + it('strips an embedded token so it is never cloned onto another host', () => { + // The clone runs on the target host and would persist this into .git/config. + const prefill = resolveProjectCloneUrlPrefill( + [project(['repo-1'])], + [repo('repo-1', 'https://x-access-token:ghp_ABC123@github.com/acme/orca.git')], + 'project-orca' + ) + + expect(prefill).toBe('https://github.com/acme/orca.git') + expect(prefill).not.toContain('ghp_ABC123') + }) + + it('strips a user:password pair on any scheme', () => { + expect( + resolveProjectCloneUrlPrefill( + [project(['repo-1'])], + [repo('repo-1', 'https://alice:hunter2@gitlab.com/acme/orca.git')], + 'project-orca' + ) + ).toBe('https://gitlab.com/acme/orca.git') + }) + + it('leaves an SSH remote untouched, since git@host is part of the URL', () => { + expect( + resolveProjectCloneUrlPrefill( + [project(['repo-1'])], + [repo('repo-1', 'git@github.com:stablyai/orca.git')], + 'project-orca' + ) + ).toBe('git@github.com:stablyai/orca.git') + }) + + it('returns empty when the project, repo, or remote is missing', () => { + expect(resolveProjectCloneUrlPrefill([], [], null)).toBe('') + expect(resolveProjectCloneUrlPrefill([], [], 'project-orca')).toBe('') + expect(resolveProjectCloneUrlPrefill([project(['repo-1'])], [], 'project-orca')).toBe('') + }) + + it('takes the first source repo that actually has a remote', () => { + expect( + resolveProjectCloneUrlPrefill( + [project(['repo-no-remote', 'repo-2'])], + [ + { id: 'repo-no-remote' } as unknown as Repo, + repo('repo-2', 'https://github.com/acme/second.git') + ], + 'project-orca' + ) + ).toBe('https://github.com/acme/second.git') + }) +}) diff --git a/src/renderer/src/lib/project-clone-url-prefill.ts b/src/renderer/src/lib/project-clone-url-prefill.ts new file mode 100644 index 00000000000..3dc78621d97 --- /dev/null +++ b/src/renderer/src/lib/project-clone-url-prefill.ts @@ -0,0 +1,29 @@ +import { stripCredentialsFromMessage } from '../../../shared/git-remote-error' +import type { Project } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' + +/** + * The clone URL to seed "Clone from URL" with, taken from the project's own + * remote so the common case is one click. + * + * Credentials are stripped: the stored value is the verbatim `git remote` URL + * and can embed a PAT, while the clone runs on the *target* host and would write + * that token into its `.git/config` — a credential the user never typed into + * this flow. Same treatment `getProvisionedRootRecipeRepoUrl` gives the + * ephemeral-VM recipe URL. + */ +export function resolveProjectCloneUrlPrefill( + projects: readonly Project[], + repos: readonly Repo[], + selectedProjectId: string | null +): string { + if (!selectedProjectId) { + return '' + } + const sourceRepoIds = + projects.find((candidate) => candidate.id === selectedProjectId)?.sourceRepoIds ?? [] + const remoteUrl = sourceRepoIds + .map((sourceId) => repos.find((repo) => repo.id === sourceId)?.gitRemoteIdentity?.remoteUrl) + .find((url): url is string => Boolean(url)) + return remoteUrl ? stripCredentialsFromMessage(remoteUrl) : '' +} diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index 9eee19f9725..898fbd7b876 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -270,11 +270,30 @@ describe('buildProjectHostSetupOptions', () => { kind: 'needs-setup', label: 'Builder', detail: 'Project location not set', - isAvailable: true + isAvailable: true, + canSetLocation: true }) ]) }) + // A `repo:` project has no cross-host identity, so linking it on another host + // always fails in main — the row keeps its status line instead of a dead button. + it('cannot set a location for a host-local project', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'repo:local-repo', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('ssh:builder', { label: 'Builder' })], + projectHostSetups: [setup('local', 'repo:local-repo', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + kind: 'needs-setup', + detail: 'Project location not set', + isAvailable: true, + canSetLocation: false + }) + }) + it.each([ ['connecting' as const, 'Connecting to host'], ['disconnected' as const, 'Connect this host to set up projects'], @@ -407,7 +426,8 @@ describe('buildProjectHostSetupOptions', () => { kind: 'needs-setup', label: 'GPU VM', detail: 'Project setup is in progress', - isAvailable: true + isAvailable: true, + canSetLocation: false }) ]) }) @@ -437,7 +457,10 @@ describe('buildProjectHostSetupOptions', () => { }) ] }).at(-1) - ).toMatchObject({ detail: 'Project tracked on this host but not set up' }) + ).toMatchObject({ + detail: 'Project tracked on this host but not set up', + canSetLocation: true + }) expect( buildProjectHostSetupOptions({ @@ -457,7 +480,7 @@ describe('buildProjectHostSetupOptions', () => { }) ] }).at(-1) - ).toMatchObject({ detail: 'Project setup needs attention' }) + ).toMatchObject({ detail: 'Project setup needs attention', canSetLocation: true }) expect( buildProjectHostSetupOptions({ @@ -477,7 +500,10 @@ describe('buildProjectHostSetupOptions', () => { }) ] }).at(-1) - ).toMatchObject({ detail: 'Project is unsupported on this host' }) + ).toMatchObject({ + detail: 'Project is unsupported on this host', + canSetLocation: false + }) }) it('marks incompatible runtime hosts as visible but unavailable', () => { diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index eca4dc397c6..b4e975b0569 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -6,6 +6,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { isHostLocalProjectId } from '../../../shared/project-host-setup-projection' import { isEphemeralVmRuntimeEnvironment } from '../../../shared/runtime-environments' import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, @@ -36,6 +37,9 @@ export type ProjectHostSetupOption = // Why: only a genuine connection error warrants an alarm glyph; a dormant // disconnected host is merely not-yet-connected, not broken. attention: boolean + // Why: available hosts without a path can be set up in place; connecting or + // in-progress/unsupported hosts need a different next step. + canSetLocation: boolean connectAction?: { kind: 'ssh'; targetId: string } | { kind: 'runtime'; environmentId: string } } @@ -187,6 +191,7 @@ function buildNeedsSetupOptions({ : availability.detail, isAvailable: availability.isAvailable, attention: host.health === 'error', + canSetLocation: canSetProjectLocation(projectId, availability.isAvailable, pendingSetup), ...(connectAction ? { connectAction } : {}) } }) @@ -263,6 +268,23 @@ function getHostHealthUnavailableDetail( } } +function canSetProjectLocation( + projectId: string, + isAvailable: boolean, + pendingSetup: ProjectHostSetup | undefined +): boolean { + // Why: setting up on another host links by project identity, and a host-local + // `repo:` project has none to match against — the call always fails, so offer + // the plain status line rather than a button that only ever toasts an error. + if (!isAvailable || isHostLocalProjectId(projectId)) { + return false + } + if (!pendingSetup) { + return true + } + return pendingSetup.setupState === 'not-set-up' || pendingSetup.setupState === 'error' +} + function getHostConnectAction( host: ExecutionHostRegistryEntry ): NeedsSetupProjectHostOption['connectAction'] | undefined { diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 09f799e5527..8c3e5d15643 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -93,6 +93,8 @@ export function isProjectRemoteIdentityPending( return repo.gitRemoteIdentity === undefined && !hasProjectRemoteIdentity(repo) } +const HOST_LOCAL_PROJECT_ID_PREFIX = 'repo:' + export function getProjectIdentityKey( repo: Pick ): string { @@ -104,7 +106,16 @@ export function getProjectIdentityKey( if (gitRemoteIdentity) { return `git:${gitRemoteIdentity.canonicalKey}` } - return `repo:${repo.id}` + return `${HOST_LOCAL_PROJECT_ID_PREFIX}${repo.id}` +} + +/** + * True for the `repo:` fallback above — a folder project, or a git repo with no + * remote. The id is a per-host repo id, so the same project on another host derives a + * different one and can never be matched there. + */ +export function isHostLocalProjectId(projectId: string): boolean { + return projectId.startsWith(HOST_LOCAL_PROJECT_ID_PREFIX) } export function getProjectIdForProviderIdentity(identity: ProjectProviderIdentity): string { From c73e5a2f590ef8904064ae92235efdfad6a91462 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:00:49 -0700 Subject: [PATCH 06/28] fix(mobile): bound pending-handle session-tab recovery polling (STA-4407) (#14916) * fix(mobile): bound pending terminal recovery polls * wip(mobile): partial STA-4407 bound pending-handle poll * fix(mobile): finish bounded pending-handle recovery * fix(mobile): preserve pending-handle recovery attempts on slow links * fix(mobile): retain pending-handle cadence budget semantics * test(mobile): pin pending recovery parked state resets * chore(mobile): drop the STA-4407 worker status logbook * fix(mobile): preserve pending recovery liveness * fix(mobile): coalesce repeated recovery retries * fix(mobile): memoize pending recovery context * fix(mobile): type the pending recovery poll test renderer explicitly * fix(mobile): type the poll test renderer without an any union * fix(mobile): keep recovery context refs current * test(mobile): tighten pending recovery coverage * test(mobile): preserve recovery-source liveness * fix(mobile): keep the poll test renderer union free of any * test(mobile): prove parked recovery isolation * fix(mobile): write the parked-recovery callback ref after commit * test(mobile): isolate recovery identity changes * test(mobile): prove recovery publication boundaries --- .../app/h/[hostId]/session/[worktreeId].tsx | 77 +++- .../mobile-session-startup-source.test.ts | 38 +- .../mobile-session-tabs-stream-health.test.ts | 23 ++ .../mobile-session-tabs-stream-health.ts | 37 +- ...inal-handle-recovery-context-cache.test.ts | 63 +++ ...ding-terminal-handle-recovery-poll.test.ts | 371 ++++++++++++++++++ .../pending-terminal-handle-recovery.test.ts | 126 +++++- .../pending-terminal-handle-recovery.ts | 75 +++- .../use-mobile-session-tabs-reconciliation.ts | 61 ++- 9 files changed, 823 insertions(+), 48 deletions(-) create mode 100644 mobile/src/session/pending-terminal-handle-recovery-context-cache.test.ts create mode 100644 mobile/src/session/pending-terminal-handle-recovery-poll.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8bef45a6b9f..7ab26ae55da 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -207,7 +207,6 @@ import { confirmsMirroredTabSelection, type AppliedSnapshotMarker } from '../../../../src/session/session-tab-snapshot-gate' -import { hasPendingTerminalHandleRecoveryNeed } from '../../../../src/session/pending-terminal-handle-recovery' import { createInitialSessionAutoCreateState, useInitialSessionTerminalAutoCreate, @@ -254,6 +253,7 @@ import type { } from '../../../../src/session/mobile-session-tabs-stream-health' import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting' import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation' +import { PendingTerminalHandleRecoveryContextCache } from '../../../../src/session/pending-terminal-handle-recovery' import { getRepoIdFromMobileWorktreeId, getActiveTabIdForHandle, @@ -936,6 +936,9 @@ export default function SessionScreen() { const pendingBrowserFocusPageIdRef = useRef(null) const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null) const pendingTerminalActivationAttemptRef = useRef(null) + const [parkedPendingTerminalContext, setParkedPendingTerminalContext] = useState( + null + ) // Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render). const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) @@ -2284,16 +2287,25 @@ export default function SessionScreen() { () => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null || - // Why: a pending-handle terminal only turns ready in a fresh snapshot. A live - // stream otherwise parks the poll, and a host that mints the handle without - // republishing strands the pane on its spinner forever (STA-4256). - hasPendingTerminalHandleRecoveryNeed(sessionTabsRef.current, activeSessionTabIdRef.current) || // Why: a chat-covered handle that ran out of rearms and left `terminal.list` // was reminted by a desktop graph reload. Only a fresh tab snapshot carries // the replacement handle, so force one instead of holding the composer locked. nativeChatStream.hasTabsRecoveryNeed(), [nativeChatStream] ) + const pendingTerminalRecoveryContextCache = useMemo( + () => new PendingTerminalHandleRecoveryContextCache(), + [] + ) + const getPendingTerminalRecoveryContextKey = useCallback( + () => + pendingTerminalRecoveryContextCache.read( + sessionTabsRef.current, + activeSessionTabIdRef.current + ), + [pendingTerminalRecoveryContextCache] + ) + const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey() const getSessionTabsApplicationRevision = useCallback( () => appliedSessionTabsRevisionRef.current, [] @@ -2302,18 +2314,25 @@ export default function SessionScreen() { worktreeId, diagnosticsRef: terminalDiagnosticsRef }) - const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } = - useMobileSessionTabsReconciliation({ - client, - connState, - worktreeId, - applySessionTabs, - consumeAcceptedSessionTabs, - fetchTerminals, - hasRecoveryNeed: hasSessionTabsRecoveryNeed, - getApplicationRevision: getSessionTabsApplicationRevision, - ...sessionTabsFetchReporting - }) + const { + fetchSessionTabs, + ensureSessionTabs, + fetchPendingBrowserSessionTabs, + retryPendingTerminalRecovery + } = useMobileSessionTabsReconciliation({ + client, + connState, + worktreeId, + applySessionTabs, + consumeAcceptedSessionTabs, + fetchTerminals, + hasRecoveryNeed: hasSessionTabsRecoveryNeed, + pendingTerminalRecoveryContextKey, + getPendingTerminalRecoveryContextKey, + onPendingTerminalRecoveryParked: setParkedPendingTerminalContext, + getApplicationRevision: getSessionTabsApplicationRevision, + ...sessionTabsFetchReporting + }) useEffect(() => { if (connState === 'connected') { @@ -4075,6 +4094,9 @@ export default function SessionScreen() { activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string' ? activeSessionTab : null + const isPendingTerminalRecoveryParked = + pendingTerminalRecoveryContextKey !== null && + pendingTerminalRecoveryContextKey === parkedPendingTerminalContext useEffect(() => { if (!client || connState !== 'connected' || !activePendingTerminalTab) { @@ -4593,10 +4615,27 @@ export default function SessionScreen() { ) : activePendingTerminalTab ? ( - + {!isPendingTerminalRecoveryParked && ( + + )} - {activePendingTerminalTab.title || 'Loading terminal'} + {isPendingTerminalRecoveryParked + ? 'Terminal is taking longer than expected' + : activePendingTerminalTab.title || 'Loading terminal'} + {isPendingTerminalRecoveryParked && ( + [ + styles.createButton, + pressed && styles.newTerminalButtonPressed + ]} + onPress={() => void retryPendingTerminalRecovery()} + > + Retry + + )} ) : ( { ) }) - it('counts a pending-handle active terminal as a tabs recovery need (STA-4256)', () => { - const recoveryNeed = sliceBetween( - 'const hasSessionTabsRecoveryNeed = useCallback(', - 'const getSessionTabsApplicationRevision =' + it('wires pending-handle recovery through its bounded context (STA-4256)', () => { + const applySessionTabs = sliceBetween( + 'const applySessionTabs = useCallback(', + 'const consumeAcceptedSessionTabs = useCallback(' + ) + const recoveryContext = sliceBetween( + 'const pendingTerminalRecoveryContextCache = useMemo(', + 'const getSessionTabsApplicationRevision' ) - expect(recoveryNeed).toContain('hasPendingTerminalHandleRecoveryNeed(') - expect(recoveryNeed).toContain('sessionTabsRef.current') - expect(recoveryNeed).toContain('activeSessionTabIdRef.current') - // The predicate only reaches the poll loop through this hook's option. + const tabsRefWrite = 'sessionTabsRef.current = nextTabs' + const tabsStateWrite = 'setSessionTabs((prev)' + const activeRefWrite = 'activeSessionTabIdRef.current = active?.id ?? null' + const activeStateWrite = 'setActiveSessionTabId(active?.id ?? null)' + for (const write of [tabsRefWrite, tabsStateWrite, activeRefWrite, activeStateWrite]) { + expect(applySessionTabs).toContain(write) + } + expect(applySessionTabs.indexOf(tabsRefWrite)).toBeLessThan( + applySessionTabs.indexOf(tabsStateWrite) + ) + expect(applySessionTabs.indexOf(activeRefWrite)).toBeLessThan( + applySessionTabs.indexOf(activeStateWrite) + ) + expect(recoveryContext).toContain('() => new PendingTerminalHandleRecoveryContextCache()') + expect(recoveryContext).toContain('sessionTabsRef.current,') + expect(recoveryContext).toContain('activeSessionTabIdRef.current') + expect(recoveryContext).toContain( + 'const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()' + ) expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed') + expect(source).toContain('getPendingTerminalRecoveryContextKey,') + expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext') + expect(source).toContain('retryPendingTerminalRecovery()') }) }) diff --git a/mobile/src/session/mobile-session-tabs-stream-health.test.ts b/mobile/src/session/mobile-session-tabs-stream-health.test.ts index 954a3417bab..399128bb511 100644 --- a/mobile/src/session/mobile-session-tabs-stream-health.test.ts +++ b/mobile/src/session/mobile-session-tabs-stream-health.test.ts @@ -134,6 +134,29 @@ describe('MobileSessionTabsStreamHealth', () => { expect(harness.apply).toHaveBeenCalledTimes(2) }) + it('coalesces repeated retries and releases the retry cohort after failure', async () => { + const harness = makeHarness() + harness.controller.setReconciliationActive(true) + + const halfOpen = harness.controller.requestReconciliation() + const retry = harness.controller.retryReconciliation() + const repeatedRetry = harness.controller.retryReconciliation() + expect(harness.sendRequest).toHaveBeenCalledTimes(2) + expect(repeatedRetry).toBe(retry) + + harness.requests[1]!.resolve(failure()) + await retry + const nextRetry = harness.controller.retryReconciliation() + expect(harness.sendRequest).toHaveBeenCalledTimes(3) + expect(nextRetry).not.toBe(retry) + + harness.requests[2]!.resolve(success(result(3))) + await nextRetry + harness.requests[0]!.resolve(success(result(1))) + await halfOpen + expect(harness.apply).toHaveBeenCalledTimes(1) + }) + it('starts distinct pre- and post-snapshot lists and discards the stale barrier', async () => { const harness = makeHarness() harness.controller.setReconciliationActive(true) diff --git a/mobile/src/session/mobile-session-tabs-stream-health.ts b/mobile/src/session/mobile-session-tabs-stream-health.ts index 944c7de9412..66dfd21ddb3 100644 --- a/mobile/src/session/mobile-session-tabs-stream-health.ts +++ b/mobile/src/session/mobile-session-tabs-stream-health.ts @@ -19,6 +19,7 @@ type RequestOwner = { type RequestCohort = { promise: Promise resolve: () => void + retry: boolean } type ControllerOptions = { @@ -31,6 +32,7 @@ type ControllerOptions = { source: SessionTabsStreamSource ) => void hasRecoveryNeed: () => boolean + allowRecoveryPoll?: () => boolean getApplicationRevision?: () => number onFetchStarted?: () => void onFetchSucceeded?: (result: Result) => void @@ -84,16 +86,35 @@ export class MobileSessionTabsStreamHealth { return this.requestReconciliation() } + retryReconciliation(): Promise { + this.syncGeneration() + const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`) + if (currentRequest?.retry) { + return currentRequest.promise + } + this.requirementRevision += 1 + this.barrier += currentRequest ? 1 : 0 + return this.startCurrentRequest(true) + } + poll(): Promise | null { this.syncGeneration() - if ( - !this.reconciliationActive || - (this.health === 'live' && - !this.options.hasRecoveryNeed() && - this.requirementRevision <= this.satisfiedRevision) - ) { + if (!this.reconciliationActive) { return null } + const recoveryNeeded = this.options.hasRecoveryNeed() + if (this.health === 'live') { + if (!recoveryNeeded && this.requirementRevision <= this.satisfiedRevision) { + return null + } + const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`) + if (currentRequest) { + return currentRequest.promise + } + if (recoveryNeeded && this.options.allowRecoveryPoll?.() === false) { + return null + } + } return this.ensureReconciliation() } @@ -186,7 +207,7 @@ export class MobileSessionTabsStreamHealth { this.requirementRevision += 1 } - private startCurrentRequest(): Promise { + private startCurrentRequest(retry = false): Promise { if (this.disposed || !this.reconciliationActive) { return Promise.resolve() } @@ -199,7 +220,7 @@ export class MobileSessionTabsStreamHealth { const promise = new Promise((resolve) => { resolveRequest = resolve }) - const cohort = { promise, resolve: resolveRequest } + const cohort = { promise, resolve: resolveRequest, retry } this.inFlight.set(key, cohort) this.runCohortRequest(key, cohort) return promise diff --git a/mobile/src/session/pending-terminal-handle-recovery-context-cache.test.ts b/mobile/src/session/pending-terminal-handle-recovery-context-cache.test.ts new file mode 100644 index 00000000000..0ba91449462 --- /dev/null +++ b/mobile/src/session/pending-terminal-handle-recovery-context-cache.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import type { MobileSessionTab } from './mobile-session-route-types' +import { PendingTerminalHandleRecoveryContextCache } from './pending-terminal-handle-recovery' + +function terminalTab(id: string, terminal: string | null): MobileSessionTab { + return { + type: 'terminal', + id, + title: 'zsh', + parentTabId: 'parent', + leafId: 'leaf', + status: terminal === null ? 'pending-handle' : 'ready', + terminal, + isActive: true + } +} + +describe('PendingTerminalHandleRecoveryContextCache', () => { + it('reads authoritative tab refs before the next render', () => { + const cache = new PendingTerminalHandleRecoveryContextCache() + const pendingTabs = [terminalTab('terminal-a', null)] + const readyTabs = [terminalTab('terminal-a', 'pty-a')] + + expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull() + expect(cache.read(readyTabs, 'terminal-a')).toBeNull() + expect(cache.read(pendingTabs, null)).toBeNull() + expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull() + }) + + it('recomputes when the pending terminal identity changes', () => { + const cache = new PendingTerminalHandleRecoveryContextCache() + const first = terminalTab('terminal-a', null) + const parentChanged = { ...first, parentTabId: 'other-parent' } + const leafChanged = { ...first, leafId: 'other-leaf' } + const collisionLeft = { ...first, parentTabId: 'ab', leafId: 'c' } + const collisionRight = { ...first, parentTabId: 'a', leafId: 'bc' } + const firstKey = cache.read([first], first.id) + + expect(cache.read([parentChanged], parentChanged.id)).not.toBe(firstKey) + expect(cache.read([leafChanged], leafChanged.id)).not.toBe(firstKey) + const collisionLeftKey = cache.read([collisionLeft], collisionLeft.id) + expect(cache.read([collisionRight], collisionRight.id)).not.toBe(collisionLeftKey) + }) + + it('does not rescan an unchanged context on the polling hot path', () => { + let skippedTabIdReads = 0 + const skippedTab = terminalTab('terminal-b', 'pty-b') + Object.defineProperty(skippedTab, 'id', { + get: () => { + skippedTabIdReads += 1 + return 'terminal-b' + } + }) + const tabs = [skippedTab, terminalTab('terminal-a', null)] + const cache = new PendingTerminalHandleRecoveryContextCache() + + const contextKey = cache.read(tabs, 'terminal-a') + const readsAfterFirstLookup = skippedTabIdReads + expect(readsAfterFirstLookup).toBeGreaterThan(0) + expect(cache.read(tabs, 'terminal-a')).toBe(contextKey) + expect(skippedTabIdReads).toBe(readsAfterFirstLookup) + }) +}) diff --git a/mobile/src/session/pending-terminal-handle-recovery-poll.test.ts b/mobile/src/session/pending-terminal-handle-recovery-poll.test.ts new file mode 100644 index 00000000000..d5b1df752fb --- /dev/null +++ b/mobile/src/session/pending-terminal-handle-recovery-poll.test.ts @@ -0,0 +1,371 @@ +import { createElement, Suspense } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health' +import { PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS } from './pending-terminal-handle-recovery' +import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation' + +type TestResult = { + type?: 'snapshot' | 'updated' + snapshotVersion: number + tabs: string[] +} + +type HarnessProps = { + onParked?: (key: string | null) => void + suspend?: Promise +} + +const lifecycle = vi.hoisted(() => ({ + appState: 'active', + focused: true, + listeners: new Set<(state: string) => void>() +})) + +vi.mock('react-native', () => ({ + AppState: { + get currentState() { + return lifecycle.appState + }, + addEventListener(_event: string, listener: (state: string) => void) { + lifecycle.listeners.add(listener) + return { remove: () => lifecycle.listeners.delete(listener) } + } + } +})) + +vi.mock('expo-router', async () => { + const React = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)): void { + React.useEffect(() => (lifecycle.focused ? effect() : undefined), [effect, lifecycle.focused]) + } + } +}) + +function result(type?: TestResult['type']): TestResult { + return { snapshotVersion: 1, tabs: ['pending-terminal'], ...(type ? { type } : {}) } +} + +function makeHarness() { + const requestTimes: number[] = [] + const parkedContexts: (string | null)[] = [] + let contextKey: string | null = 'terminal-a' + let otherRecoveryNeeded = false + let connState: 'connected' | 'disconnected' = 'connected' + let streamListener: ((payload: unknown) => void) | null = null + let actions: ReturnType> | null = + null + const sendRequest = vi.fn(async (): Promise => { + requestTimes.push(Date.now()) + return { + id: `list-${requestTimes.length}`, + ok: true, + result: result(), + _meta: { runtimeId: 'runtime-1' } + } + }) + const subscribe = vi.fn( + (_method: string, _params: unknown, listener: (payload: unknown) => void) => { + streamListener = listener + return () => {} + } + ) + const client = { sendRequest, subscribe } as unknown as RpcClient + const consumeAcceptedSessionTabs = () => {} + const fetchTerminals = async () => {} + const getPendingTerminalRecoveryContextKey = () => contextKey + const hasRecoveryNeed = () => otherRecoveryNeeded + const onPendingTerminalRecoveryParked = (key: string | null) => parkedContexts.push(key) + const applySessionTabs = (value: TestResult): SessionTabsApplyOutcome => ({ + accepted: true, + effectiveTabs: value.tabs + }) + + function Harness({ onParked = onPendingTerminalRecoveryParked, suspend }: HarnessProps): null { + actions = useMobileSessionTabsReconciliation({ + client, + connState, + worktreeId: 'repo::worktree', + applySessionTabs, + consumeAcceptedSessionTabs, + fetchTerminals, + hasRecoveryNeed, + pendingTerminalRecoveryContextKey: contextKey, + getPendingTerminalRecoveryContextKey, + onPendingTerminalRecoveryParked: onParked + }) + if (suspend) { + throw suspend + } + return null + } + + return { + Harness, + actions: () => actions, + emit(payload: TestResult) { + streamListener?.(payload) + }, + parkedContexts, + requestTimes, + sendRequest, + setConnectionState(value: typeof connState) { + connState = value + }, + setContextKey(value: string | null) { + contextKey = value + }, + setOtherRecoveryNeeded(value: boolean) { + otherRecoveryNeeded = value + } + } +} + +async function flush(): Promise { + for (let turn = 0; turn < 8; turn += 1) { + await Promise.resolve() + } +} + +// react-test-renderer's types do not resolve in the type-aware lint project, so both +// ReactTestRenderer and ReturnType degrade to `any` and poison this union. +// Name the two methods this suite actually uses instead. +type MountedHarness = { + unmount: () => void + update: (element: ReturnType) => void +} + +describe('bounded pending-handle reconciliation cadence', () => { + let renderer: MountedHarness | null = null + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + lifecycle.appState = 'active' + lifecycle.focused = true + lifecycle.listeners.clear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + async function mount(harness: ReturnType): Promise { + await act(async () => { + renderer = create(createElement(harness.Harness)) + await flush() + }) + await act(async () => { + harness.emit(result('snapshot')) + await flush() + harness.emit(result('updated')) + await flush() + }) + harness.sendRequest.mockClear() + harness.requestTimes.length = 0 + harness.parkedContexts.length = 0 + } + + async function advance(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds) + }) + } + + async function setAppState(state: string): Promise { + lifecycle.appState = state + await act(async () => { + for (const listener of lifecycle.listeners) { + listener(state) + } + await flush() + }) + } + + it('drops the main baseline from 1800 lists per hour to five, then parks', async () => { + const harness = makeHarness() + await mount(harness) + + await advance(3_600_000) + + expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000]) + expect(harness.parkedContexts).toContain('terminal-a') + }) + + it('resets for active-tab and pending-terminal context changes, but not ordinary ticks', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + + harness.setContextKey(null) + await advance(2000) + const afterActiveTabChange = harness.requestTimes.length + harness.setContextKey('terminal-a') + await advance(2000) + expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 1) + + harness.setContextKey('terminal-b') + await advance(2000) + expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 2) + }) + + it('resets when the active tab changes away and back between poll ticks', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + + harness.setContextKey(null) + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + expect(harness.parkedContexts.at(-1)).toBeNull() + harness.setContextKey('terminal-a') + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + expect(harness.parkedContexts.at(-1)).toBeNull() + await advance(2000) + + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1) + }) + + it('keeps the parked state across unrelated rerenders', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + expect(harness.parkedContexts.at(-1)).toBe('terminal-a') + + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + + expect(harness.parkedContexts.at(-1)).toBe('terminal-a') + await advance(2000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + }) + + it('does not publish a parked callback from a suspended render', async () => { + const harness = makeHarness() + const committedCallback = vi.fn() + const suspendedCallback = vi.fn() + const neverCommits = new Promise(() => {}) + await act(async () => { + renderer = create( + createElement( + Suspense, + { fallback: null }, + createElement(harness.Harness, { onParked: committedCallback }) + ) + ) + await flush() + }) + const committedActions = harness.actions() + committedCallback.mockClear() + + await act(async () => { + renderer?.update( + createElement( + Suspense, + { fallback: null }, + createElement(harness.Harness, { + onParked: suspendedCallback, + suspend: neverCommits + }) + ) + ) + await flush() + }) + await act(async () => { + await committedActions?.retryPendingTerminalRecovery() + }) + + expect(committedCallback).toHaveBeenCalledWith(null) + expect(suspendedCallback).not.toHaveBeenCalled() + }) + + it('publishes the committed parked callback before a same-commit context reset', async () => { + const harness = makeHarness() + const previousCallback = vi.fn() + const committedCallback = vi.fn() + await act(async () => { + renderer = create(createElement(harness.Harness, { onParked: previousCallback })) + await flush() + }) + previousCallback.mockClear() + + harness.setContextKey('terminal-b') + await act(async () => { + renderer?.update(createElement(harness.Harness, { onParked: committedCallback })) + await flush() + }) + + expect(committedCallback).toHaveBeenCalledWith(null) + expect(previousCallback).not.toHaveBeenCalled() + }) + + it('keeps other recovery sources live while the pending-terminal budget is parked', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000]) + + harness.setOtherRecoveryNeeded(true) + await advance(14_000) + expect(harness.requestTimes).toEqual([ + 2000, 4000, 6000, 8000, 10_000, 14_000, 16_000, 18_000, 20_000, 22_000, 24_000, 26_000 + ]) + + harness.setOtherRecoveryNeeded(false) + await advance(2000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 7) + }) + + it('resets after foregrounding, reconnecting, and explicit retry', async () => { + const harness = makeHarness() + await mount(harness) + await advance(12_000) + + await setAppState('background') + await setAppState('active') + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1) + await advance(2000) + expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2) + await advance(10_000) + + harness.setConnectionState('disconnected') + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + harness.setConnectionState('connected') + await act(async () => { + renderer?.update(createElement(harness.Harness)) + await flush() + }) + await act(async () => { + harness.emit(result('updated')) + await flush() + }) + const afterReconnect = harness.requestTimes.length + await advance(2000) + expect(harness.requestTimes).toHaveLength(afterReconnect + 1) + + await advance(10_000) + const beforeRetry = harness.requestTimes.length + await act(async () => { + await harness.actions()?.retryPendingTerminalRecovery() + }) + expect(harness.requestTimes).toHaveLength(beforeRetry + 1) + await advance(2000) + expect(harness.requestTimes).toHaveLength(beforeRetry + 2) + }) +}) diff --git a/mobile/src/session/pending-terminal-handle-recovery.test.ts b/mobile/src/session/pending-terminal-handle-recovery.test.ts index 1945cb72114..3fcb73e9c11 100644 --- a/mobile/src/session/pending-terminal-handle-recovery.test.ts +++ b/mobile/src/session/pending-terminal-handle-recovery.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types' -import { hasPendingTerminalHandleRecoveryNeed } from './pending-terminal-handle-recovery' +import { + getPendingTerminalHandleRecoveryContextKey, + hasPendingTerminalHandleRecoveryNeed, + PendingTerminalHandleRecoveryBudget, + PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS +} from './pending-terminal-handle-recovery' import { MobileSessionTabsStreamHealth, type SessionTabsApplyOutcome @@ -62,6 +67,38 @@ describe('hasPendingTerminalHandleRecoveryNeed', () => { expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], null)).toBe(false) expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], 'gone')).toBe(false) }) + + it('keeps recovery contexts distinct when tab identifiers contain separators', () => { + const first = { ...terminalTab(null), id: 'a:b', parentTabId: 'c' } + const second = { ...terminalTab(null), id: 'a', parentTabId: 'b:c' } + + expect(getPendingTerminalHandleRecoveryContextKey([first], first.id)).not.toBe( + getPendingTerminalHandleRecoveryContextKey([second], second.id) + ) + }) +}) + +describe('PendingTerminalHandleRecoveryBudget', () => { + it('parks after the bounded attempt window and resets for a new terminal', () => { + const budget = new PendingTerminalHandleRecoveryBudget() + for (let attempt = 1; attempt <= PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + expect(budget.take('terminal-a')).toEqual({ + allowed: true, + parked: false + }) + } + expect(budget.take('terminal-a')).toEqual({ allowed: false, parked: true }) + expect(budget.take('terminal-b')).toEqual({ allowed: true, parked: false }) + }) + + it('resets the current terminal budget explicitly', () => { + const budget = new PendingTerminalHandleRecoveryBudget() + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + budget.take('terminal-a') + } + budget.reset() + expect(budget.take('terminal-a')).toEqual({ allowed: true, parked: false }) + }) }) /** @@ -82,6 +119,7 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () => const client = { sendRequest, getGeneration: () => 1 } as unknown as RpcClient let tabs: MobileSessionTab[] = [] let activeTabId: string | null = null + const recoveryBudget = new PendingTerminalHandleRecoveryBudget() const controller = new MobileSessionTabsStreamHealth({ client, scope: 'id:repo::worktree', @@ -91,7 +129,9 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () => return { accepted: true, effectiveTabs: result.tabs } }, consumeAccepted: () => {}, - hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId) + hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId), + allowRecoveryPoll: () => + recoveryBudget.take(getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)).allowed }) return { controller, @@ -139,6 +179,88 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () => }) }) + it('parks the live poll after five pending-handle lists', async () => { + const harness = makeHarness() + await driveToLiveStream(harness, null) + + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + const request = harness.controller.poll() + expect(request).not.toBeNull() + await settle() + harness.resolveNext(snapshot(null)) + await request + } + + expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + expect(harness.controller.poll()).toBeNull() + await settle() + expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + }) + + it('does not spend live fallback attempts while a slow request is coalesced', async () => { + const harness = makeHarness() + await driveToLiveStream(harness, null) + + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) { + const request = harness.controller.poll() + expect(request).not.toBeNull() + for (let joinedTick = 0; joinedTick < 5; joinedTick += 1) { + expect(harness.controller.poll()).toBe(request) + } + expect(harness.sendRequest).toHaveBeenCalledTimes(attempt + 1) + harness.resolveNext(snapshot(null)) + await request + } + + expect(harness.controller.poll()).toBeNull() + expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS) + }) + + it.each(['probing', 'degraded'] as const)( + 'keeps polling a pending handle while stream health is %s', + async (health) => { + const harness = makeHarness() + harness.controller.setReconciliationActive(true) + const subscription = harness.controller.beginSubscription() + if (health === 'degraded') { + subscription.listener({ ...snapshot(null), type: 'end' }) + } + + for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2; attempt += 1) { + const request = harness.controller.poll() + expect(request).not.toBeNull() + await settle() + harness.resolveNext(snapshot(null)) + await request + } + + expect(harness.sendRequest).toHaveBeenCalledTimes( + PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2 + ) + } + ) + + it('starts a fresh retry and fences the half-open request result', async () => { + const harness = makeHarness() + await driveToLiveStream(harness, null) + + const halfOpen = harness.controller.poll() + expect(harness.sendRequest).toHaveBeenCalledTimes(1) + const retry = harness.controller.retryReconciliation() + expect(retry).not.toBe(halfOpen) + expect(harness.sendRequest).toHaveBeenCalledTimes(2) + expect(harness.controller.retryReconciliation()).toBe(retry) + expect(harness.sendRequest).toHaveBeenCalledTimes(2) + + harness.resolveNext(snapshot('stale-handle')) + await halfOpen + expect(harness.readTabs()[0]).toMatchObject({ terminal: null }) + + harness.resolveNext(snapshot('fresh-handle')) + await retry + expect(harness.readTabs()[0]).toMatchObject({ terminal: 'fresh-handle' }) + }) + it('stops polling once a fresh snapshot carries the handle', async () => { const harness = makeHarness() await driveToLiveStream(harness, null) diff --git a/mobile/src/session/pending-terminal-handle-recovery.ts b/mobile/src/session/pending-terminal-handle-recovery.ts index c3a2a850193..c0ec81a76fb 100644 --- a/mobile/src/session/pending-terminal-handle-recovery.ts +++ b/mobile/src/session/pending-terminal-handle-recovery.ts @@ -1,5 +1,74 @@ import type { MobileSessionTab } from './mobile-session-route-types' +export const PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS = 5 + +export type PendingTerminalHandleRecoveryAttempt = { + allowed: boolean + parked: boolean +} + +export class PendingTerminalHandleRecoveryBudget { + private contextKey: string | null = null + private remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + + observeContext(contextKey: string | null): void { + if (contextKey === this.contextKey) { + return + } + this.contextKey = contextKey + this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + } + + take(contextKey: string | null): PendingTerminalHandleRecoveryAttempt { + this.observeContext(contextKey) + if (contextKey === null) { + return { allowed: false, parked: false } + } + if (this.remaining === 0) { + return { allowed: false, parked: true } + } + this.remaining -= 1 + return { allowed: true, parked: false } + } + + reset(): void { + this.contextKey = null + this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + } +} + +export class PendingTerminalHandleRecoveryContextCache { + private activeTabId: string | null = null + private contextKey: string | null = null + private initialized = false + private tabs: readonly MobileSessionTab[] | null = null + + read(tabs: readonly MobileSessionTab[], activeTabId: string | null): string | null { + if (this.initialized && tabs === this.tabs && activeTabId === this.activeTabId) { + return this.contextKey + } + this.initialized = true + this.tabs = tabs + this.activeTabId = activeTabId + this.contextKey = getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId) + return this.contextKey + } +} + +export function getPendingTerminalHandleRecoveryContextKey( + tabs: readonly MobileSessionTab[], + activeTabId: string | null +): string | null { + if (activeTabId === null) { + return null + } + const active = tabs.find((tab) => tab.id === activeTabId) + if (active?.type !== 'terminal' || typeof active.terminal === 'string') { + return null + } + return JSON.stringify([active.id, active.parentTabId, active.leafId ?? null]) +} + /** * Whether the tab the user is looking at is a terminal the host published * without a PTY handle (`status: 'pending-handle'`). The session screen renders @@ -16,9 +85,5 @@ export function hasPendingTerminalHandleRecoveryNeed( tabs: readonly MobileSessionTab[], activeTabId: string | null ): boolean { - if (activeTabId === null) { - return false - } - const active = tabs.find((tab) => tab.id === activeTabId) - return active?.type === 'terminal' && typeof active.terminal !== 'string' + return getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId) !== null } diff --git a/mobile/src/session/use-mobile-session-tabs-reconciliation.ts b/mobile/src/session/use-mobile-session-tabs-reconciliation.ts index 504d6c26fd1..277fc77ac47 100644 --- a/mobile/src/session/use-mobile-session-tabs-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tabs-reconciliation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { AppState } from 'react-native' import { useFocusEffect } from 'expo-router' import type { RpcClient } from '../transport/rpc-client' @@ -8,6 +8,7 @@ import { type SessionTabsApplyOutcome, type SessionTabsStreamSource } from './mobile-session-tabs-stream-health' +import { PendingTerminalHandleRecoveryBudget } from './pending-terminal-handle-recovery' type Params = { client: RpcClient | null @@ -21,6 +22,9 @@ type Params = { ) => void fetchTerminals: () => Promise hasRecoveryNeed: () => boolean + pendingTerminalRecoveryContextKey?: string | null + getPendingTerminalRecoveryContextKey?: () => string | null + onPendingTerminalRecoveryParked?: (contextKey: string | null) => void getApplicationRevision?: () => number onFetchStarted?: () => void onFetchSucceeded?: (result: Result) => void @@ -32,6 +36,7 @@ type ResultActions = { fetchSessionTabs: () => Promise ensureSessionTabs: () => Promise fetchPendingBrowserSessionTabs: () => Promise + retryPendingTerminalRecovery: () => Promise } const resolved = Promise.resolve() @@ -44,12 +49,46 @@ export function useMobileSessionTabsReconciliation({ consumeAcceptedSessionTabs, fetchTerminals, hasRecoveryNeed, + pendingTerminalRecoveryContextKey, + getPendingTerminalRecoveryContextKey, + onPendingTerminalRecoveryParked, getApplicationRevision, onFetchStarted, onFetchSucceeded, onFetchFailed, onFetchErrored }: Params): ResultActions { + const pendingTerminalRecoveryBudget = useMemo(() => new PendingTerminalHandleRecoveryBudget(), []) + const onPendingTerminalRecoveryParkedRef = useRef(onPendingTerminalRecoveryParked) + // Why: only poll/reset callbacks read this, and they run after commit — so writing it in + // render would let a discarded render leak a callback that never mounted. + useEffect(() => { + onPendingTerminalRecoveryParkedRef.current = onPendingTerminalRecoveryParked + }) + const combinedHasRecoveryNeed = useCallback(() => { + const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null + pendingTerminalRecoveryBudget.observeContext(contextKey) + return hasRecoveryNeed() || contextKey !== null + }, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget]) + const allowRecoveryPoll = useCallback(() => { + if (hasRecoveryNeed()) { + return true + } + const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null + const attempt = pendingTerminalRecoveryBudget.take(contextKey) + if (attempt.parked) { + onPendingTerminalRecoveryParkedRef.current?.(contextKey) + } + return attempt.allowed + }, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget]) + const resetPendingTerminalRecovery = useCallback(() => { + pendingTerminalRecoveryBudget.reset() + onPendingTerminalRecoveryParkedRef.current?.(null) + }, [pendingTerminalRecoveryBudget]) + useEffect(() => { + pendingTerminalRecoveryBudget.observeContext(pendingTerminalRecoveryContextKey ?? null) + onPendingTerminalRecoveryParkedRef.current?.(null) + }, [pendingTerminalRecoveryBudget, pendingTerminalRecoveryContextKey]) const controller = useMemo( () => client @@ -58,7 +97,8 @@ export function useMobileSessionTabsReconciliation({ scope: `id:${worktreeId}`, apply: applySessionTabs, consumeAccepted: consumeAcceptedSessionTabs, - hasRecoveryNeed, + hasRecoveryNeed: combinedHasRecoveryNeed, + allowRecoveryPoll: getPendingTerminalRecoveryContextKey ? allowRecoveryPoll : undefined, getApplicationRevision, onFetchStarted, onFetchSucceeded, @@ -71,7 +111,9 @@ export function useMobileSessionTabsReconciliation({ client, consumeAcceptedSessionTabs, getApplicationRevision, - hasRecoveryNeed, + allowRecoveryPoll, + combinedHasRecoveryNeed, + getPendingTerminalRecoveryContextKey, onFetchErrored, onFetchFailed, onFetchStarted, @@ -91,6 +133,7 @@ export function useMobileSessionTabsReconciliation({ if (!client || !controller || connState !== 'connected') { return } + resetPendingTerminalRecovery() const subscription = controller.beginSubscription() const unsubscribe = client.subscribe( 'session.tabs.subscribe', @@ -101,7 +144,7 @@ export function useMobileSessionTabsReconciliation({ subscription.cancel() unsubscribe() } - }, [client, connState, controller, worktreeId]) + }, [client, connState, controller, resetPendingTerminalRecovery, worktreeId]) useFocusEffect( useCallback(() => { @@ -123,19 +166,21 @@ export function useMobileSessionTabsReconciliation({ } const appStateSubscription = AppState.addEventListener('change', (state) => { if (state === 'active') { + resetPendingTerminalRecovery() refresh(true) } else { controller.setReconciliationActive(false) } }) const interval = setInterval(() => refresh(false), 2000) + resetPendingTerminalRecovery() refresh(true) return () => { controller.setReconciliationActive(false) clearInterval(interval) appStateSubscription.remove() } - }, [connState, controller, fetchTerminals]) + }, [connState, controller, fetchTerminals, resetPendingTerminalRecovery]) ) return { @@ -150,6 +195,10 @@ export function useMobileSessionTabsReconciliation({ fetchPendingBrowserSessionTabs: useCallback( () => controller?.requestPendingRecovery() ?? resolved, [controller] - ) + ), + retryPendingTerminalRecovery: useCallback(() => { + resetPendingTerminalRecovery() + return controller?.retryReconciliation() ?? resolved + }, [controller, resetPendingTerminalRecovery]) } } From a324ee20d42db4e3090bf9bedca51584fc4e1524 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:08:10 -0700 Subject: [PATCH 07/28] Reset terminal SGR state around restored output (#14700) * fix(terminal): reset SGR around restored output * fix(terminal): preserve live replay styling * fix(terminal): ground dead reattach fallback --- mobile/src/terminal/terminal-webview-html.ts | 3 +- .../terminal-webview-init-surface.test.ts | 16 +- .../terminal-webview-text-zoom.test.ts | 2 +- .../terminal-history-seed-segments.test.ts | 15 +- .../daemon/terminal-history-seed-segments.ts | 15 +- .../terminal-mode-rehydrate-sequences.ts | 3 +- .../layout-serialization.test.ts | 6 +- .../terminal-pane/layout-serialization.ts | 15 +- ...nnection-cold-restore-agent-resume.test.ts | 26 ++- ...ty-connection-cold-restore-repaint.test.ts | 28 ++- ...-connection-daemon-snapshot-replay.test.ts | 43 +++- ...tion-deferred-reattach-live-output.test.ts | 3 +- ...pty-connection-parked-ssh-snapshot.test.ts | 7 +- ...pty-connection-reattach-mode-reset.test.ts | 4 + .../terminal-pane/pty-connection.ts | 25 ++- .../terminal-restore-sgr-latch.test.ts | 193 ++++++++++++++++++ .../terminal-mode-reset-profiles.test.ts | 11 +- src/shared/terminal-mode-reset-profiles.ts | 25 +-- 18 files changed, 374 insertions(+), 66 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index c3ba29bb542..8d79ecfab67 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -752,7 +752,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS} attachWebglAddon(true); if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} if (typeof replayData === 'string' && replayData.length > 0) { - enqueueWrite(replayData); + // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. + enqueueWrite(ESC + '[0m' + replayData); } // Why: reset eviction tracking + attach observers for the new term. diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts index fdef4f5b4df..b32d7e6858e 100644 --- a/mobile/src/terminal/terminal-webview-init-surface.test.ts +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -26,7 +26,7 @@ type RegisteredWindowListener = { type: string } -function makeTerminal(writeCallbacks: Array<() => void>) { +function makeTerminal(writeCallbacks: Array<() => void>, writes: string[]) { const terminal = { cols: 80, rows: 24, @@ -45,7 +45,8 @@ function makeTerminal(writeCallbacks: Array<() => void>) { getLine: () => null } }, - write(_data: string, callback?: () => void) { + write(data: string, callback?: () => void) { + writes.push(data) if (callback) { writeCallbacks.push(callback) } @@ -93,6 +94,7 @@ describe('terminal WebView init surface replacement', () => { let terminalOptions: TerminalOptions[] let terminals: TerminalStub[] let writeCallbacks: Array<() => void> + let writes: string[] beforeEach(() => { animationFrames = [] @@ -100,6 +102,7 @@ describe('terminal WebView init surface replacement', () => { terminalOptions = [] terminals = [] writeCallbacks = [] + writes = [] const addWindowEventListener = window.addEventListener.bind(window) vi.spyOn(window, 'addEventListener').mockImplementation((( type: string, @@ -121,7 +124,7 @@ describe('terminal WebView init surface replacement', () => { } webWindow.Terminal = function (options: TerminalOptions) { terminalOptions.push(options) - const terminal = makeTerminal(writeCallbacks) + const terminal = makeTerminal(writeCallbacks, writes) terminals.push(terminal) return terminal } as unknown as new (options: TerminalOptions) => TerminalStub @@ -153,6 +156,13 @@ describe('terminal WebView init surface replacement', () => { } }) + it('grounds the initial replay without clearing the host live pen', () => { + dispatchInit(80, '\x1b[1mBOLD-RUN-LEFT-OPEN') + animationFrames.shift()?.() + + expect(writes).toEqual(['\x1b[0m\x1b[1mBOLD-RUN-LEFT-OPEN']) + }) + it('commits only the newest surface when phone-fit init calls overlap', () => { // Why: restored terminals can receive desktop scrollback, a phone resize, // and phone scrollback before any xterm replay callback has completed. diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 09aeb82d3f7..cf16a93c0e6 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -144,7 +144,7 @@ describe('TerminalWebView text zoom', () => { expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") - const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)') + const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)") expect(open).toBeGreaterThanOrEqual(0) expect(unicode).toBeGreaterThan(open) expect(replay).toBeGreaterThan(unicode) diff --git a/src/main/daemon/terminal-history-seed-segments.test.ts b/src/main/daemon/terminal-history-seed-segments.test.ts index 7852a7eac89..ec022a94b8b 100644 --- a/src/main/daemon/terminal-history-seed-segments.test.ts +++ b/src/main/daemon/terminal-history-seed-segments.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { HeadlessEmulator } from './headless-emulator' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' -import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' +import { + COLD_RESTORE_SEED_MODE_RESET, + RESET_GRAPHIC_RENDITION +} from '../../shared/terminal-mode-reset-profiles' import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' import type { TerminalModes } from './types' @@ -38,7 +41,7 @@ describe('getRecoveredHistorySeedSegments', () => { restoreInfo({ pendingEscapeTailAnsi: '\x1b[3' }) ) expect(segments).toEqual([ - '\x1b[?1003h\x1b[?1006h', + `${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h`, 'user@host ~ $ \x1b[?1003h', MOUSE_OFF, '\x1b[3' @@ -50,7 +53,7 @@ describe('getRecoveredHistorySeedSegments', () => { getRecoveredHistorySeedSegments( restoreInfo({ modes: { ...ARMED_MODES, alternateScreen: true } }) ) - ).toEqual(['user@host ~ $ ', MOUSE_OFF]) + ).toEqual([`${RESET_GRAPHIC_RENDITION}user@host ~ $ `, MOUSE_OFF]) }) it('stays empty when there is no recovered normal buffer', () => { @@ -116,4 +119,10 @@ describe('getRecoveredHistorySeedSegments', () => { // snapshots must keep re-arming or an alt-screen TUI loses scroll forever. expect(buildRehydrateSequences(ARMED_MODES)).toBe('\x1b[?1003h\x1b[?1006h') }) + + it('grounds the pen before re-entering the alternate screen', () => { + expect(buildRehydrateSequences({ ...ARMED_MODES, alternateScreen: true })).toBe( + `${RESET_GRAPHIC_RENDITION}\x1b[?1049h\x1b[?1003h\x1b[?1006h` + ) + }) }) diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts index 670cef152b8..09cd94e8348 100644 --- a/src/main/daemon/terminal-history-seed-segments.ts +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -1,5 +1,8 @@ import type { ColdRestoreInfo } from './terminal-history-cold-restore-info' -import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-profiles' +import { + COLD_RESTORE_SEED_MODE_RESET, + RESET_GRAPHIC_RENDITION +} from '../../shared/terminal-mode-reset-profiles' // Why the reset belongs in the seed and not only at replay: the recovered stream // re-arms mouse reporting from two independent sources (rehydrateSequences AND @@ -12,7 +15,9 @@ import { COLD_RESTORE_SEED_MODE_RESET } from '../../shared/terminal-mode-reset-p export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): readonly string[] { if (restoreInfo.modes.alternateScreen) { const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi - return normalBuffer ? [normalBuffer, COLD_RESTORE_SEED_MODE_RESET] : [] + return normalBuffer + ? [`${RESET_GRAPHIC_RENDITION}${normalBuffer}`, COLD_RESTORE_SEED_MODE_RESET] + : [] } const recovered = [restoreInfo.rehydrateSequences, restoreInfo.snapshotAnsi].filter( (segment) => segment.length > 0 @@ -25,5 +30,9 @@ export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): r } // Why after the snapshot: it must undo the snapshot's own mode trailer, and // pendingEscapeTailAnsi is a torn escape that has to stay at the very end. - return [...recovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])] + const [firstRecovered, ...remainingRecovered] = recovered + const groundedRecovered = firstRecovered + ? [`${RESET_GRAPHIC_RENDITION}${firstRecovered}`, ...remainingRecovered] + : [] + return [...groundedRecovered, COLD_RESTORE_SEED_MODE_RESET, ...(escapeTail ? [escapeTail] : [])] } diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index 77e3603d4f5..ac4e34897b6 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -1,4 +1,5 @@ import type { TerminalModes } from './types' +import { RESET_GRAPHIC_RENDITION } from '../../shared/terminal-mode-reset-profiles' // Why no kitty flags here: rehydrateSequences feeds renderer xterms, and // POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C @@ -10,7 +11,7 @@ export function buildRehydrateSequences(modes: TerminalModes): string { if (modes.alternateScreen) { // Why: normal-buffer serialization can leave its pen active, while the // separately serialized alt body assumes it starts from default SGR. - seqs.push('\x1b[0m\x1b[?1049h') + seqs.push(`${RESET_GRAPHIC_RENDITION}\x1b[?1049h`) } if (modes.bracketedPaste) { seqs.push('\x1b[?2004h') 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 597d5e54b58..8f6915f07a6 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -37,6 +37,7 @@ import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, replayPayloadEndsWithCursorHidden, + RESET_GRAPHIC_RENDITION, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' @@ -456,7 +457,10 @@ describe('restoreScrollbackBuffers', () => { restoredViewportBlankingPanesRef ) - expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET]) + expect(writes).toEqual([ + `${RESET_GRAPHIC_RENDITION}restored output${RESET_GRAPHIC_RENDITION}\r\n`, + POST_REPLAY_MODE_RESET + ]) expect(manager.hasWebglRenderer).toHaveBeenCalledWith(1) expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true) expect(replayingPanesRef.current.size).toBe(0) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index c3807cffeac..a9be5af9817 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -4,7 +4,10 @@ import type { TerminalPaneSplitDirection } from '../../../../shared/terminal-tab-types' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' -import { POST_REPLAY_MODE_RESET } from '../../../../shared/terminal-mode-reset-profiles' +import { + POST_REPLAY_MODE_RESET, + RESET_GRAPHIC_RENDITION +} from '../../../../shared/terminal-mode-reset-profiles' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' @@ -179,9 +182,13 @@ export function restoreScrollbackBuffers( } if (buf.length > 0) { // replayIntoTerminal: buffer queries (DA1/DECRQM/CPR) would auto-reply into the new shell's stdin. See replay-guard.ts. - replayIntoTerminal(pane, replayingPanesRef, buf, renderOptions) - // Newline first so the new shell prompt doesn't trigger zsh's PROMPT_EOL_MARK (%) indicator. - replayIntoTerminal(pane, replayingPanesRef, '\r\n', renderOptions) + replayIntoTerminal( + pane, + replayingPanesRef, + `${RESET_GRAPHIC_RENDITION}${buf}${RESET_GRAPHIC_RENDITION}\r\n`, + renderOptions + ) + // The grounded newline avoids both the prompt marker and background-color erase from the captured pen. // Clear mode bits the buffer replayed: the fresh shell has no TUI to consume them. See POST_REPLAY_MODE_RESET. replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET, renderOptions) // Why: connection resolution runs after layout replay; only fresh-shell paths move these rows into scrollback. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index debbd7f46be..8e29afb8763 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -1,6 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' import { flushAsyncTicks } from './pty-connection-test-async' import { UUID_RE } from './pty-connection-test-constants' import { @@ -195,7 +196,10 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) await new Promise((resolve) => setTimeout(resolve, 70)) - expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('--- session restored ---'), expect.any(Function) @@ -286,7 +290,10 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) await new Promise((resolve) => setTimeout(resolve, 70)) - expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) expect(transport.sendInput).not.toHaveBeenCalled() expect(transport.connect).toHaveBeenCalledWith( expect.objectContaining({ @@ -361,7 +368,10 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) await new Promise((resolve) => setTimeout(resolve, 70)) - expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('--- session restored ---'), expect.any(Function) @@ -512,7 +522,10 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) await new Promise((resolve) => setTimeout(resolve, 70)) - expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored') expect(transport.sendInput).not.toHaveBeenCalled() expect(transport.connect).toHaveBeenCalledWith( @@ -589,7 +602,10 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) await new Promise((resolve) => setTimeout(resolve, 70)) - expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}cold-payload`, + expect.any(Function) + ) expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() expect(transport.connect).not.toHaveBeenCalledWith( expect.objectContaining({ command: expect.stringContaining('resume') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts index 38644059cc3..a1c71fdbce4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts @@ -1,6 +1,9 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { POST_REPLAY_MODE_RESET } from '../../../../shared/terminal-mode-reset-profiles' +import { + POST_REPLAY_MODE_RESET, + RESET_GRAPHIC_RENDITION +} from '../../../../shared/terminal-mode-reset-profiles' import { Terminal } from '@xterm/headless' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { @@ -180,7 +183,10 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) - expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-payload', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}snapshot-payload`, + expect.any(Function) + ) expect(pane.terminal.write).not.toHaveBeenCalledWith('replay-payload', expect.any(Function)) }) @@ -215,7 +221,10 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) - expect(pane.terminal.write).toHaveBeenCalledWith('replay-payload', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}replay-payload`, + expect.any(Function) + ) expect(pane.terminal.write).not.toHaveBeenCalledWith('cold-payload', expect.any(Function)) // Why: the replay branch supersedes cold-restore but must still ack, or the daemon redelivers the cold-restore payload next reattach. expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty') @@ -286,7 +295,8 @@ describe('connectPanePty', () => { const recoveredCols = 20 const recoveredRows = 3 const coldScrollback = '\x1b[1;1HCOLD\x1b[1;15HEND\r\nCOLD_SOURCE_ROW_02' - const viewportClear = '\x1b[2J\x1b[H' + const groundedColdScrollback = `${RESET_GRAPHIC_RENDITION}${coldScrollback}` + const viewportClear = `${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H` transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -362,10 +372,10 @@ describe('connectPanePty', () => { expect(written).toContain(viewportClear) expect(written).not.toContain(NORMAL_BUFFER_PROLOGUE) expect(written).toEqual( - expect.arrayContaining([coldScrollback, POST_REPLAY_MODE_RESET, blankViewport]) + expect.arrayContaining([groundedColdScrollback, POST_REPLAY_MODE_RESET, blankViewport]) ) - expect(written.indexOf(viewportClear)).toBeLessThan(written.indexOf(coldScrollback)) - expect(written.indexOf(coldScrollback)).toBeLessThan(written.indexOf(blankViewport)) + expect(written.indexOf(viewportClear)).toBeLessThan(written.indexOf(groundedColdScrollback)) + expect(written.indexOf(groundedColdScrollback)).toBeLessThan(written.indexOf(blankViewport)) const viewportClearOperation = operations.findIndex( (operation) => operation.kind === 'write' && operation.data === viewportClear ) @@ -388,6 +398,7 @@ describe('connectPanePty', () => { rendered, 'KEEP_1\r\nKEEP_2\r\nOLD_ROW_1\r\nOLD_ROW_2\r\nOLD_ROW_3\r\nOLD_ROW_4\r\nOLD_ROW_5' ) + await writeHeadlessTerminal(rendered, '\x1b[44m') let replayedAtRecoveredGrid = false let sourceGridLines: string[] = [] for (const operation of operations) { @@ -410,6 +421,9 @@ describe('connectPanePty', () => { } } else { await writeHeadlessTerminal(rendered, operation.data) + if (operation.data === viewportClear) { + expect(rendered.buffer.active.getLine(0)?.getCell(0)?.getBgColor()).toBe(-1) + } } } if (sourceGridLines.length === 0) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts index 668f50b99ab..0e130d0b00e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts @@ -2,9 +2,11 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { POST_REPLAY_MODE_RESET, - POST_REPLAY_REATTACH_RESET + POST_REPLAY_REATTACH_RESET, + RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' -import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { Terminal } from '@xterm/headless' +import { flushAsyncTicks, createDeferred, writeHeadlessTerminal } from './pty-connection-test-async' import { createRect } from './pty-connection-test-dom' import { LEAF_1, @@ -149,12 +151,13 @@ describe('connectPanePty', () => { await restoreTerminalTestGlobals() }) - it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { + it('clears the captured pen for a normal-buffer fallback reattach', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') + const snapshot = 'ORCA-SGR-REPRO \x1b[1mBOLD-RUN-LEFT-OPEN\x1b[1;34H' transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { - return { id: sessionId, snapshot: '\x1b[?1004hrestored snapshot' } + return { id: sessionId, snapshot } } return null }) @@ -179,9 +182,12 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[?1004hrestored snapshot', + `${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`, + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_GRAPHIC_RENDITION}${snapshot}`, expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( @@ -192,6 +198,26 @@ describe('connectPanePty', () => { POST_REPLAY_MODE_RESET, expect.any(Function) ) + + const rendered = new Terminal({ cols: 40, rows: 6, allowProposedApi: true }) + try { + await writeHeadlessTerminal(rendered, '\x1b[1;44mDIRTY') + for (const [data] of pane.terminal.write.mock.calls) { + if (data) { + await writeHeadlessTerminal(rendered, data) + } + } + await writeHeadlessTerminal(rendered, 'PLAIN') + const line = rendered.buffer.active.getLine(rendered.buffer.active.baseY) + const plainColumn = line?.translateToString(true).indexOf('PLAIN') ?? -1 + + expect(line?.getCell(plainColumn)?.isBold()).toBe(0) + expect(line?.getCell(plainColumn)?.getFgColor()).toBe(-1) + expect(line?.getCell(plainColumn)?.getBgColor()).toBe(-1) + expect(rendered.buffer.active.getLine(5)?.getCell(39)?.getBgColor()).toBe(-1) + } finally { + rendered.dispose() + } }) it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { @@ -397,6 +423,7 @@ describe('connectPanePty', () => { expect(writes.join('')).toContain('PREFIX-SCROLLBACK') expect(writes.join('')).toContain('RESTORE-LIVE-STATE') expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}PREFIX-SCROLLBACKRESTORE-LIVE-STATE`) expect(writes).toContain(POST_REPLAY_MODE_RESET) }) @@ -443,7 +470,9 @@ describe('connectPanePty', () => { } ) const snapshotWriteCall = pane.terminal.write.mock.invocationCallOrder.find( - (_order, index) => pane.terminal.write.mock.calls[index][0] === '\x1b[?1004hrestored snapshot' + (_order, index) => + pane.terminal.write.mock.calls[index][0] === + `${RESET_GRAPHIC_RENDITION}\x1b[?1004hrestored snapshot` ) expect(resizeToSnapshotCall).toBeDefined() expect(snapshotWriteCall).toBeDefined() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts index c3dd399431e..fa110cdfe0e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts @@ -1,6 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' import { flushAsyncTicks } from './pty-connection-test-async' import { LEAF_1, @@ -260,7 +261,7 @@ describe('connectPanePty', () => { connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) - const snapshotIndex = writes.indexOf('authoritative-snapshot') + const snapshotIndex = writes.indexOf(`${RESET_GRAPHIC_RENDITION}authoritative-snapshot`) expect(snapshotIndex).toBeGreaterThanOrEqual(0) expect(writes).not.toContain('post-snapshot-live') for (let step = 0; step < 40; step += 1) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts index 98deb8897f0..39191d5d36a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts @@ -1,6 +1,9 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles' +import { + POST_REPLAY_REATTACH_RESET, + RESET_GRAPHIC_RENDITION +} from '../../../../shared/terminal-mode-reset-profiles' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { SshConnectionState } from '../../../../shared/ssh-types' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' @@ -204,7 +207,7 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'leaf-session') expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'leaf-session') // Why: the relay's replay buffer holds full history, so clear xterm before writing to avoid duplicating prior-session content. - expect(writes).toContain('\x1b[2J\x1b[3J\x1b[H') + expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) expect(writes).toContain('restored-ssh-output') expect(writes).toContain(POST_REPLAY_REATTACH_RESET) expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index e49ec7d52e5..bf4d4a34feb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -5,6 +5,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + RESET_GRAPHIC_RENDITION, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' @@ -415,6 +416,9 @@ describe('connectPanePty', () => { const writes = (pane.terminal.write as ReturnType).mock.calls.map( ([data]) => data as string ) + expect(writes).toContain( + `${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ` + ) expect(writes).toContain(POST_REPLAY_MODE_RESET) expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 6aa84444817..397d5bf84a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -125,6 +125,7 @@ import { POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, RESET_AFTER_BYTE_GAP, + RESET_GRAPHIC_RENDITION, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' @@ -8272,7 +8273,7 @@ export function connectPanePty( suppressStructuralReplayPtyResize = false } } - writeReplayData('\x1b[2J\x1b[3J\x1b[H') + writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. applySnapshotKittyKeyboardModes(daemonSnapshotReplay, { kittyKeyboardFlags: connectResult.snapshotKittyKeyboardFlags, @@ -8288,10 +8289,16 @@ export function connectPanePty( connectResult.snapshotCols, readProposedTerminalCols(pane) ) + const groundDaemonSnapshot = + Boolean(connectResult.coldRestore) || + (!shouldPreserveAgentReattachModes() && + !(connectResult.isAlternateScreen ?? kittyKeyboardModes.isAlternateScreen)) writeReplayData( - daemonAltFrameSkippable - ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi - : daemonSnapshotReplay + `${groundDaemonSnapshot ? RESET_GRAPHIC_RENDITION : ''}${ + daemonAltFrameSkippable + ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi + : daemonSnapshotReplay + }` ) writeReplayData( reattachReplayResetSequence( @@ -8382,7 +8389,7 @@ export function connectPanePty( } else if (connectResult?.replay) { rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) // Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') + writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) // Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack. // A constructor-fresh mirror (window reload) first demotes to unproven: // the replay window proves nothing about negotiations that predate it. @@ -8390,7 +8397,9 @@ export function connectPanePty( kittyKeyboardModes.resetForSnapshot() } kittyKeyboardModes.scanReplay(connectResult.replay) - writeReplayData(connectResult.replay) + writeReplayData( + `${connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${connectResult.replay}` + ) writeReplayData( reattachReplayResetSequence( connectResult.replay, @@ -8420,7 +8429,7 @@ export function connectPanePty( // The current xterm grid remains a safe lower bound for blanking. } // Why: shrinking first would promote clipped stale viewport rows into scrollback, beyond the reach of a later viewport-only clear. - writeReplayData('\x1b[2J\x1b[H') + writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H`) await waitForTerminalReplayWritesParsed(pane.terminal) if (!isCurrentReattachPayload()) { return @@ -8443,7 +8452,7 @@ export function connectPanePty( } } // Why: recorded scrollback is raw PTY output that may hold query sequences; xterm.write would auto-reply into the new shell's stdin. See replay-guard.ts. - writeReplayData(connectResult.coldRestore.scrollback) + writeReplayData(`${RESET_GRAPHIC_RENDITION}${connectResult.coldRestore.scrollback}`) const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) if (didPrepareResume) { diff --git a/src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts b/src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts new file mode 100644 index 00000000000..63c6bc72c2f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts @@ -0,0 +1,193 @@ +import { SerializeAddon } from '@xterm/addon-serialize' +import { Terminal } from '@xterm/headless' +import { afterEach, describe, expect, it } from 'vitest' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' +import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + POST_REPLAY_REATTACH_RESET_KEEP_MOUSE +} from '../../../../shared/terminal-mode-reset-profiles' +import { restoreScrollbackBuffers } from './layout-serialization' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const UNCLOSED_BOLD_FIXTURE = 'ORCA-SGR-REPRO \x1b[1mBOLD-RUN-LEFT-OPEN\x1b[1;34H' +const terminals: Terminal[] = [] + +function createTerminal(): Terminal { + const terminal = new Terminal({ cols: 40, rows: 6, scrollback: 20, allowProposedApi: true }) + terminals.push(terminal) + return terminal +} + +function writeTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function boldAtText(terminal: Terminal, text: string): number { + const buffer = terminal.buffer.normal + for (let row = 0; row < buffer.length; row += 1) { + const line = buffer.getLine(row) + const column = line?.translateToString(true).indexOf(text) ?? -1 + if (line && column >= 0) { + return line.getCell(column)?.isBold() ?? 0 + } + } + throw new Error(`Missing terminal text: ${text}`) +} + +function foregroundAtText(terminal: Terminal, text: string): number { + const buffer = terminal.buffer.normal + for (let row = 0; row < buffer.length; row += 1) { + const line = buffer.getLine(row) + const column = line?.translateToString(true).indexOf(text) ?? -1 + if (line && column >= 0) { + return line.getCell(column)?.getFgColor() ?? -1 + } + } + throw new Error(`Missing terminal text: ${text}`) +} + +async function restoreBuffer( + buffer: string, + options: { initialState?: string; followingOutput?: string } = {} +): Promise { + const terminal = createTerminal() + if (options.initialState) { + await writeTerminal(terminal, options.initialState) + } + const pane = { id: 1, terminal } + const manager = { + getPanes: () => [pane], + hasWebglRenderer: () => true + } + restoreScrollbackBuffers( + manager as unknown as Parameters[0], + { [LEAF_ID]: buffer }, + new Map([[LEAF_ID, pane.id]]), + { current: new Map() } + ) + await writeTerminal(terminal, options.followingOutput ?? 'fresh-shell') + return terminal +} + +function serialize(data: string): { terminal: Terminal; addon: SerializeAddon; data: string } { + const terminal = createTerminal() + const addon = new SerializeAddon() + terminal.loadAddon(addon) + return { terminal, addon, data } +} + +afterEach(() => { + for (const terminal of terminals.splice(0)) { + terminal.dispose() + } +}) + +describe('fresh-shell terminal restore SGR state', () => { + it('grounds the pen before replaying normal-buffer cells', async () => { + const restored = await restoreBuffer('plain-history', { initialState: '\x1b[1m' }) + + expect(boldAtText(restored, 'plain-history')).toBe(0) + }) + + it('clears an unclosed bold run before fresh shell output', async () => { + const restored = await restoreBuffer('\x1b[1mBOLD') + + expect(boldAtText(restored, 'BOLD')).not.toBe(0) + expect(boldAtText(restored, 'fresh-shell')).toBe(0) + }) + + it('grounds the erase attributes before the restored newline scrolls', async () => { + const restored = await restoreBuffer('\x1b[6;1H\x1b[41mX', { followingOutput: '' }) + const buffer = restored.buffer.active + const bottomLine = buffer.getLine(buffer.baseY + restored.rows - 1) + + expect(bottomLine?.getCell(20)?.getBgColor()).toBe(-1) + }) + + it('clears the serialized live pen before fresh shell output', async () => { + const source = serialize('\x1b[1mBOLD') + await writeTerminal(source.terminal, source.data) + + const restored = await restoreBuffer(source.addon.serialize()) + + expect(boldAtText(restored, 'BOLD')).not.toBe(0) + expect(boldAtText(restored, 'fresh-shell')).toBe(0) + }) + + it('clears the captured pen after normal-buffer daemon reattach', async () => { + const terminal = createTerminal() + await writeTerminal(terminal, UNCLOSED_BOLD_FIXTURE) + await writeTerminal(terminal, POST_REPLAY_REATTACH_RESET) + await writeTerminal(terminal, 'PLAIN-TEXT-NO-SGR-WHATSOEVER') + + expect(boldAtText(terminal, 'BOLD-RUN-LEFT-OPEN')).not.toBe(0) + expect(boldAtText(terminal, 'PLAIN')).toBe(0) + }) + + it.each([ + ['live agent', POST_REPLAY_LIVE_AGENT_REATTACH_RESET], + ['alternate-screen TUI', POST_REPLAY_REATTACH_RESET_KEEP_MOUSE] + ])('preserves a %s pen across daemon reattach', async (_kind, reset) => { + const terminal = createTerminal() + await writeTerminal(terminal, 'ORCA-SGR-REPRO \x1b[1;34mBOLD-RUN-LEFT-OPEN\x1b[1;34H') + await writeTerminal(terminal, reset) + await writeTerminal(terminal, 'LIVE-CONTINUATION') + + expect(boldAtText(terminal, 'BOLD-RUN-LEFT-OPEN')).not.toBe(0) + expect(boldAtText(terminal, 'LIVE')).not.toBe(0) + expect(foregroundAtText(terminal, 'LIVE')).toBe(4) + }) + + it('clears the captured pen and saved cursor for a fresh shell', async () => { + const terminal = createTerminal() + await writeTerminal(terminal, '\x1b[1mBOLD-RUN-LEFT-OPEN\x1b7') + await writeTerminal(terminal, POST_REPLAY_MODE_RESET) + await writeTerminal(terminal, '\x1b8PLAIN') + + expect(boldAtText(terminal, 'PLAIN')).toBe(0) + expect(foregroundAtText(terminal, 'PLAIN')).toBe(-1) + }) + + it('keeps the synthetic saved-cursor register from restoring bold', async () => { + const source = serialize('\x1b[1mBOLD') + await writeTerminal(source.terminal, source.data) + const snapshot = serializeWithAbsoluteCursor(source.addon, source.terminal, undefined, { + x: 10, + y: 0, + originMode: false + }) + + const restored = await restoreBuffer(snapshot, { followingOutput: '\x1b8after-restore' }) + + expect(boldAtText(restored, 'after-restore')).toBe(0) + }) + + it('grounds the synthetic saved cursor after normal-buffer daemon reattach', async () => { + const source = serialize('\x1b[1mBOLD') + await writeTerminal(source.terminal, source.data) + const snapshot = serializeWithAbsoluteCursor(source.addon, source.terminal, undefined, { + x: 10, + y: 0, + originMode: false + }) + const restored = createTerminal() + + await writeTerminal(restored, snapshot) + await writeTerminal(restored, POST_REPLAY_REATTACH_RESET) + await writeTerminal(restored, '\x1b8after-reattach') + + expect(boldAtText(restored, 'after-reattach')).toBe(0) + }) + + it('restores alt-screen scrollback without leaking its bold frame', async () => { + const source = serialize('shell-history\x1b[?1049h\x1b[1mBOLD-TUI') + await writeTerminal(source.terminal, source.data) + + const restored = await restoreBuffer(source.addon.serialize()) + + expect(boldAtText(restored, 'shell-history')).toBe(0) + expect(boldAtText(restored, 'fresh-shell')).toBe(0) + }) +}) diff --git a/src/shared/terminal-mode-reset-profiles.test.ts b/src/shared/terminal-mode-reset-profiles.test.ts index cef6d77a03b..4026be7edcd 100644 --- a/src/shared/terminal-mode-reset-profiles.test.ts +++ b/src/shared/terminal-mode-reset-profiles.test.ts @@ -7,6 +7,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + RESET_GRAPHIC_RENDITION, RESET_MOUSE_REPORTING, buildPostReplayLiveAgentReattachReset, replayPayloadEndsWithCursorHidden @@ -23,14 +24,15 @@ describe('terminal mode reset profiles', () => { it('pins the fresh-shell profile', () => { expect(POST_REPLAY_MODE_RESET).toBe( - '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l' + '\x1b[0m\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l\x1b7' ) }) it('pins the daemon-reattach profile, which keeps bracketed paste', () => { expect(POST_REPLAY_REATTACH_RESET).toBe( - '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l' + '\x1b[0m\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b7' ) + expect(POST_REPLAY_REATTACH_RESET).toContain(RESET_GRAPHIC_RENDITION) expect(POST_REPLAY_REATTACH_RESET).not.toContain('\x1b[?2004l') }) @@ -46,8 +48,8 @@ describe('terminal mode reset profiles', () => { }) // Why: #12101 — a cold-restored seed re-arms mouse reporting for a dead TUI. - it('disarms mouse reporting on the cold-restore seed', () => { - expect(COLD_RESTORE_SEED_MODE_RESET).toBe(RESET_MOUSE_REPORTING) + it('clears the pen and disarms mouse reporting on the cold-restore seed', () => { + expect(COLD_RESTORE_SEED_MODE_RESET).toBe(`${RESET_GRAPHIC_RENDITION}${RESET_MOUSE_REPORTING}`) }) // Why: the seed also feeds the daemon emulator and is re-serialized from it, so @@ -73,6 +75,7 @@ describe('terminal mode reset profiles', () => { POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET ]) { + expect(profile).not.toContain(RESET_GRAPHIC_RENDITION) expect(profile).not.toContain('\x1b[?1000l') expect(profile).not.toContain('\x1b[?2004l') } diff --git a/src/shared/terminal-mode-reset-profiles.ts b/src/shared/terminal-mode-reset-profiles.ts index 5c912cb3da2..01a7f62acc3 100644 --- a/src/shared/terminal-mode-reset-profiles.ts +++ b/src/shared/terminal-mode-reset-profiles.ts @@ -6,17 +6,22 @@ // Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset. export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' +// Why: abandoned byte-gap replay drains live chunks, so a dropped intensity reset must not style them (STA-4042). +export const RESET_GRAPHIC_RENDITION = '\x1b[0m' +// Last so a dead process cannot leave stale attributes in the DECSC register. +const SAVE_GROUNDED_CURSOR = '\x1b7' // Every mouse mode the daemon can re-arm from a snapshot: protocols 9/1000/1002/1003 + SGR encodings 1006/1016. export const RESET_MOUSE_REPORTING = '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' -export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l` +// Why: serialized panes can end with a live pen, but the following shell assumes default attributes. +export const POST_REPLAY_MODE_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l${SAVE_GROUNDED_CURSOR}` // Why: same-session live replay; keep cursor/focus cleanup but preserve Kitty flags the running TUI relies on. export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` -// Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor. -export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` +// Why: the normal-buffer fallback can follow a dead TUI, so its stale pen and saved pen must not reach the surviving shell. +export const POST_REPLAY_REATTACH_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l${SAVE_GROUNDED_CURSOR}` // Why: an alt-screen reattach replays the daemon's rehydrateSequences, which re-arm the live TUI's // mouse modes; wiping them one write later hands drags back to xterm's row selection (#8291). @@ -29,16 +34,8 @@ export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_ST // Why: a live agent owns cursor/focus here; forcing ?25h/?1004l breaks a parked agent that only arms ?1004h at startup. export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE -/** Dead-TUI bytes feed a fresh shell; clear mouse modes here and renderer-owned modes later. */ -export const COLD_RESTORE_SEED_MODE_RESET = RESET_MOUSE_REPORTING - -// Why separate from every profile above: those clear DEC *mode* bits and none of -// them touches SGR. A recovery path that declares bytes unrecoverable has by -// definition lost whatever turned the pen on, so the pen must be cleared too — -// otherwise a dropped `ESC[22m` leaves bold applied to everything written after -// (STA-4042: hidden-delivery gate drops the reset, the abandoned restore then -// drains queued foreground chunks under the stale pen). -export const RESET_GRAPHIC_RENDITION = '\x1b[0m' +/** Dead-TUI bytes feed a fresh shell; clear their pen and mouse modes before re-serialization. */ +export const COLD_RESTORE_SEED_MODE_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_MOUSE_REPORTING}` // CAN, not a bare ESC: xterm dispatches OSC/DCS/APC with // `success = code !== 0x18 && code !== 0x1a`, so ESC grounds the parser but @@ -72,8 +69,6 @@ const REPLAY_BASELINE_BUFFER_RESET = '\x1b[r' // Last, so the saved-cursor register holds grounded state — otherwise a stranded // `ESC 7` is reachable through the live TUI's next `ESC 8`. Only a floor: a // snapshot carrying the model's own DECSC epilogue overwrites it. -const SAVE_GROUNDED_CURSOR = '\x1b7' - /** * Prologue that puts a pane on `targetAlternateScreen` and grounds it for a * serialized snapshot. Shared because the parity/fuzz harnesses replay the same From 96b03bab4f3797e1233e73ccffca6c3d5177a445 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:18:24 -0700 Subject: [PATCH 08/28] fix(terminal): align Cursor Agent IME preedit anchor (#14982) --- .../pane-manager/terminal-ime-anchor.test.ts | 16 ++++++- .../lib/pane-manager/terminal-ime-anchor.ts | 22 +++++++--- .../terminal-ime-candidate-anchor.test.ts | 44 +++++++++++++++++++ .../terminal-ime-candidate-anchor.ts | 44 ++++++++++++++----- 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts index 19de8afef2c..0045b1c000c 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts @@ -91,7 +91,7 @@ describe('resolveCursorAgentImeAnchor', () => { }) it('does not override normal terminal cursor positioning', () => { - const buffer = makeBuffer(['', ' → Plan, search, build anything', '', '']) + const buffer = makeBuffer(['', ' → ordinary shell output', '', '']) expect( resolveCursorAgentImeAnchor({ @@ -104,6 +104,20 @@ describe('resolveCursorAgentImeAnchor', () => { ).toBeNull() }) + it('anchors the follow-up placeholder after the Cursor Agent header scrolls away', () => { + const buffer = makeBuffer(['transcript', '', ' → Add a follow-up', '', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 5, + cols: 80, + cursorX: 0, + cursorY: 4 + }) + ).toEqual({ row: 2, column: 4 }) + }) + it('does not override when xterm already exposes a non-stale cursor position', () => { const buffer = makeBuffer(['', ' Cursor Agent', '', ' → Plan, search, build anything']) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts index bda4c751a88..e5ada5b3a97 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts @@ -7,7 +7,7 @@ export type TerminalImeAnchor = { const CURSOR_AGENT_HEADER = 'Cursor Agent' const CURSOR_AGENT_INPUT_MARKER = '→' -const CURSOR_AGENT_EMPTY_PROMPT = 'Plan, search, build anything' +const CURSOR_AGENT_EMPTY_PROMPTS = ['Plan, search, build anything', 'Add a follow-up'] as const const CURSOR_AGENT_HEADER_SCAN_ROWS = 6 export function resolveCursorAgentImeAnchor(args: { @@ -16,6 +16,7 @@ export function resolveCursorAgentImeAnchor(args: { cols: number cursorX: number cursorY: number + knownCursorAgent?: boolean }): TerminalImeAnchor | null { const cursorLine = getVisibleLine(args.buffer, args.cursorY) if (args.cursorX !== 0 || !isBlankLine(cursorLine)) { @@ -28,10 +29,9 @@ function findCursorAgentScreenInputAnchor(args: { buffer: IBuffer rows: number cols: number + knownCursorAgent?: boolean }): TerminalImeAnchor | null { - if (!hasCursorAgentHeader(args.buffer, args.rows)) { - return null - } + const allowTypedInput = args.knownCursorAgent || hasCursorAgentHeader(args.buffer, args.rows) // Why: the input box sits below the transcript, so scan bottom-up — a // transcript line containing "→ " (e.g. a rename diff) must not win. @@ -40,7 +40,7 @@ function findCursorAgentScreenInputAnchor(args: { if (!line) { continue } - const column = resolveCursorAgentInputColumn(line, args.cols) + const column = resolveCursorAgentInputColumn(line, args.cols, Boolean(allowTypedInput)) if (column !== null) { return { row, column: Math.min(column, Math.max(args.cols - 1, 0)) } } @@ -63,16 +63,24 @@ function hasCursorAgentHeader(buffer: IBuffer, rows: number): boolean { return false } -function resolveCursorAgentInputColumn(line: IBufferLine, cols: number): number | null { +function resolveCursorAgentInputColumn( + line: IBufferLine, + cols: number, + allowTypedInput: boolean +): number | null { const inputColumn = findCursorAgentInputStartColumn(line, cols) if (inputColumn === null) { return null } const inputText = line.translateToString(true, inputColumn, cols) - if (!inputText.trim() || inputText.startsWith(CURSOR_AGENT_EMPTY_PROMPT)) { + const isEmptyPrompt = CURSOR_AGENT_EMPTY_PROMPTS.some((prompt) => inputText.startsWith(prompt)) + if (!inputText.trim() || isEmptyPrompt) { return inputColumn } + if (!allowTypedInput) { + return null + } return findLineContentEndColumn(line, inputColumn, cols) ?? inputColumn } diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts index c59b62f8751..8b0ef503ded 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts @@ -13,6 +13,7 @@ type AnchorHarness = { terminal: Terminal element: HTMLElement style: { top: string; left: string } + compositionStyle: CSSStyleDeclaration counts: { rectReads: number; styleWrites: number } setCursor: (cursorX: number, cursorY: number) => void setLines: (lines: string[]) => void @@ -45,6 +46,9 @@ function createHarness(): AnchorHarness { const element = document.createElement('div') const screen = document.createElement('div') screen.className = 'xterm-screen' + const compositionView = document.createElement('div') + compositionView.className = 'composition-view' + screen.appendChild(compositionView) element.appendChild(screen) document.body.appendChild(element) @@ -86,6 +90,7 @@ function createHarness(): AnchorHarness { terminal, element, style, + compositionStyle: compositionView.style, counts, setCursor: (cursorX: number, cursorY: number) => { Object.assign(buffer, { cursorX, cursorY }) @@ -211,6 +216,45 @@ describe('installTerminalImeCandidateAnchor', () => { expect(harness.style.top).toBe(`${2 * CELL_HEIGHT}px`) }) + it('keeps the Cursor Agent preedit overlay on the textarea anchor', () => { + const harness = createHarness() + harness.setLines(['Cursor Agent', '', '→ hello']) + harness.element.addEventListener('compositionupdate', () => { + window.setTimeout(() => { + harness.style.top = `${CELL_HEIGHT}px` + harness.compositionStyle.top = `${CELL_HEIGHT}px` + harness.compositionStyle.left = '0px' + }, 0) + }) + installTerminalImeCandidateAnchor(harness.terminal) + + harness.setCursor(0, 1) + fire(harness.element, 'compositionstart') + fire(harness.element, 'compositionupdate') + vi.runAllTimers() + + expect(harness.style).toEqual({ top: `${2 * CELL_HEIGHT}px`, left: `${7 * CELL_WIDTH}px` }) + expect(harness.compositionStyle.top).toBe(`${2 * CELL_HEIGHT}px`) + expect(harness.compositionStyle.left).toBe(`${7 * CELL_WIDTH}px`) + expect(harness.compositionStyle.height).toBe(`${CELL_HEIGHT}px`) + expect(harness.compositionStyle.lineHeight).toBe(`${CELL_HEIGHT}px`) + }) + + it('keeps typed follow-ups anchored after recognizing the initial Cursor Agent screen', () => { + const harness = createHarness() + harness.setLines(['Cursor Agent', '', '→ Plan, search, build anything', '']) + installTerminalImeCandidateAnchor(harness.terminal) + typeHangulSyllable(harness, 0, 1, 3) + + harness.setLines(['transcript', '', '→ hello', '']) + harness.style.left = '0px' + fire(harness.element, 'compositionupdate') + vi.runAllTimers() + + expect(harness.style).toEqual({ top: `${2 * CELL_HEIGHT}px`, left: `${7 * CELL_WIDTH}px` }) + expect(harness.compositionStyle.left).toBe(`${7 * CELL_WIDTH}px`) + }) + it('refreshes the deferred metrics and anchor after a refit', () => { const harness = createHarness() harness.setLines(['Cursor Agent', '', '→ hello']) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts index 64893f84332..2d4349d67d2 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts @@ -8,6 +8,8 @@ type ImeAnchorCellMetrics = { rows: number } +type ImeAnchorStyleProperty = 'top' | 'left' | 'height' | 'lineHeight' + /** * Keep the OS IME candidate window anchored to the cell the user is typing in. * @@ -37,9 +39,11 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo return null } const screenElement = terminal.element.querySelector('.xterm-screen') + const compositionView = terminal.element.querySelector('.composition-view') const textarea = terminal.textarea let metrics: ImeAnchorCellMetrics | null = null let deferredApply: number | null = null + let cursorAgentSeen = false const measureCells = (): ImeAnchorCellMetrics | null => { if (!screenElement) { @@ -57,15 +61,33 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo // Why: xterm rewrites these between our events, so compare against the live // inline value — a CSSOM read, unlike getBoundingClientRect — and skip the // write when it already matches instead of re-invalidating layout. - const writeOffset = (property: 'top' | 'left', value: string): void => { - if (textarea.style[property] !== value) { - textarea.style[property] = value + const writeStyle = ( + element: HTMLElement, + property: ImeAnchorStyleProperty, + value: string + ): void => { + if (element.style[property] !== value) { + element.style[property] = value } } - const applyAnchor = (row: number, column: number, cells: ImeAnchorCellMetrics): void => { - writeOffset('top', `${row * cells.cellHeight}px`) - writeOffset('left', `${column * cells.cellWidth}px`) + const applyAnchor = ( + row: number, + column: number, + cells: ImeAnchorCellMetrics, + isCursorAgent: boolean + ): void => { + const top = `${row * cells.cellHeight}px` + const left = `${column * cells.cellWidth}px` + writeStyle(textarea, 'top', top) + writeStyle(textarea, 'left', left) + if (isCursorAgent && compositionView) { + const height = `${cells.cellHeight}px` + writeStyle(compositionView, 'top', top) + writeStyle(compositionView, 'left', left) + writeStyle(compositionView, 'height', height) + writeStyle(compositionView, 'lineHeight', height) + } } const resolveAnchor = (): { anchor: TerminalImeAnchor; isCursorAgent: boolean } => { @@ -77,8 +99,10 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo rows: terminal.rows, cols: terminal.cols, cursorX: buf.cursorX, - cursorY: buf.cursorY + cursorY: buf.cursorY, + knownCursorAgent: cursorAgentSeen }) + cursorAgentSeen ||= cursorAgentAnchor !== null return { anchor: cursorAgentAnchor ?? { row: buf.cursorY, @@ -104,7 +128,7 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo return } const { anchor, isCursorAgent } = resolveAnchor() - applyAnchor(anchor.row, anchor.column, cells) + applyAnchor(anchor.row, anchor.column, cells, isCursorAgent) // Why: xterm re-positions the textarea from a setTimeout(0) of its own after // each compositionupdate, so the correction has to land after that timer — // one pending timer per burst, re-reading the anchor when it fires. @@ -128,8 +152,8 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo metrics = measureCells() } if (metrics) { - const currentAnchor = resolveAnchor().anchor - applyAnchor(currentAnchor.row, currentAnchor.column, metrics) + const current = resolveAnchor() + applyAnchor(current.anchor.row, current.anchor.column, metrics, current.isCursorAgent) } }, 0) } From 5e189d60816a2e4b7ed68960c04396021246fe02 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:32:20 -0700 Subject: [PATCH 09/28] feat(browser): add a WebAuthn account picker (#14687) * fix(browser): prompt for WebAuthn account selection * fix(browser): scope WebAuthn cancellation to session * fix(renderer): keep WebAuthn render phase pure --- src/main/browser/browser-manager.ts | 11 ++ .../browser/browser-webauthn-access.test.ts | 57 ++++++ src/main/browser/browser-webauthn-access.ts | 45 ++++- .../browser-webauthn-account-picker.test.ts | 166 +++++++++++++++++ .../browser-webauthn-account-picker.ts | 150 +++++++++++++++ .../browser-webauthn-profile-delete.test.ts | 144 +++++++++++++++ src/main/ipc/browser.ts | 17 ++ src/preload/api/browser-api.ts | 9 + src/preload/index.ts | 27 +++ src/renderer/src/App.tsx | 2 + .../browser-webauthn-account-dialog.test.tsx | 98 ++++++++++ .../browser-webauthn-account-dialog.tsx | 174 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 9 + src/shared/browser-webauthn-account.ts | 17 ++ 14 files changed, 918 insertions(+), 8 deletions(-) create mode 100644 src/main/browser/browser-webauthn-access.test.ts create mode 100644 src/main/browser/browser-webauthn-account-picker.test.ts create mode 100644 src/main/browser/browser-webauthn-account-picker.ts create mode 100644 src/main/browser/browser-webauthn-profile-delete.test.ts create mode 100644 src/renderer/src/components/browser-webauthn-account-dialog.test.tsx create mode 100644 src/renderer/src/components/browser-webauthn-account-dialog.tsx create mode 100644 src/shared/browser-webauthn-account.ts diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 3af2e46be1c..abbd79bcf4f 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1334,6 +1334,17 @@ export class BrowserManager { return this.worktreeIdByTabId.get(browserTabId) } + getRendererContextForGuest( + guestWebContentsId: number + ): { browserPageId: string; renderer: Electron.WebContents } | null { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return null + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + return renderer ? { browserPageId, renderer } : null + } + getSessionProfileIdForTab(browserTabId: string): string | null { return this.sessionProfileIdByPageId.get(browserTabId) ?? null } diff --git a/src/main/browser/browser-webauthn-access.test.ts b/src/main/browser/browser-webauthn-access.test.ts new file mode 100644 index 00000000000..dfcd3ac61ca --- /dev/null +++ b/src/main/browser/browser-webauthn-access.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { requestAccountMock, cancelSessionAccountRequestsMock } = vi.hoisted(() => ({ + requestAccountMock: vi.fn(), + cancelSessionAccountRequestsMock: vi.fn() +})) + +vi.mock('./browser-webauthn-account-picker', () => ({ + requestBrowserWebAuthnAccount: requestAccountMock, + cancelBrowserWebAuthnAccountRequestsForSession: cancelSessionAccountRequestsMock +})) + +import { installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' + +describe('browser WebAuthn access', () => { + beforeEach(() => { + requestAccountMock.mockReset() + cancelSessionAccountRequestsMock.mockReset() + }) + + it('dispatches multi-account assertions to the account picker', async () => { + let selectHandler: + | (( + event: Electron.Event, + details: Electron.SelectWebauthnAccountDetails, + callback: (credentialId?: string | null) => void + ) => Promise) + | undefined + const browserSession = { + setDevicePermissionHandler: vi.fn(), + removeListener: vi.fn(), + on: vi.fn((eventName: string, listener: unknown) => { + if (eventName === 'select-webauthn-account') { + selectHandler = listener as typeof selectHandler + } + }) + } as unknown as Electron.Session + const details = { + relyingPartyId: 'example.com', + frame: {} as Electron.WebFrameMain, + accounts: [{ credentialId: 'first' }, { credentialId: 'second' }] + } + const callback = vi.fn() + requestAccountMock.mockResolvedValue('second') + + installBrowserWebAuthnAccessHandlers(browserSession) + await selectHandler?.( + { preventDefault: vi.fn() } as unknown as Electron.Event, + details, + callback + ) + + expect(requestAccountMock).toHaveBeenCalledWith(details, browserSession) + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith('second') + }) +}) diff --git a/src/main/browser/browser-webauthn-access.ts b/src/main/browser/browser-webauthn-access.ts index 60da5b1e297..521c0f15886 100644 --- a/src/main/browser/browser-webauthn-access.ts +++ b/src/main/browser/browser-webauthn-access.ts @@ -1,7 +1,17 @@ import type { Session } from 'electron' +import { + cancelBrowserWebAuthnAccountRequestsForSession, + requestBrowserWebAuthnAccount +} from './browser-webauthn-account-picker' const FIDO_HID_USAGE_PAGE = 0xf1d0 const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) +type SelectWebAuthnAccountHandler = ( + event: Electron.Event, + details: Electron.SelectWebauthnAccountDetails, + callback: (credentialId?: string | null) => void +) => Promise +const selectWebAuthnAccountHandlers = new WeakMap() function isSecureBrowserOrigin(rawOrigin: string | undefined): boolean { if (!rawOrigin) { @@ -53,15 +63,23 @@ function handleBrowserSelectHidDevice( callback(selectedDevice?.deviceId) } -function handleBrowserSelectWebAuthnAccount( +async function handleBrowserSelectWebAuthnAccount( + browserSession: Session, event: Electron.Event, details: Electron.SelectWebauthnAccountDetails, callback: (credentialId?: string | null) => void -): void { +): Promise { event.preventDefault() - // Why: Electron cancels discoverable WebAuthn when no listener exists. Pick - // only the unambiguous single-account case until Orca has account-picker UI. - callback(details.accounts.length === 1 ? details.accounts[0].credentialId : null) + if (details.accounts.length <= 1) { + callback(details.accounts[0]?.credentialId ?? null) + return + } + let credentialId: string | null = null + try { + credentialId = await requestBrowserWebAuthnAccount(details, browserSession) + } finally { + callback(credentialId) + } } export function installBrowserWebAuthnAccessHandlers(browserSession: Session): void { @@ -74,12 +92,23 @@ export function installBrowserWebAuthnAccessHandlers(browserSession: Session): v }) browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice) browserSession.on('select-hid-device', handleBrowserSelectHidDevice) - browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount) - browserSession.on('select-webauthn-account', handleBrowserSelectWebAuthnAccount) + const previousHandler = selectWebAuthnAccountHandlers.get(browserSession) + if (previousHandler) { + browserSession.removeListener('select-webauthn-account', previousHandler) + } + const selectWebAuthnAccountHandler: SelectWebAuthnAccountHandler = (event, details, callback) => + handleBrowserSelectWebAuthnAccount(browserSession, event, details, callback) + selectWebAuthnAccountHandlers.set(browserSession, selectWebAuthnAccountHandler) + browserSession.on('select-webauthn-account', selectWebAuthnAccountHandler) } export function clearBrowserWebAuthnAccessHandlers(browserSession: Session): void { browserSession.removeListener('select-hid-device', handleBrowserSelectHidDevice) - browserSession.removeListener('select-webauthn-account', handleBrowserSelectWebAuthnAccount) + const selectWebAuthnAccountHandler = selectWebAuthnAccountHandlers.get(browserSession) + if (selectWebAuthnAccountHandler) { + browserSession.removeListener('select-webauthn-account', selectWebAuthnAccountHandler) + selectWebAuthnAccountHandlers.delete(browserSession) + } browserSession.setDevicePermissionHandler(null) + cancelBrowserWebAuthnAccountRequestsForSession(browserSession) } diff --git a/src/main/browser/browser-webauthn-account-picker.test.ts b/src/main/browser/browser-webauthn-account-picker.test.ts new file mode 100644 index 00000000000..3e9e370b807 --- /dev/null +++ b/src/main/browser/browser-webauthn-account-picker.test.ts @@ -0,0 +1,166 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fromFrameMock, getRendererContextForGuestMock } = vi.hoisted(() => ({ + fromFrameMock: vi.fn(), + getRendererContextForGuestMock: vi.fn() +})) + +vi.mock('electron', () => ({ + webContents: { fromFrame: fromFrameMock } +})) + +vi.mock('./browser-manager', () => ({ + browserManager: { getRendererContextForGuest: getRendererContextForGuestMock } +})) + +import { + BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS, + cancelAllBrowserWebAuthnAccountRequests, + cancelBrowserWebAuthnAccountRequests, + requestBrowserWebAuthnAccount, + respondToBrowserWebAuthnAccountRequest +} from './browser-webauthn-account-picker' + +function mockWebContents(id: number): Electron.WebContents & EventEmitter { + const contents = new EventEmitter() as Electron.WebContents & EventEmitter + Object.assign(contents, { + id, + isDestroyed: vi.fn(() => false), + send: vi.fn() + }) + return contents +} + +function accountDetails(): Electron.SelectWebauthnAccountDetails { + return { + relyingPartyId: 'accounts.example.com', + frame: {} as Electron.WebFrameMain, + accounts: [ + { credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' } + ] + } +} + +function mockSession(): Electron.Session { + return {} as Electron.Session +} + +describe('browser WebAuthn account picker', () => { + beforeEach(() => { + vi.useFakeTimers() + fromFrameMock.mockReset() + getRendererContextForGuestMock.mockReset() + }) + + afterEach(() => { + cancelAllBrowserWebAuthnAccountRequests() + vi.useRealTimers() + }) + + it('returns the credential selected by the owning renderer', async () => { + const guest = mockWebContents(41) + const renderer = mockWebContents(42) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-1', renderer }) + + const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession()) + const request = vi.mocked(renderer.send).mock.calls[0][1] + + expect(request).toMatchObject({ + browserPageId: 'page-1', + relyingPartyId: 'accounts.example.com', + accounts: [ + { credentialId: 'credential-1', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'credential-2', displayName: 'Work', name: 'me@work.example' } + ] + }) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { + requestId: request.requestId, + credentialId: 'credential-2' + }) + ).toBe(true) + await expect(selection).resolves.toBe('credential-2') + }) + + it('rejects responses from another renderer or with an unoffered credential', async () => { + const guest = mockWebContents(51) + const renderer = mockWebContents(52) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-2', renderer }) + + const selection = requestBrowserWebAuthnAccount(accountDetails(), mockSession()) + const requestId = vi.mocked(renderer.send).mock.calls[0][1].requestId + + expect( + respondToBrowserWebAuthnAccountRequest(mockWebContents(53), { + requestId, + credentialId: 'credential-1' + }) + ).toBe(false) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { + requestId, + credentialId: 'credential-not-offered' + }) + ).toBe(false) + expect( + respondToBrowserWebAuthnAccountRequest(renderer, { requestId, credentialId: null }) + ).toBe(true) + await expect(selection).resolves.toBeNull() + }) + + it('cancels when the tab closes or the prompt times out', async () => { + const guest = mockWebContents(61) + const renderer = mockWebContents(62) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-3', renderer }) + const browserSession = mockSession() + const closedSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + vi.mocked(renderer.send).mockImplementation((channel) => { + if (channel === 'browser:webauthn-account-request-closed') { + throw new Error('renderer destroyed during send') + } + }) + + cancelBrowserWebAuthnAccountRequests('page-3') + await expect(closedSelection).resolves.toBeNull() + + const timedOutSelection = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + await vi.advanceTimersByTimeAsync(BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS) + await expect(timedOutSelection).resolves.toBeNull() + }) + + it('cancels when the guest or window is destroyed', async () => { + const guest = mockWebContents(71) + const renderer = mockWebContents(72) + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-4', renderer }) + const browserSession = mockSession() + const guestDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + guest.emit('destroyed') + await expect(guestDestroyed).resolves.toBeNull() + + const windowDestroyed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + renderer.emit('destroyed') + await expect(windowDestroyed).resolves.toBeNull() + }) + + it('cancels immediately when the guest or window renderer process exits', async () => { + const guest = mockWebContents(81) + const renderer = mockWebContents(82) + const browserSession = mockSession() + fromFrameMock.mockReturnValue(guest) + getRendererContextForGuestMock.mockReturnValue({ browserPageId: 'page-5', renderer }) + + const guestCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + guest.emit('render-process-gone') + await expect(guestCrashed).resolves.toBeNull() + + const windowCrashed = requestBrowserWebAuthnAccount(accountDetails(), browserSession) + renderer.emit('render-process-gone') + await expect(windowCrashed).resolves.toBeNull() + }) +}) diff --git a/src/main/browser/browser-webauthn-account-picker.ts b/src/main/browser/browser-webauthn-account-picker.ts new file mode 100644 index 00000000000..522630e6851 --- /dev/null +++ b/src/main/browser/browser-webauthn-account-picker.ts @@ -0,0 +1,150 @@ +import { randomUUID } from 'node:crypto' +import { webContents } from 'electron' + +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' +import { browserManager } from './browser-manager' + +export const BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS = 60_000 + +type PendingAccountRequest = { + browserPageId: string + browserSession: Electron.Session + guest: Electron.WebContents + renderer: Electron.WebContents + credentialIds: Set + timer: ReturnType + onUnavailable: () => void + settle: (credentialId: string | null) => void +} + +const pendingAccountRequests = new Map() + +function closeRendererPrompt(requestId: string, request: PendingAccountRequest): void { + try { + if (!request.renderer.isDestroyed()) { + request.renderer.send('browser:webauthn-account-request-closed', { requestId }) + } + } catch { + // Renderer may be destroyed between the check and send. + } +} + +function cleanUpRequest(requestId: string, request: PendingAccountRequest): void { + clearTimeout(request.timer) + request.guest.removeListener('destroyed', request.onUnavailable) + request.guest.removeListener('render-process-gone', request.onUnavailable) + request.renderer.removeListener('destroyed', request.onUnavailable) + request.renderer.removeListener('render-process-gone', request.onUnavailable) + pendingAccountRequests.delete(requestId) +} + +export function requestBrowserWebAuthnAccount( + details: Electron.SelectWebauthnAccountDetails, + browserSession: Electron.Session +): Promise { + const guest = details.frame ? webContents.fromFrame(details.frame) : undefined + if (!guest || guest.isDestroyed()) { + return Promise.resolve(null) + } + const context = browserManager.getRendererContextForGuest(guest.id) + if (!context || context.renderer.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + const requestPayload: BrowserWebAuthnAccountRequest = { + requestId, + browserPageId: context.browserPageId, + relyingPartyId: details.relyingPartyId, + accounts: details.accounts.map(({ credentialId, displayName, name }) => ({ + credentialId, + ...(displayName ? { displayName } : {}), + ...(name ? { name } : {}) + })) + } + + return new Promise((resolve) => { + let settled = false + const settle = (credentialId: string | null = null): void => { + if (settled) { + return + } + settled = true + const request = pendingAccountRequests.get(requestId) + if (request) { + cleanUpRequest(requestId, request) + closeRendererPrompt(requestId, request) + } + resolve(credentialId) + } + const request: PendingAccountRequest = { + browserPageId: context.browserPageId, + browserSession, + guest, + renderer: context.renderer, + credentialIds: new Set(details.accounts.map((account) => account.credentialId)), + timer: setTimeout(settle, BROWSER_WEBAUTHN_ACCOUNT_PICKER_TIMEOUT_MS), + onUnavailable: () => settle(null), + settle + } + pendingAccountRequests.set(requestId, request) + guest.once('destroyed', request.onUnavailable) + guest.once('render-process-gone', request.onUnavailable) + context.renderer.once('destroyed', request.onUnavailable) + context.renderer.once('render-process-gone', request.onUnavailable) + try { + context.renderer.send('browser:webauthn-account-requested', requestPayload) + } catch { + settle(null) + } + }) +} + +export function respondToBrowserWebAuthnAccountRequest( + sender: Electron.WebContents, + response: BrowserWebAuthnAccountResponse +): boolean { + if ( + !response || + typeof response.requestId !== 'string' || + (response.credentialId !== null && typeof response.credentialId !== 'string') + ) { + return false + } + const request = pendingAccountRequests.get(response.requestId) + if (!request || request.renderer.id !== sender.id) { + return false + } + if (response.credentialId !== null && !request.credentialIds.has(response.credentialId)) { + return false + } + request.settle(response.credentialId) + return true +} + +export function cancelBrowserWebAuthnAccountRequests(browserPageId: string): void { + for (const request of pendingAccountRequests.values()) { + if (request.browserPageId === browserPageId) { + request.settle(null) + } + } +} + +export function cancelBrowserWebAuthnAccountRequestsForSession( + browserSession: Electron.Session +): void { + for (const request of pendingAccountRequests.values()) { + if (request.browserSession === browserSession) { + request.settle(null) + } + } +} + +export function cancelAllBrowserWebAuthnAccountRequests(): void { + for (const request of pendingAccountRequests.values()) { + request.settle(null) + } +} diff --git a/src/main/browser/browser-webauthn-profile-delete.test.ts b/src/main/browser/browser-webauthn-profile-delete.test.ts new file mode 100644 index 00000000000..9b063b6bbdd --- /dev/null +++ b/src/main/browser/browser-webauthn-profile-delete.test.ts @@ -0,0 +1,144 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fromFrameMock, getRendererContextForGuestMock, sessionFromPartitionMock } = vi.hoisted( + () => ({ + fromFrameMock: vi.fn(), + getRendererContextForGuestMock: vi.fn(), + sessionFromPartitionMock: vi.fn() + }) +) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => { + throw new Error('userData unavailable in test') + }) + }, + session: { fromPartition: sessionFromPartitionMock }, + systemPreferences: { + askForMediaAccess: vi.fn(), + getMediaAccessStatus: vi.fn(() => 'granted') + }, + webContents: { fromFrame: fromFrameMock } +})) + +vi.mock('./browser-manager', () => ({ + browserManager: { + getRendererContextForGuest: getRendererContextForGuestMock, + handleGuestWillDownload: vi.fn(), + installCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn(), + removeCertificateRequestGuard: vi.fn() + } +})) + +import { browserSessionRegistry } from './browser-session-registry' +import { + cancelAllBrowserWebAuthnAccountRequests, + respondToBrowserWebAuthnAccountRequest +} from './browser-webauthn-account-picker' + +type MockSession = Electron.Session & EventEmitter + +function mockSession(): MockSession { + return Object.assign(new EventEmitter(), { + clearCache: vi.fn().mockResolvedValue(undefined), + clearStorageData: vi.fn().mockResolvedValue(undefined), + setDevicePermissionHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setPermissionRequestHandler: vi.fn() + }) as unknown as MockSession +} + +function mockWebContents(id: number): Electron.WebContents & EventEmitter { + return Object.assign(new EventEmitter(), { + id, + isDestroyed: vi.fn(() => false), + send: vi.fn() + }) as unknown as Electron.WebContents & EventEmitter +} + +function accountDetails(frame: Electron.WebFrameMain): Electron.SelectWebauthnAccountDetails { + return { + relyingPartyId: 'accounts.example.com', + frame, + accounts: [{ credentialId: 'personal' }, { credentialId: 'work' }] + } +} + +describe('browser WebAuthn profile deletion', () => { + const sessionsByPartition = new Map() + + beforeEach(() => { + sessionsByPartition.clear() + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockImplementation((partition: string) => { + const existing = sessionsByPartition.get(partition) + if (existing) { + return existing + } + const created = mockSession() + sessionsByPartition.set(partition, created) + return created + }) + fromFrameMock.mockReset() + getRendererContextForGuestMock.mockReset() + }) + + afterEach(async () => { + cancelAllBrowserWebAuthnAccountRequests() + for (const profile of browserSessionRegistry.listProfiles()) { + if (profile.scope !== 'default') { + await browserSessionRegistry.deleteProfile(profile.id) + } + } + }) + + it('leaves another session pending when a profile is deleted', async () => { + const firstProfile = browserSessionRegistry.createProfile('isolated', 'First') + const secondProfile = browserSessionRegistry.createProfile('isolated', 'Second') + expect(firstProfile).not.toBeNull() + expect(secondProfile).not.toBeNull() + + const firstSession = sessionsByPartition.get(firstProfile!.partition)! + const secondSession = sessionsByPartition.get(secondProfile!.partition)! + const firstFrame = {} as Electron.WebFrameMain + const secondFrame = {} as Electron.WebFrameMain + const firstGuest = mockWebContents(101) + const secondGuest = mockWebContents(102) + const firstRenderer = mockWebContents(201) + const secondRenderer = mockWebContents(202) + fromFrameMock.mockImplementation((frame) => (frame === firstFrame ? firstGuest : secondGuest)) + getRendererContextForGuestMock.mockImplementation((guestId: number) => + guestId === firstGuest.id + ? { browserPageId: 'first-page', renderer: firstRenderer } + : { browserPageId: 'second-page', renderer: secondRenderer } + ) + const firstCallback = vi.fn() + const secondCallback = vi.fn() + const firstHandler = firstSession.listeners('select-webauthn-account')[0] + const secondHandler = secondSession.listeners('select-webauthn-account')[0] + + void firstHandler({ preventDefault: vi.fn() }, accountDetails(firstFrame), firstCallback) + void secondHandler({ preventDefault: vi.fn() }, accountDetails(secondFrame), secondCallback) + + await expect(browserSessionRegistry.deleteProfile(firstProfile!.id)).resolves.toBe(true) + await vi.waitFor(() => expect(firstCallback).toHaveBeenCalledWith(null)) + firstGuest.emit('destroyed') + firstRenderer.emit('render-process-gone') + expect(firstCallback).toHaveBeenCalledOnce() + expect(secondCallback).not.toHaveBeenCalled() + + const secondRequest = vi.mocked(secondRenderer.send).mock.calls[0][1] + expect( + respondToBrowserWebAuthnAccountRequest(secondRenderer, { + requestId: secondRequest.requestId, + credentialId: 'work' + }) + ).toBe(true) + await vi.waitFor(() => expect(secondCallback).toHaveBeenCalledWith('work')) + expect(secondCallback).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 4a8e0fad03a..2f374e186b2 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -15,6 +15,11 @@ import { } from './browser-grab-ipc' import { registerBrowserSessionProfileHandlers } from './browser-session-profile-ipc' import type { BrowserCertificateProceedResult } from '../../shared/browser-workspace-types' +import { + cancelBrowserWebAuthnAccountRequests, + respondToBrowserWebAuthnAccountRequest +} from '../browser/browser-webauthn-account-picker' +import type { BrowserWebAuthnAccountResponse } from '../../shared/browser-webauthn-account' let agentBrowserBridgeRef: AgentBrowserBridge | null = null @@ -38,6 +43,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:activeTabChanged') ipcMain.removeHandler('browser:proceedCertificate') + ipcMain.removeHandler('browser:respondWebAuthnAccount') const registerGuest = ( event: Electron.IpcMainInvokeEvent, @@ -124,11 +130,22 @@ export function registerBrowserHandlers(): void { if (wcId !== null && agentBrowserBridgeRef) { agentBrowserBridgeRef.onTabClosed(wcId) } + cancelBrowserWebAuthnAccountRequests(args.browserPageId) browserManager.unregisterGuest(args.browserPageId) disposeGrabModeStateForPage(args.browserPageId) return true }) + ipcMain.handle( + 'browser:respondWebAuthnAccount', + (event, response: BrowserWebAuthnAccountResponse): boolean => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return respondToBrowserWebAuthnAccountRequest(event.sender, response) + } + ) + ipcMain.handle( 'browser:proceedCertificate', ( diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index 6b352eff527..2810e8b35f2 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -1,4 +1,8 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -48,6 +52,11 @@ export type BrowserApi = { webContentsId: number }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ) => () => void + onWebAuthnAccountRequestClosed: (callback: (event: { requestId: string }) => void) => () => void + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse) => Promise openDevTools: (args: { browserPageId: string }) => Promise setViewportOverride: (args: { browserPageId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d6405f171be..2c7beed48a2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -63,6 +63,10 @@ import type { import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' import type { BrowserViewportOverride } from '../shared/browser-workspace-types' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../shared/browser-webauthn-account' import type { SearchResult } from '../shared/code-search-types' import type { FilesystemPathFlavor, @@ -2691,6 +2695,29 @@ const api = { unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: BrowserWebAuthnAccountRequest + ): void => callback(request) + ipcRenderer.on('browser:webauthn-account-requested', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) + }, + + onWebAuthnAccountRequestClosed: ( + callback: (event: { requestId: string }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => + callback(data) + ipcRenderer.on('browser:webauthn-account-request-closed', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) + }, + + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => + ipcRenderer.invoke('browser:respondWebAuthnAccount', response), + openDevTools: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:openDevTools', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b586b424b35..b6dfd73718f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { Toaster } from '@/components/ui/sonner' import { TooltipProvider } from '@/components/ui/tooltip' import { ConfirmationDialogProvider } from './components/confirmation-dialog' +import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-account-dialog' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' @@ -75,6 +76,7 @@ function App(): React.JSX.Element { floatingWorkspace={floatingWorkspace} onboardingGate={onboardingGate} /> + diff --git a/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx b/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx new file mode 100644 index 00000000000..69120a43777 --- /dev/null +++ b/src/renderer/src/components/browser-webauthn-account-dialog.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { BrowserWebAuthnAccountRequest } from '../../../shared/browser-webauthn-account' + +const { respondMock, setBlockingSurfaceMock } = vi.hoisted(() => ({ + respondMock: vi.fn(), + setBlockingSurfaceMock: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ setContextualToursBlockingSurfaceVisible: setBlockingSurfaceMock }) +})) + +import { BrowserWebAuthnAccountDialog } from './browser-webauthn-account-dialog' + +describe('BrowserWebAuthnAccountDialog', () => { + let requestListener!: (request: BrowserWebAuthnAccountRequest) => void + let closedListener!: (event: { requestId: string }) => void + + afterEach(cleanup) + + beforeEach(() => { + respondMock.mockReset().mockResolvedValue(true) + setBlockingSurfaceMock.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + browser: { + onWebAuthnAccountRequest: vi.fn((callback) => { + requestListener = callback + return vi.fn() + }), + onWebAuthnAccountRequestClosed: vi.fn((callback) => { + closedListener = callback + return vi.fn() + }), + respondWebAuthnAccount: respondMock + } + } + }) + }) + + function showRequest(): void { + act(() => { + requestListener({ + requestId: 'request-1', + browserPageId: 'page-1', + relyingPartyId: 'accounts.example.com', + accounts: [ + { credentialId: 'personal-id', displayName: 'Personal', name: 'me@example.com' }, + { credentialId: 'work-id', displayName: 'Work', name: 'me@work.example' } + ] + }) + }) + } + + it('returns the selected credential without displaying opaque credential IDs', async () => { + render() + showRequest() + + expect(screen.getByText('accounts.example.com')).toBeInTheDocument() + expect(screen.queryByText('work-id')).not.toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('button', { name: /Personal/ })).toHaveFocus()) + fireEvent.click(screen.getByRole('button', { name: /Work/ })) + + expect(respondMock).toHaveBeenCalledWith({ + requestId: 'request-1', + credentialId: 'work-id' + }) + }) + + it('returns null only when the user cancels', () => { + render() + showRequest() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(respondMock).toHaveBeenCalledWith({ + requestId: 'request-1', + credentialId: null + }) + }) + + it('closes a timed-out prompt when main dispatches teardown', () => { + render() + showRequest() + + act(() => closedListener({ requestId: 'request-1' })) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/browser-webauthn-account-dialog.tsx b/src/renderer/src/components/browser-webauthn-account-dialog.tsx new file mode 100644 index 00000000000..cb7bc9ff2c3 --- /dev/null +++ b/src/renderer/src/components/browser-webauthn-account-dialog.tsx @@ -0,0 +1,174 @@ +import { KeyRound } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { + BrowserWebAuthnAccount, + BrowserWebAuthnAccountRequest +} from '../../../shared/browser-webauthn-account' + +function accountLabels( + account: BrowserWebAuthnAccount, + index: number +): { primary: string; secondary: string | null } { + const primary = + account.displayName?.trim() || + account.name?.trim() || + `${translate('auto.components.browser.webauthn.account.fallback', 'Passkey')} ${index + 1}` + const secondary = account.name?.trim() + return { primary, secondary: secondary && secondary !== primary ? secondary : null } +} + +export function BrowserWebAuthnAccountDialog(): React.JSX.Element { + const [requests, setRequests] = useState([]) + const [respondingRequestId, setRespondingRequestId] = useState(null) + const requestsRef = useRef(requests) + const firstAccountRef = useRef(null) + const setContextualToursBlockingSurfaceVisible = useAppStore( + (state) => state.setContextualToursBlockingSurfaceVisible + ) + const activeRequest = requests[0] ?? null + const lastRequestRef = useRef(activeRequest) + const displayedRequest = activeRequest ?? lastRequestRef.current + + useEffect(() => { + requestsRef.current = requests + if (activeRequest) { + lastRequestRef.current = activeRequest + } + }, [activeRequest, requests]) + + const removeRequest = useCallback((requestId: string) => { + setRequests((current) => current.filter((request) => request.requestId !== requestId)) + setRespondingRequestId((current) => (current === requestId ? null : current)) + }, []) + + useEffect(() => { + const stopRequests = window.api.browser.onWebAuthnAccountRequest((request) => { + setRequests((current) => [...current, request]) + }) + const stopClosures = window.api.browser.onWebAuthnAccountRequestClosed(({ requestId }) => { + removeRequest(requestId) + }) + return () => { + stopRequests() + stopClosures() + for (const request of requestsRef.current) { + void window.api.browser + .respondWebAuthnAccount({ + requestId: request.requestId, + credentialId: null + }) + .catch(() => {}) + } + } + }, [removeRequest]) + + useEffect(() => { + setContextualToursBlockingSurfaceVisible(activeRequest !== null) + return () => setContextualToursBlockingSurfaceVisible(false) + }, [activeRequest, setContextualToursBlockingSurfaceVisible]) + + useEffect(() => { + if (!activeRequest) { + return + } + const focusTimer = setTimeout(() => firstAccountRef.current?.focus()) + return () => clearTimeout(focusTimer) + }, [activeRequest]) + + const respond = useCallback( + (credentialId: string | null) => { + const request = requestsRef.current[0] + if (!request || respondingRequestId === request.requestId) { + return + } + setRespondingRequestId(request.requestId) + void window.api.browser + .respondWebAuthnAccount({ requestId: request.requestId, credentialId }) + .then((accepted) => { + if (accepted) { + removeRequest(request.requestId) + } else { + setRespondingRequestId(null) + } + }) + .catch(() => setRespondingRequestId(null)) + }, + [removeRequest, respondingRequestId] + ) + + return ( + !open && respond(null)}> + { + event.preventDefault() + firstAccountRef.current?.focus() + }} + > + + + {translate('auto.components.browser.webauthn.account.title', 'Choose a passkey')} + + + {translate( + 'auto.components.browser.webauthn.account.description', + 'Choose the account to use with this security key.' + )} + + + +
+ {displayedRequest?.accounts.map((account, index) => { + const labels = accountLabels(account, index) + return ( + + ) + })} +
+ +

+ {translate('auto.components.browser.webauthn.account.site', 'Site')}{' '} + {displayedRequest?.relyingPartyId} +

+ + + + +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f5a4b142808..3477f7aef38 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14893,6 +14893,15 @@ } } } + }, + "webauthn": { + "account": { + "fallback": "Passkey", + "title": "Choose a passkey", + "description": "Choose the account to use with this security key.", + "site": "Site", + "cancel": "Cancel" + } } }, "automations": { diff --git a/src/shared/browser-webauthn-account.ts b/src/shared/browser-webauthn-account.ts new file mode 100644 index 00000000000..d80792bb8f3 --- /dev/null +++ b/src/shared/browser-webauthn-account.ts @@ -0,0 +1,17 @@ +export type BrowserWebAuthnAccount = { + credentialId: string + displayName?: string + name?: string +} + +export type BrowserWebAuthnAccountRequest = { + requestId: string + browserPageId: string + relyingPartyId: string + accounts: BrowserWebAuthnAccount[] +} + +export type BrowserWebAuthnAccountResponse = { + requestId: string + credentialId: string | null +} From 886dec1d2aaa0cab0b2714415fea93e992c0cb90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:43:11 -0700 Subject: [PATCH 10/28] fix(browser): report cookies an import could not decrypt (#14683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): report cookies an import could not decrypt Supersedes #13193, which reported only the Windows v20 case. Nothing distinguished "decryption failed" from "no cookies present". A row that would not decrypt was folded into the generic `skipped` counter, and a profile whose rows all failed returned ok:true with importedCookies:0 and no warning — a green "Imported 0 cookies from Google Chrome." The two situations produce opposite result shapes and the worse one reported success. Attribute the cause at the point of failure, while the version prefix is still in hand, and surface it as one `cookies-undecryptable` warning carrying the reason. Covers all three known causes rather than one prefix: - app-bound-encryption: Chrome/Edge 140+ on Windows write `v20`, which only the writing browser can unwrap. The version gate is a FORMAT check (`/^v\d\d$/`), so v20 passed it and failed inside AES like corruption. - linux-keyring-unavailable: getLinuxEncryptionKey derived the v11 key from an empty password when both secret-tool lookups failed, so it never returned null and the "Could not access encryption key" guard was unreachable on Linux. - unknown: any other cause still warns instead of reporting success. Deliberately not a hard failure on Linux: Chrome falls back to the "peanuts" v10 key precisely when no keyring exists, so those profiles still import. Pinned by a regression test. Refs #13192, #14181 * fix(browser): attribute decrypt failures exactly and gate CBC by version Review-loop findings on the initial commit, all fixed here. - CORRECTNESS: v11 rows were attempted with the v10 key when the keyring was unavailable. AES-128-CBC is unauthenticated, so a wrong key that yields valid PKCS#7 padding was accepted — roughly 1 in 256 per row. Garbage values were written into the jar as real cookies, and because those rows counted as successes the warning this PR adds could never fire. Key eligibility is now explicit per version rather than implicit in key ordering. - CORRECTNESS: the CBC path returned an empty Buffer for a prefix-only value BEFORE checking eligibility. An empty Buffer is truthy, so an ineligible row counted as imported and reached the live-jar clear. Eligibility now precedes that branch and empty CBC ciphertext is rejected as malformed. - ACCURACY: a named cause reported the TOTAL failure count, so one v20 row plus one corrupt row claimed both failed to app-bound encryption. Counts are now exact per cause, with the remainder reported separately and a tie falling back to 'unknown'. Exact-count approach carried over from #13193. - The app-bound copy no longer dead-ends. It names the existing in-app file import without describing how to produce the file — Chrome has no native decrypted-cookie export, so concrete guidance would send users to an extension that can read their whole session jar. - Direct prefix edge tests carried forward from #13193. Repo-wide search found no second multi-key unauthenticated-CBC first-success site, so this pattern was one occurrence rather than a class. Co-authored-by: manuaudio * fix(pr): preserve split worktree slice Remove the unrelated rollback of the worktree-slice split and its forbidden max-lines baseline addition from this cookie-import PR. * fix(browser): match the unknown decrypt reason explicitly CI's type-aware code-quality gate flagged the reason switch as non-exhaustive: the 'unknown' member was handled by `default:` rather than matched. Matching it explicitly keeps the behaviour identical today and makes the gate enforce the thing that matters — adding a new reason to the union now fails the switch instead of falling silently into a generic message that would not describe it. This gate is separate from `oxlint` and is not covered by running oxlint on the changed files, which is why it only surfaced in CI. --------- Co-authored-by: manuaudio --- ...ser-cookie-import-app-bound-prefix.test.ts | 27 ++ ...rowser-cookie-import-undecryptable.test.ts | 259 ++++++++++++++++++ src/main/browser/browser-cookie-import.ts | 155 ++++++++--- src/renderer/src/i18n/locales/en.json | 7 +- .../lib/browser-cookie-import-toast.test.ts | 21 ++ .../src/lib/browser-cookie-import-toast.ts | 33 +++ src/shared/browser-workspace-types.ts | 20 +- 7 files changed, 485 insertions(+), 37 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-app-bound-prefix.test.ts create mode 100644 src/main/browser/browser-cookie-import-undecryptable.test.ts diff --git a/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts b/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts new file mode 100644 index 00000000000..0109269248b --- /dev/null +++ b/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { isAppBoundEncryptedCookie } from './browser-cookie-import' + +function encryptedCookie(versionPrefix: string): Buffer { + return Buffer.concat([Buffer.from(versionPrefix), Buffer.from([0xde, 0xad, 0xbe, 0xef])]) +} + +describe('isAppBoundEncryptedCookie', () => { + it('detects the v20 app-bound prefix', () => { + expect(isAppBoundEncryptedCookie(encryptedCookie('v20'))).toBe(true) + }) + + it.each(['v10', 'v11'])('leaves the decryptable %s prefix alone', (prefix) => { + expect(isAppBoundEncryptedCookie(encryptedCookie(prefix))).toBe(false) + }) + + it('does not treat a plaintext value as app-bound', () => { + expect(isAppBoundEncryptedCookie(Buffer.from('session=abc123'))).toBe(false) + }) + + it.each([ + ['empty', Buffer.alloc(0)], + ['shorter than the prefix', Buffer.from('v2')] + ])('handles a buffer %s without throwing', (_label, buffer) => { + expect(isAppBoundEncryptedCookie(buffer)).toBe(false) + }) +}) diff --git a/src/main/browser/browser-cookie-import-undecryptable.test.ts b/src/main/browser/browser-cookie-import-undecryptable.test.ts new file mode 100644 index 00000000000..e79485bef20 --- /dev/null +++ b/src/main/browser/browser-cookie-import-undecryptable.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeCrypto from 'node:crypto' +import type * as NodeFs from 'node:fs' + +const { + appGetPathMock, + createDecipherivMock, + execFileSyncMock, + sessionFromPartitionMock, + dialogShowOpenDialogMock, + setPendingCookieImportMock, + clearPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + createDecipherivMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn() +})) + +vi.mock('node:crypto', async (importOriginal) => { + const original = await importOriginal() + createDecipherivMock.mockImplementation(original.createDecipheriv) + return { ...original, createDecipheriv: createDecipherivMock } +}) +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + BrowserWindow: { fromWebContents: vi.fn() }, + dialog: { showOpenDialog: dialogShowOpenDialogMock }, + session: { fromPartition: sessionFromPartitionMock } +})) +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { + get: (filter: object) => Promise + remove: (url: string, name: string) => Promise + } + }) => ({ + get: (filter: object) => targetSession.cookies.get(filter), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie, url }) => ({ url, ...cookie })), + restoreClearIdentities: async () => undefined, + dispose: () => undefined + }) +})) + +import { importCookiesFromBrowser, type DetectedBrowser } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createCipheriv, pbkdf2Sync } from 'node:crypto' + +// Why: Linux derives its cookie key with a single PBKDF2 round, unlike the 1003 macOS uses, so +// the shared macOS helper cannot produce a row this code path will decrypt. +function encryptLinuxChromiumCookie(value: string, password: string, prefix: string): Buffer { + const key = pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1') + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' ')) + return Buffer.concat([ + Buffer.from(prefix), + cipher.update(Buffer.from(value, 'latin1')), + cipher.final() + ]) +} + +function chromeBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'chrome', + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} + +describe('importCookiesFromBrowser — undecryptable cookies', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + let platformSpy: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-linux-keyring-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + sessionFromPartitionMock.mockReturnValue({ + cookies: { + get: vi.fn().mockResolvedValue([]), + set: cookiesSetMock, + remove: vi.fn().mockResolvedValue(undefined), + flushStore: vi.fn().mockResolvedValue(undefined) + }, + clearData: vi.fn().mockResolvedValue(undefined), + setUserAgent: vi.fn() + }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + // Why: both secret-tool lookups failing is exactly the keyring-unavailable case. + execFileSyncMock.mockImplementation(() => { + throw new Error('secret-tool: no such service') + }) + }) + + afterEach(() => { + platformSpy.mockRestore() + vi.clearAllMocks() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function seedProfile(...encryptedValues: Buffer[]): string { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase( + sourceCookiesPath, + encryptedValues.map((encryptedValue, index) => ({ + name: `sid-${index}`, + value: '', + encryptedValue + })) + ).close() + createChromiumCookieTestDatabase( + join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'), + [] + ).close() + return sourceCookiesPath + } + + it('does not attempt v11 CBC decryption without a keyring, even with valid wrong-key padding', async () => { + const sourceCookiesPath = seedProfile( + encryptLinuxChromiumCookie('wrong-key-garbage', 'peanuts', 'v11') + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(createDecipherivMock).not.toHaveBeenCalled() + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'linux-keyring-unavailable' + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it.each(['v10', 'v99'])( + 'rejects a prefix-only %s CBC row before importing it', + async (prefix) => { + const sourceCookiesPath = seedProfile(Buffer.from(prefix)) + + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(createDecipherivMock).not.toHaveBeenCalled() + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'unknown' + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + } + ) + + it('reports Windows app-bound (v20) cookies rather than an empty success', async () => { + // Why: Chrome/Edge 140+ v20 rows can only be unwrapped by the writing browser. + const sourceCookiesPath = seedProfile( + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])]) + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'app-bound-encryption' + }) + }) + + it('still imports v10 cookies, which is what a keyring-less profile actually holds', async () => { + const sourceCookiesPath = seedProfile(encryptLinuxChromiumCookie('v10-value', 'peanuts', 'v10')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + // Mutation guard: replacing the keyring-unavailable branch with `return null` must break v10. + expect(result.summary.importedCookies).toBe(1) + expect(result.summary.warning).toBeUndefined() + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid-0', value: 'v10-value' }) + ) + }) + + it('uses an unknown warning when app-bound and corrupt failures are tied', async () => { + const sourceCookiesPath = seedProfile( + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])]), + Buffer.from('v99-corrupt') + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 2, + reason: 'unknown' + }) + }) + + it('reports a dominant app-bound cause with its exact count and the remainder', async () => { + const appBoundValues = Array.from({ length: 200 }, () => + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])]) + ) + const sourceCookiesPath = seedProfile(...appBoundValues, Buffer.from('v99-corrupt')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 200, + otherFailedCookies: 1, + reason: 'app-bound-encryption' + }) + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 2e2f50dbdd1..581b2b2222b 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -823,12 +823,13 @@ function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { // Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. -type EncryptionKeyResult = { - key: Buffer - mode: 'aes-128-cbc' | 'aes-256-gcm' - // Why: Linux v10 cookies use "peanuts" and v11 the keyring password; both keys are needed to decrypt the full set. - fallbackKey?: Buffer -} +type EncryptionKeyResult = + | { + mode: 'aes-128-cbc' + keysByVersion: Partial> + keyringUnavailable?: boolean + } + | { mode: 'aes-256-gcm'; key: Buffer } export type ChromiumCookieColumnInfo = { name: string @@ -971,8 +972,10 @@ function getMacEncryptionKey( { encoding: 'utf-8', timeout: 30_000 } ).trim() return { - key: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1'), - mode: 'aes-128-cbc' + mode: 'aes-128-cbc', + keysByVersion: { + v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } } } catch { return null @@ -983,7 +986,8 @@ function getLinuxEncryptionKey( keychainService: string, keychainAccount: string ): EncryptionKeyResult | null { - // Why: v10 cookies use hardcoded "peanuts", v11 the keyring password; derive both so decrypt can pick by version prefix. + // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded + // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') let keyringPassword = '' @@ -1003,12 +1007,20 @@ function getLinuxEncryptionKey( timeout: 5_000 }).trim() } catch { - diag(' Linux keyring unavailable — v11 cookies may fail to decrypt') + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') + } + } + + if (!keyringPassword) { + return { + mode: 'aes-128-cbc', + keysByVersion: { v10: v10Key }, + keyringUnavailable: true } } const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - return { key: v11Key, mode: 'aes-128-cbc', fallbackKey: v10Key } + return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } } function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { @@ -1084,6 +1096,53 @@ function stripHmac(buf: Buffer): Buffer { return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf } +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: a named cause must carry only its exact count; tied causes fall back to unknown. +function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const unknownFailed = + counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed + const rankedCauses = [ + { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, + { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, + { reason: 'unknown' as const, count: unknownFailed } + ].sort((left, right) => right.count - left.count) + const [dominant, runnerUp] = rankedCauses + + if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } + } + + const otherFailedCookies = counts.decryptFailed - dominant.count + return { + code: 'cookies-undecryptable', + failedCookies: dominant.count, + reason: dominant.reason, + ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) + } +} + function decryptCookieValueRaw( encryptedBuffer: Buffer, keyResult: EncryptionKeyResult @@ -1101,29 +1160,25 @@ function decryptCookieValueRaw( } // AES-128-CBC (macOS and Linux) + const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined + if (!key) { + return null + } + const ciphertext = encryptedBuffer.subarray(3) if (!ciphertext.length) { - return Buffer.alloc(0) + return null } - // Why: Linux v10 uses the "peanuts" key, v11 the keyring key; try primary then fallback (macOS uses one key). - const keysToTry = - version === 'v10' && keyResult.fallbackKey - ? [keyResult.fallbackKey, keyResult.key] - : [keyResult.key, ...(keyResult.fallbackKey ? [keyResult.fallbackKey] : [])] - - for (const key of keysToTry) { - try { - const iv = Buffer.alloc(16, ' ') - const decipher = createDecipheriv('aes-128-cbc', key, iv) - decipher.setAutoPadding(true) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - continue - } + try { + const iv = Buffer.alloc(16, ' ') + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null } - return null } function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { @@ -1630,6 +1685,9 @@ export async function importCookiesFromBrowser( let imported = 0 let skipped = 0 + let decryptFailed = 0 + let appBoundFailed = 0 + let keyringUnavailableFailed = 0 let integritySkipped = 0 let nonTransplantableSkipped = 0 let memoryLoaded = 0 @@ -1697,8 +1755,26 @@ export async function importCookiesFromBrowser( let decryptedValue: Buffer if (encBuf && encBuf.length > 0) { - const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null if (!raw) { + // Why: once decrypt returns null every failure looks identical, so attribute the cause + // here while the version prefix is still in hand. Without this an undecryptable profile + // is indistinguishable from an empty one and reports success. + decryptFailed++ + if (appBoundIneligible) { + appBoundFailed++ + } else if (keyringIneligible) { + keyringUnavailableFailed++ + } skipped++ continue } @@ -1765,7 +1841,14 @@ export async function importCookiesFromBrowser( ) const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const undecryptableWarning = buildUndecryptableWarning({ + decryptFailed, + appBoundFailed, + keyringUnavailableFailed + }) + if (decryptedCookies.length === 0) { + const zeroPathWarning = undecryptableWarning closeStagingDb() discardStagingFile() return { @@ -1776,7 +1859,10 @@ export async function importCookiesFromBrowser( importedCookies: 0, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - domains: [] + domains: [], + // Why: a profile whose rows cannot be decrypted returns here, and without this it is + // reported as a successful empty import. + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) } } } @@ -1874,6 +1960,13 @@ export async function importCookiesFromBrowser( // Google-bound integrity cookies are already excluded by // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. + // Why: a partial import still drops every undecryptable row, so silence here would report it + // as an unqualified success. The restart-fallback warning describes a lossier outcome and + // keeps precedence. + if (!warning && undecryptableWarning) { + warning = undecryptableWarning + } + const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3477f7aef38..ab43728ae84 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -783,7 +783,12 @@ "toast": { "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.", - "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google." + "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google.", + "undecryptableAppBound": "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption. You can import cookies from a file using “From File…”.", + "undecryptableAppBoundMixed": "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption; {{value1}} more could not be decrypted for another reason. You can import cookies from a file using “From File…”.", + "undecryptableKeyring": "{{value0}} cookies could not be decrypted because the system keyring was unavailable. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.", + "undecryptableKeyringMixed": "{{value0}} cookies could not be decrypted because the system keyring was unavailable; {{value1}} more could not be decrypted for another reason. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.", + "undecryptableUnknown": "{{value0}} cookies could not be decrypted and were skipped. Close the source browser completely and try the import again." } } } diff --git a/src/renderer/src/lib/browser-cookie-import-toast.test.ts b/src/renderer/src/lib/browser-cookie-import-toast.test.ts index 3479887b419..23e04c53034 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.test.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.test.ts @@ -52,6 +52,27 @@ describe('emitBrowserCookieImportToast', () => { expect(warningToastMock).not.toHaveBeenCalled() }) + it('offers the in-app file import without recommending an exporter', () => { + emitBrowserCookieImportToast( + { + ...summary, + warning: { + code: 'cookies-undecryptable', + failedCookies: 3, + reason: 'app-bound-encryption' + } + }, + 'Imported 0 cookies.', + 'Local Windows' + ) + + const message = warningToastMock.mock.calls[0]?.[0] + expect(message).toBe( + "Orca cannot decrypt 3 of this browser's cookies because they use app-bound encryption. You can import cookies from a file using “From File…”." + ) + expect(message).not.toContain('export') + }) + it('shows separate host-specific Google guidance after success', () => { emitBrowserCookieImportToast( { ...summary, importedCookies: 2, skippedCookies: 1, googleCookiesSkipped: 1 }, diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index 9e18e3c96c8..a8fa4aa6bae 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -21,6 +21,39 @@ function formatCookieImportWarning(warning: CookieImportWarning): string { value1: warning.loadedCookies + warning.failedCookies } ) + case 'cookies-undecryptable': + switch (warning.reason) { + case 'app-bound-encryption': + return warning.otherFailedCookies + ? translate( + 'auto.lib.browser.cookie.import.toast.undecryptableAppBoundMixed', + "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption; {{value1}} more could not be decrypted for another reason. You can import cookies from a file using “From File…”.", + { value0: warning.failedCookies, value1: warning.otherFailedCookies } + ) + : translate( + 'auto.lib.browser.cookie.import.toast.undecryptableAppBound', + "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption. You can import cookies from a file using “From File…”.", + { value0: warning.failedCookies } + ) + case 'linux-keyring-unavailable': + return warning.otherFailedCookies + ? translate( + 'auto.lib.browser.cookie.import.toast.undecryptableKeyringMixed', + '{{value0}} cookies could not be decrypted because the system keyring was unavailable; {{value1}} more could not be decrypted for another reason. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.', + { value0: warning.failedCookies, value1: warning.otherFailedCookies } + ) + : translate( + 'auto.lib.browser.cookie.import.toast.undecryptableKeyring', + '{{value0}} cookies could not be decrypted because the system keyring was unavailable. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.', + { value0: warning.failedCookies } + ) + case 'unknown': + return translate( + 'auto.lib.browser.cookie.import.toast.undecryptableUnknown', + '{{value0}} cookies could not be decrypted and were skipped. Close the source browser completely and try the import again.', + { value0: warning.failedCookies } + ) + } } } diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts index 67619decd58..dbd796833b3 100644 --- a/src/shared/browser-workspace-types.ts +++ b/src/shared/browser-workspace-types.ts @@ -143,11 +143,21 @@ export type BrowserCookieImportSummary = { skippedCookies: number googleCookiesSkipped?: number domains: string[] - warning?: { - code: 'restart-fallback-unavailable' - loadedCookies: number - failedCookies: number - } + warning?: + | { + code: 'restart-fallback-unavailable' + loadedCookies: number + failedCookies: number + } + | { + // Why: a row that will not decrypt is indistinguishable from a corrupt one by the time + // decrypt returns null, so the cause is captured at the point of failure and reported + // here. Without this an undecryptable profile is reported as a successful empty import. + code: 'cookies-undecryptable' + failedCookies: number + otherFailedCookies?: number + reason: 'app-bound-encryption' | 'linux-keyring-unavailable' | 'unknown' + } } export type BrowserCookieImportResult = From 21ed09e45c66f52480c5fc6073178eb8f4784369 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:51:02 -0700 Subject: [PATCH 11/28] Bump mobile app.json to 0.0.44 (#14983) --- mobile/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/app.json b/mobile/app.json index 34cc8de3be5..6b6d43f8f0e 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.43", + "version": "0.0.44", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 12 + "versionCode": 13 }, "plugins": [ "expo-router", From b6d5972ec4539a96be5a70bfc611517e780e7c3d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:09:15 -0700 Subject: [PATCH 12/28] fix(mobile): reland truthful Relay recovery status (#14986) --- config/reliability-gates.jsonc | 58 +++++++++----- mobile/app/h/[hostId]/index.tsx | 5 +- .../app/h/[hostId]/session/[worktreeId].tsx | 5 +- mobile/app/h/[hostId]/tasks.tsx | 5 +- mobile/app/index.tsx | 7 +- mobile/src/components/MobileHostCard.test.tsx | 11 +-- mobile/src/components/MobileHostCard.tsx | 7 +- .../client-context-connection-metrics.ts | 5 ++ mobile/src/transport/client-context.test.ts | 40 ++++++++++ mobile/src/transport/client-context.tsx | 20 ++--- .../src/transport/connection-health.test.ts | 34 ++++++++- mobile/src/transport/connection-health.ts | 14 ++++ .../transport/host-client-context-state.ts | 9 ++- mobile/src/transport/host-entry-opener.ts | 13 +++- .../logical-client-connection-path.ts | 46 ++++++++++++ .../mobile-endpoint-supervisor-test-fakes.ts | 20 ++++- .../mobile-endpoint-supervisor.test.ts | 75 +++++++++++++++++++ .../transport/mobile-endpoint-supervisor.ts | 21 ++++-- .../mobile-relay-reconnect-controller.ts | 36 ++++----- .../mobile-relay-recovery-presentation.ts | 29 +++++++ .../mobile-relay-runtime-failover.test.ts | 24 ++++++ .../transport/relay-credential-eligibility.ts | 41 ++++++++++ .../transport/rpc-client-context-contract.ts | 1 + .../stable-logical-rpc-client.test.ts | 37 ++++++++- .../transport/stable-logical-rpc-client.ts | 22 ++++-- mobile/src/transport/use-all-host-clients.ts | 11 ++- 26 files changed, 513 insertions(+), 83 deletions(-) create mode 100644 mobile/src/transport/logical-client-connection-path.ts create mode 100644 mobile/src/transport/mobile-relay-recovery-presentation.ts create mode 100644 mobile/src/transport/relay-credential-eligibility.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c9ec16d7d9a..e91aee76a78 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -943,10 +943,10 @@ "providers": ["lan", "tailscale", "cloud-relay"], "coveredPlatforms": ["macos"], "coveredProviders": ["lan", "tailscale", "cloud-relay"], - "coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.", + "coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.", "motivatingLinks": ["https://github.com/stablyai/orca-cloud/pull/96"], - "invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.", - "oracle": "Inject deterministic relay close codes, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.", + "invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, and remains pending through a genuine failed-dial cooldown. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.", + "oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.", "commands": [ "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot" @@ -975,7 +975,10 @@ { "file": "mobile/src/transport/mobile-endpoint-supervisor.test.ts", "assertions": [ - "a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal" + "a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal", + "a missing or expired credential never presents a Relay connection that cannot start", + "backgrounding during credential selection cannot publish a stale Relay recovery path", + "a genuine failed Relay dial remains pending through its retry cooldown" ] }, { @@ -985,21 +988,21 @@ ], "evidenceRuns": [ { - "date": "2026-07-25", + "date": "2026-08-16", "runner": "local", "platform": "macos", "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", "result": "passed", - "durationSeconds": 0.89, - "summary": "Three focused mobile transport files passed with 39 assertions." + "durationSeconds": 1.23, + "summary": "Three focused mobile transport files passed with 54 assertions." }, { - "date": "2026-07-25", + "date": "2026-08-16", "runner": "local", "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 0.19, + "durationSeconds": 0.17, "summary": "The shared close-code contract passed with five assertions." } ], @@ -1049,14 +1052,14 @@ "providers": ["lan", "tailscale", "cloud-relay"], "coveredPlatforms": ["macos"], "coveredProviders": ["lan", "tailscale", "cloud-relay"], - "coverageNotes": "Deterministic React/provider tests cover catalog, missing-host, construction, cancellation, release, forget, refresh, backoff, and stale-generation outcomes. Direct sessions cover idle probing and three fair misses. Relay sessions cover zero idle polling, rate-limited foreground probes, two fair misses, authenticated activity before semantic decoding, delivery ambiguity, scheduler stalls, and write failure. Physical iOS/Android sleep, carrier/Tailscale loss, and production relay paths remain live-test gaps.", + "coverageNotes": "Deterministic React/provider tests cover catalog, missing-host, construction, cancellation, release, forget, refresh, backoff, stale-generation outcomes, and path-aware recovery presentation. Direct sessions cover idle probing and three fair misses. Relay sessions cover zero idle polling, rate-limited foreground probes, two fair misses, authenticated activity before semantic decoding, delivery ambiguity, scheduler stalls, and write failure. Physical iOS/Android sleep, carrier/Tailscale loss, and production relay paths remain live-test gaps.", "motivatingLinks": [ "https://linear.app/stably/issue/STA-4153/mobile-app-disconnects-frequently" ], - "invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions emit no periodic idle or demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death.", - "oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay zero idle and demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay.", + "invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions emit no periodic idle or demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death. Relay recovery must notify presentation independently of transport-state changes, must not inherit a failed direct path's Tailscale hint, and must retain long-outage escalation.", + "oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay zero idle and demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay. Start and clear Relay recovery without changing transport state and require observers to rerender. Project five failed Tailscale attempts into a neutral Relay-specific verdict, then require twelve attempts to produce a Relay-specific unreachable verdict with no Tailscale hint.", "commands": [ - "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts" + "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts" ], "testFiles": [ "mobile/src/transport/client-context.test.ts", @@ -1068,12 +1071,16 @@ "mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts", "mobile/src/transport/mobile-endpoint-nudge-router.test.ts", "mobile/src/transport/mobile-relay-e2ee-link.test.ts", - "mobile/src/transport/stable-logical-rpc-client.test.ts" + "mobile/src/transport/stable-logical-rpc-client.test.ts", + "mobile/src/transport/connection-health.test.ts" ], "assertionRefs": [ { "file": "mobile/src/transport/client-context.test.ts", - "assertions": ["a retired close-on-release owner closes an ownerless manual reconnect"] + "assertions": [ + "a retired close-on-release owner closes an ownerless manual reconnect", + "a Relay recovery path change rerenders observers without a transport-state change" + ] }, { "file": "mobile/src/transport/host-open-recovery.test.tsx", @@ -1124,18 +1131,29 @@ }, { "file": "mobile/src/transport/stable-logical-rpc-client.test.ts", - "assertions": ["a delivery-unknown mutation is not replayed onto a replacement Relay"] + "assertions": [ + "a delivery-unknown mutation is not replayed onto a replacement Relay", + "Relay recovery path changes publish and remain pending between failed dials", + "a connected path clears recovery state before any later disconnect" + ] + }, + { + "file": "mobile/src/transport/connection-health.test.ts", + "assertions": [ + "a Relay fallback pending over five failed Tailscale attempts remains neutral and omits the Tailscale hint", + "a prolonged Relay recovery escalates without reviving the Tailscale hint" + ] } ], "evidenceRuns": [ { - "date": "2026-08-14", + "date": "2026-08-16", "runner": "local", "platform": "macos", - "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts", "result": "passed", - "durationSeconds": 0.74, - "summary": "Ten focused files passed 66 deterministic provider lifecycle, direct/Relay liveness, traffic, and delivery-ambiguity tests." + "durationSeconds": 0.77, + "summary": "Eleven focused files passed 85 deterministic provider lifecycle, path presentation, direct/Relay liveness, traffic, and delivery-ambiguity tests." } ], "runtimeBudget": { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 9cf10c94356..dd4fd09516a 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -32,6 +32,7 @@ import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh' import { useLastConnectedAt, + usePendingConnectionPath, useReconnectAttempt } from '../../../src/transport/client-context-connection-metrics' import { @@ -138,6 +139,7 @@ export function HostScreen({ const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) + const pendingConnectionPath = usePendingConnectionPath(hostId) const clientRef = useRef(null) const fetchWorktreesInFlightRef = useRef(false) // Why: useRef, not useMemo — React may discard memoized values, which would silently @@ -817,7 +819,8 @@ export function HostScreen({ const headerVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt + lastConnectedAt, + pendingPath: pendingConnectionPath }) return ( <> diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7ab26ae55da..6cff5a841fd 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -61,6 +61,7 @@ import { import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' import { useLastConnectedAt, + usePendingConnectionPath, useReconnectAttempt } from '../../../../src/transport/client-context-connection-metrics' import { @@ -736,6 +737,7 @@ export default function SessionScreen() { const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) + const pendingConnectionPath = usePendingConnectionPath(hostId) const forceReconnectHost = useForceReconnect() const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({ client, @@ -4171,7 +4173,8 @@ export default function SessionScreen() { state: connState, reconnectAttempts, lastConnectedAt, - endpoint: hostEndpoint + endpoint: hostEndpoint, + pendingPath: pendingConnectionPath }) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 717e4640516..7f96f2db903 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -37,6 +37,7 @@ import type { RpcSuccess } from '../../../src/transport/types' import { useHostClient } from '../../../src/transport/client-context' import { useLastConnectedAt, + usePendingConnectionPath, useReconnectAttempt } from '../../../src/transport/client-context-connection-metrics' import { classifyConnection } from '../../../src/transport/connection-health' @@ -2073,6 +2074,7 @@ export default function MobileTasksScreen() { const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) + const pendingConnectionPath = usePendingConnectionPath(hostId) const clientRef = useRef(null) const loadGenerationRef = useRef(0) const taskResumeRef = useRef({}) @@ -8673,7 +8675,8 @@ export default function MobileTasksScreen() { const headerVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt + lastConnectedAt, + pendingPath: pendingConnectionPath }) const emptyLabel = connState !== 'connected' diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 66b4ebf4b62..6a2ce2ac1d0 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -259,6 +259,10 @@ export default function HomeScreen() { () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), [allClients] ) + const hostPendingPaths = useMemo( + () => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])), + [allClients] + ) const disconnectHostClient = useDisconnectHostClient() const forgetHostClient = useForgetHostClient() const forceReconnectHost = useForceReconnect() @@ -765,7 +769,8 @@ export default function HomeScreen() { state, reconnectAttempts: attempts, lastConnectedAt, - endpoint: item.endpoint + endpoint: item.endpoint, + pendingPath: hostPendingPaths[item.id] ?? null }) return ( { it('names the relay while the dial is still in flight', async () => { const lines = await renderCard(undefined, { state: 'connecting', - verdict: { kind: 'normal', label: 'Connecting…' }, + verdict: { kind: 'normal', label: 'Connecting via Relay…' }, path: 'relay' }) - expect(lines).toContain('Connecting…') - expect(lines).toContain(' · Orca Relay') + expect(lines).toContain('Connecting via Relay…') + expect(lines).not.toContain(' · Orca Relay') }) it('names the relay while a failed direct dial is still retrying', async () => { const lines = await renderCard(undefined, { state: 'reconnecting', - verdict: { kind: 'normal', label: 'Reconnecting…' }, + verdict: { kind: 'normal', label: 'Connecting via Relay…' }, path: 'relay' }) - expect(lines).toContain(' · Orca Relay') + expect(lines).toContain('Connecting via Relay…') + expect(lines).not.toContain(' · Orca Relay') }) it('leaves an idle disconnected host unlabelled', async () => { diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index a09ae0f3cf7..c2637ca1855 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -25,11 +25,6 @@ export function MobileHostCard(props: { const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable' const credentialMissing = props.credentialStatus === 'missing' const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing - // Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the - // path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is - // named — 'lan' doubles as the unknown-path default, so it would be a guess before connect. - const dialingPath = - ['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay' const isError = credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) const statusLabel = credentialMissing @@ -44,7 +39,7 @@ export function MobileHostCard(props: { : props.verdict const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) const connectionPathLabel = - !credentialMissing && !credentialUnavailable && (connected || dialingPath) + !credentialMissing && !credentialUnavailable && connected ? mobileConnectionPathLabel(props.path) : null const discoveryHint = diff --git a/mobile/src/transport/client-context-connection-metrics.ts b/mobile/src/transport/client-context-connection-metrics.ts index 391c0cb0568..9fb0df4913d 100644 --- a/mobile/src/transport/client-context-connection-metrics.ts +++ b/mobile/src/transport/client-context-connection-metrics.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { useRpcClientContext, type RpcClientContextValue } from './client-context' +import type { MobileConnectionPath } from './stable-logical-rpc-client' export function useReconnectAttempt(hostId: string | undefined): number { return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0) @@ -9,6 +10,10 @@ export function useLastConnectedAt(hostId: string | undefined): number | null { return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null) } +export function usePendingConnectionPath(hostId: string | undefined): MobileConnectionPath | null { + return useHostMetric(hostId, (context, id) => context.getPendingPath(id), null) +} + function useHostMetric( hostId: string | undefined, read: (context: RpcClientContextValue, hostId: string) => T, diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 69f531ef45f..8b109c49207 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionState } from './types' import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' const connectMock = vi.fn() const loadHostsMock = vi.fn() @@ -31,12 +32,15 @@ import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void + emitPendingPath: (path: MobileConnectionPath | null) => void closeMock: ReturnType } function makeFakeClient(initialState: ConnectionState): FakeClient { let state = initialState + let pendingPath: MobileConnectionPath | null = null const listeners = new Set<(state: ConnectionState) => void>() + const pathListeners = new Set<() => void>() const closeMock = vi.fn() return { sendRequest: vi.fn(), @@ -45,6 +49,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient { getState: () => state, getReconnectAttempt: () => 0, getLastConnectedAt: () => null, + getActivePath: () => 'tailscale', + getPendingPath: () => pendingPath, + onConnectionPathChange: (listener: () => void) => { + pathListeners.add(listener) + return () => pathListeners.delete(listener) + }, onStateChange: (listener) => { listeners.add(listener) return () => listeners.delete(listener) @@ -57,6 +67,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient { for (const listener of listeners) { listener(next) } + }, + emitPendingPath: (next) => { + pendingPath = next + for (const listener of pathListeners) { + listener() + } } } as FakeClient } @@ -393,6 +409,30 @@ describe('useHostClient', () => { }) describe('useAllHostClients', () => { + it('rerenders when Relay becomes pending without a transport-state change', async () => { + const client = makeFakeClient('reconnecting') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValue([HOST]) + let pendingPath: MobileConnectionPath | null | undefined + let renderer!: ReturnType + + function Probe(): null { + pendingPath = useAllHostClients([HOST.id])[0]?.pendingPath + return null + } + + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(pendingPath).toBeNull() + + act(() => client.emitPendingPath('relay')) + expect(pendingPath).toBe('relay') + + act(() => renderer.unmount()) + }) + it('only opens the requested startup subset', async () => { const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } connectMock.mockReturnValue(makeFakeClient('connected')) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 3e241b71270..6e4d9014ad1 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -76,6 +76,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { primedHostsRef.current.delete(hostId) } entry?.unsubState() + entry?.unsubConnectionPath() storeRef.current.delete(hostId) entry?.client.close() notifyHostState(hostId, 'disconnected') @@ -242,6 +243,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { manualDemandRef.current.add(hostId) if (entry) { entry.unsubState() + entry.unsubConnectionPath() entry.client.close() storeRef.current.delete(hostId) } @@ -260,8 +262,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { [openEntry] ) - const { getKnownState, getState, getReconnectAttempt, getLastConnectedAt, getActivePath } = - useMemo(() => createHostClientSelectors(storeRef.current, pendingOpensRef.current), []) + const selectors = useMemo( + () => createHostClientSelectors(storeRef.current, pendingOpensRef.current), + [] + ) const subscribeHostState = useCallback( (hostId: string, listener: (state: ConnectionState) => void) => @@ -318,11 +322,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { refreshHostClient, forgetHostClient, disconnectHostClient, - getState, - getKnownState, - getReconnectAttempt, - getLastConnectedAt, - getActivePath, + ...selectors, subscribeHostState, getAllClients, subscribeAllHosts, @@ -337,11 +337,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { refreshHostClient, forgetHostClient, disconnectHostClient, - getState, - getKnownState, - getReconnectAttempt, - getLastConnectedAt, - getActivePath, + selectors, subscribeHostState, getAllClients, subscribeAllHosts, diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts index 5ac61c06a93..ac135ab2bbb 100644 --- a/mobile/src/transport/connection-health.test.ts +++ b/mobile/src/transport/connection-health.test.ts @@ -25,7 +25,8 @@ describe('classifyConnection Tailscale hint', () => { const verdict = classifyConnection({ ...base, reconnectAttempts: 3, - endpoint: 'ws://100.65.9.106:6768' + endpoint: 'ws://100.65.9.106:6768', + pendingPath: null }) expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' }) }) @@ -69,6 +70,37 @@ describe('classifyConnection Tailscale hint', () => { }) expect(verdict).toEqual({ kind: 'normal', label: 'Connected' }) }) + + it('presents an in-flight relay fallback instead of the failed Tailscale path', () => { + const incident = { + ...base, + reconnectAttempts: 5, + endpoint: 'ws://100.88.90.25:6768', + pendingPath: 'relay' as const + } + + for (const state of ['connecting', 'handshaking', 'reconnecting', 'disconnected'] as const) { + const verdict = classifyConnection({ ...incident, state }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connecting via Relay…' }) + expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale') + } + }) + + it('escalates a prolonged relay recovery without reviving the Tailscale hint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 12, + endpoint: 'ws://100.88.90.25:6768', + pendingPath: 'relay' + }) + + expect(verdict).toEqual({ + kind: 'unreachable', + label: "Can't connect via Relay", + reason: 'never-connected' + }) + expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale') + }) }) // Issue #10119: every redial re-enters 'connecting', which used to revert an diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 6774b111ff5..2afa880e8d4 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -1,4 +1,5 @@ import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState } from './types' // Why: thresholds for escalating connection UX from neutral @@ -49,6 +50,7 @@ export function classifyConnection(args: { // Optional pinned host endpoint — enables the Tailscale hint on // warning/unreachable verdicts. Callers without it get plain labels. endpoint?: string | null + pendingPath?: MobileConnectionPath | null nowMs?: number }): ConnectionVerdict { const { state, reconnectAttempts, lastConnectedAt } = args @@ -65,6 +67,18 @@ export function classifyConnection(args: { return { kind: 'normal', label: 'Connected' } } + if (args.pendingPath === 'relay') { + if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) { + if (lastConnectedAt == null) { + return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'never-connected' } + } + if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { + return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'stale' } + } + } + return { kind: 'normal', label: 'Connecting via Relay…' } + } + if (state === 'disconnected') { return { kind: 'normal', label: 'Disconnected' } } diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts index 2aa7ca36400..9b7b4cc9022 100644 --- a/mobile/src/transport/host-client-context-state.ts +++ b/mobile/src/transport/host-client-context-state.ts @@ -84,7 +84,9 @@ export function createHostClientSelectors( getLastConnectedAt: (hostId: string): number | null => entries.get(hostId)?.client.getLastConnectedAt() ?? null, getActivePath: (hostId: string): MobileConnectionPath => - clientActivePath(entries.get(hostId)?.client) + clientActivePath(entries.get(hostId)?.client), + getPendingPath: (hostId: string): MobileConnectionPath | null => + clientPendingPath(entries.get(hostId)?.client) } } @@ -96,3 +98,8 @@ export function clientActivePath(client: RpcClient | undefined): MobileConnectio // Why: during migration the pending path is what the user is waiting on. return logical.getPendingPath?.() ?? logical.getActivePath() } + +export function clientPendingPath(client: RpcClient | undefined): MobileConnectionPath | null { + const logical = client as Partial | undefined + return logical?.getPendingPath?.() ?? null +} diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 220b63abc64..092d205bb75 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -4,6 +4,7 @@ import { openHostLogicalClient } from './host-logical-client' import type { HostClientOpenRegistry } from './host-client-open-registry' import type { HostOpenRetryScheduler } from './host-open-retry-scheduler' import type { RpcClient } from './rpc-client' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState, HostProfile } from './types' export type HostClientStoreEntry = { @@ -11,6 +12,7 @@ export type HostClientStoreEntry = { state: ConnectionState refCount: number unsubState: () => void + unsubConnectionPath: () => void } type HostEntryOpenerState = { @@ -113,11 +115,20 @@ export async function openHostClientEntry( current.state = next state.notifyHostState(hostId, next) }) + const logical = client as Partial + const unsubConnectionPath = + logical.onConnectionPathChange?.(() => { + const current = state.store.get(hostId) + if (current) { + state.notifyHostState(hostId, current.state) + } + }) ?? (() => {}) const entry: HostClientStoreEntry = { client, state: client.getState(), refCount: state.pendingAcquisitions.get(hostId) ?? 0, - unsubState + unsubState, + unsubConnectionPath } state.pendingAcquisitions.delete(hostId) state.store.set(hostId, entry) diff --git a/mobile/src/transport/logical-client-connection-path.ts b/mobile/src/transport/logical-client-connection-path.ts new file mode 100644 index 00000000000..cec69bc0118 --- /dev/null +++ b/mobile/src/transport/logical-client-connection-path.ts @@ -0,0 +1,46 @@ +import type { MobileConnectionPath } from './stable-logical-rpc-client' + +export class LogicalClientConnectionPath { + private migration: MobileConnectionPath | null = null + private recovery: MobileConnectionPath | null = null + private readonly listeners = new Set<() => void>() + + constructor(private readonly isConnected: () => boolean) {} + + pending(): MobileConnectionPath | null { + return this.isConnected() ? null : (this.migration ?? this.recovery) + } + + setMigration(path: MobileConnectionPath | null): void { + this.update(() => { + this.migration = path + }) + } + + clearAfterConnected(): void { + this.migration = null + this.recovery = null + } + + setRecovery(path: MobileConnectionPath | null): void { + this.update(() => { + this.recovery = path + }) + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private update(apply: () => void): void { + const previous = this.pending() + apply() + if (previous === this.pending()) { + return + } + for (const listener of this.listeners) { + listener() + } + } +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 2ef13c0b590..affa4d12636 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -65,7 +65,9 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { private path: MobileConnectionPath + private recoveryPath: MobileConnectionPath | null = null private generation = 1 + private readonly pathListeners = new Set<() => void>() constructor(state: ConnectionState, path: MobileConnectionPath) { super(state) @@ -89,14 +91,28 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl throw new Error('migration superseded') } this.path = path + this.recoveryPath = null this.generation += 1 + // Connected-state publication carries the migration cleanup. this.publishState('connected') } ) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) getActivePath = () => this.path - // This fake migrates instantly, so no dial is ever in flight to name. - getPendingPath = () => null + getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath) + setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { + const previous = this.getPendingPath() + this.recoveryPath = path + if (previous !== this.getPendingPath()) { + for (const listener of this.pathListeners) { + listener() + } + } + }) + onConnectionPathChange = vi.fn((listener: () => void) => { + this.pathListeners.add(listener) + return () => this.pathListeners.delete(listener) + }) getGeneration = () => this.generation } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index bddc09795be..96a60f6e11d 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -91,6 +91,63 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('does not announce Relay when credential selection finishes after backgrounding', async () => { + const logical = new FakeLogicalClient('reconnecting', 'lan') + let finishCredentialRead: ((value: MobileRelayCredentialBundle) => void) | undefined + const credentialReadPending = new Promise((resolve) => { + finishCredentialRead = resolve + }) + const readBundle = vi + .fn() + .mockResolvedValueOnce(null) + .mockReturnValueOnce(credentialReadPending) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + const starting = supervisor.start() + await vi.waitFor(() => expect(readBundle).toHaveBeenCalledTimes(2)) + supervisor.setForeground(false) + finishCredentialRead?.(bundle) + await starting + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() + supervisor.stop() + }) + + it('does not announce Relay without a dialable credential', async () => { + const logical = new FakeLogicalClient('reconnecting', 'lan') + const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } } + const deps = dependencies({ readBundle: vi.fn(async () => expired) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() + supervisor.stop() + }) + + it('keeps Relay pending through a failed dial cooldown and clears it on stop', async () => { + const logical = new FakeLogicalClient('reconnecting', 'lan') + const deps = dependencies({ + openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))), + randomBytes: () => new Uint8Array([128, 0]) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + + expect(logical.getPendingPath()).toBe('relay') + await vi.advanceTimersByTimeAsync(249) + expect(logical.getPendingPath()).toBe('relay') + + supervisor.stop() + expect(logical.getPendingPath()).toBeNull() + }) + it('does not spend a queued relay retry while direct authentication is progressing', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))) @@ -187,6 +244,7 @@ describe('mobile endpoint supervisor', () => { logical.publishState('disconnected') expect(openRelay).toHaveBeenCalledOnce() + expect(logical.getPendingPath()).toBe('relay') await vi.advanceTimersByTimeAsync(249) expect(openRelay).toHaveBeenCalledOnce() await vi.advanceTimersByTimeAsync(1) @@ -194,6 +252,22 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('does not announce recovery when an active Relay drops after credential expiry', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } } + const deps = dependencies({ readBundle: vi.fn(async () => expired) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + logical.publishState('disconnected') + await vi.advanceTimersByTimeAsync(0) + + expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() + supervisor.stop() + }) + it('escalates backoff when relay sessions connect and then drop repeatedly', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408))) @@ -432,6 +506,7 @@ describe('mobile endpoint supervisor', () => { expect(openRelay).toHaveBeenCalledOnce() expect(deps.writeBundle).not.toHaveBeenCalled() + expect(logical.getPendingPath()).toBeNull() logical.publishState('connected') await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce()) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 84a3526d022..8292c871e35 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -17,6 +17,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router' import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer' import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher' +import * as recoveryPresentation from './mobile-relay-recovery-presentation' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ForegroundNudgeReason, HostProfile } from './types' @@ -154,6 +155,7 @@ export class MobileEndpointSupervisor { } else { // Why: the direct client enters reconnecting after its first failed // dial and may never publish disconnected while its retry loop lives. + recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle) this.relayReconnect.handleStateFailure(this.logical, state) } }) @@ -181,12 +183,11 @@ export class MobileEndpointSupervisor { this.relayReconnect.clear() this.leaseRotation.clear() this.directGrace.clear() + this.logical.setRecoveryPath(null) } } - nudge(reason: ForegroundNudgeReason): void { - this.nudgeRouter.nudge(reason) - } + nudge = (reason: ForegroundNudgeReason): void => this.nudgeRouter.nudge(reason) stop(): void { this.stopped = true @@ -196,6 +197,7 @@ export class MobileEndpointSupervisor { this.relayReconnect.clear() this.leaseRotation.clear() this.directGrace.clear() + this.logical.setRecoveryPath(null) } // forceReplacement: dial past the "direct still looks live" guard — a lease @@ -235,7 +237,6 @@ export class MobileEndpointSupervisor { return } this.operationInFlight = true - let lastError: Error | null = null let retryAfterOperation = false try { const selection = await selectDialableRelayCredentials({ @@ -246,6 +247,7 @@ export class MobileEndpointSupervisor { }) this.bundle = selection.bundle if (selection.credentials.length === 0) { + this.logical.setRecoveryPath(null) // Why: "expired" vs "missing" separates a sleep-past-expiry phone // (needs re-pair or LAN) from a Keychain failure in field reports. this.logRelay( @@ -261,6 +263,12 @@ export class MobileEndpointSupervisor { } return } + const recoveryNeeded = + forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState()) + if (this.stopped || !this.foreground || !recoveryNeeded) { + return + } + this.logical.setRecoveryPath('relay') const dialed = await this.sessionEstablisher.dialEligible(selection.credentials) if (dialed.outcome === 'established') { // Why: a fresh socket satisfies any replacement intent queued mid-dial. @@ -269,15 +277,16 @@ export class MobileEndpointSupervisor { return } if (dialed.outcome === 'aborted') { + this.logical.setRecoveryPath(null) // Why: direct won the race or the supervisor went inactive — not a // failure; booking backoff would delay the next genuine recovery. return } - lastError = dialed.error // Why: cleanup may happen while a relay dial is awaiting the network; // record its outcome without recreating a foreground retry timer. const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped - this.relayReconnect.registerFailure(lastError, scheduleRetry) + this.relayReconnect.registerFailure(dialed.error, scheduleRetry) + recoveryPresentation.clearIfCredentialBlocked(this.logical, this.relayReconnect) if (ownsRecovery) { suspendRelayIfStillConnected(this.relayReconnect, this.logical) } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 2955819ea4a..ff67750c0e0 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -7,9 +7,12 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' +import { RelayCredentialEligibility } from './relay-credential-eligibility' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState, ForegroundNudgeReason } from './types' +type RelayCredentialLease = { expiresAt: number; version: number } + export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array @@ -28,7 +31,7 @@ export class RelayReconnectController { private recoveryGate: RecoveryGate | null = null private gateReprobePending = false private gateReprobeStreak = 0 - private readonly rejectedCredentialVersions = new Set() + private readonly credentials: RelayCredentialEligibility private readonly delays: RelayRetryDelays constructor( @@ -36,6 +39,7 @@ export class RelayReconnectController { private readonly onRetry: (forceReplacement?: boolean) => void ) { this.delays = new RelayRetryDelays(dependencies.randomBytes) + this.credentials = new RelayCredentialEligibility(dependencies.now) } handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { @@ -105,7 +109,7 @@ export class RelayReconnectController { resetForDirectConnection(): boolean { const needsCredentialRefresh = - this.recoveryGate === 'fresh-credential' || this.rejectedCredentialVersions.size > 0 + this.recoveryGate === 'fresh-credential' || this.credentials.hasRejected() this.activeSession = null this.activeRelayConnectedAt = null if (needsCredentialRefresh) { @@ -126,22 +130,22 @@ export class RelayReconnectController { completeCredentialRefresh(): void { if (this.recoveryGate === 'fresh-credential') { - this.rejectedCredentialVersions.clear() + this.credentials.clearRejected() this.reset() } } - eligibleCredentials( + blocksUntilFreshCredential = (): boolean => this.recoveryGate === 'fresh-credential' + + hasDialableCredential(...credentials: (RelayCredentialLease | null | undefined)[]): boolean { + return this.credentials.hasDialable(...credentials) + } + + eligibleCredentials( ...credentials: Array ): T[] { - const eligible = credentials.filter((credential): credential is T => - Boolean( - credential && - credential.expiresAt > this.dependencies.now() && - !this.rejectedCredentialVersions.has(credential.version) - ) - ) - if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) { + const eligible = this.credentials.eligible(...credentials) + if (eligible.length === 0 && this.credentials.hasRejected()) { this.recoveryGate = 'fresh-credential' this.clearTimer() this.scheduleGateReprobe() @@ -152,7 +156,7 @@ export class RelayReconnectController { // For callers that found no dialable credential at all: keep a slow retry // alive so a later durable write can recover. armCredentialReprobe(): void { - if (this.rejectedCredentialVersions.size > 0) { + if (this.credentials.hasRejected()) { this.recoveryGate = 'fresh-credential' this.clearTimer() this.scheduleGateReprobe() @@ -176,14 +180,12 @@ export class RelayReconnectController { // A durable bundle whose current version is not rejected reopens the gate. acceptFreshCredential(version: number): void { - if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) { + if (this.recoveryGate === 'fresh-credential' && !this.credentials.isRejected(version)) { this.liftGate() } } - recordRejectedCredential(version: number): void { - this.rejectedCredentialVersions.add(version) - } + recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version) registerActiveFailure(logical: StableLogicalRpcClient): void { if (logical.getActivePath() !== 'relay') { diff --git a/mobile/src/transport/mobile-relay-recovery-presentation.ts b/mobile/src/transport/mobile-relay-recovery-presentation.ts new file mode 100644 index 00000000000..549e8f07a82 --- /dev/null +++ b/mobile/src/transport/mobile-relay-recovery-presentation.ts @@ -0,0 +1,29 @@ +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState } from './types' + +export function onActiveFailure( + logical: StableLogicalRpcClient, + controller: RelayReconnectController, + state: ConnectionState, + bundle: MobileRelayCredentialBundle | null +): void { + if ( + controller.needsRecovery(state) && + logical.getActivePath() === 'relay' && + bundle && + controller.hasDialableCredential(bundle.current, bundle.grace) + ) { + logical.setRecoveryPath('relay') + } +} + +export function clearIfCredentialBlocked( + logical: StableLogicalRpcClient, + controller: RelayReconnectController +): void { + if (controller.blocksUntilFreshCredential()) { + logical.setRecoveryPath(null) + } +} diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index dcb8ba194e2..9ef0ea70c04 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -90,6 +90,9 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { private path: MobileConnectionPath + private recoveryPath: MobileConnectionPath | null = null + private generation = 1 + private readonly pathListeners = new Set<() => void>() constructor(state: ConnectionState, path: MobileConnectionPath) { super(state) @@ -102,10 +105,28 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { throw new Error(`replacement session ${session.getState()}`) } this.path = path + this.recoveryPath = null + this.generation += 1 + // Connected-state publication carries the migration cleanup. this.publishState('connected') }) suspendActiveSession = vi.fn(() => this.publishState('disconnected')) getActivePath = () => this.path + getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath) + setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => { + const previous = this.getPendingPath() + this.recoveryPath = path + if (previous !== this.getPendingPath()) { + for (const listener of this.pathListeners) { + listener() + } + } + }) + onConnectionPathChange = vi.fn((listener: () => void) => { + this.pathListeners.add(listener) + return () => this.pathListeners.delete(listener) + }) + getGeneration = () => this.generation } const relay = { @@ -237,12 +258,15 @@ describe('relay runtime recovery without direct connectivity', () => { await supervisor.start() await vi.advanceTimersByTimeAsync(0) expect(deps.openRelay).not.toHaveBeenCalled() + expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay') + expect(logical.getPendingPath()).toBeNull() readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) // Pre-fix, an expired bundle produced a silent no-op with nothing scheduled. await vi.advanceTimersByTimeAsync(60_000) expect(deps.openRelay).toHaveBeenCalledOnce() + expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay') expect(logical.getActivePath()).toBe('relay') supervisor.stop() }) diff --git a/mobile/src/transport/relay-credential-eligibility.ts b/mobile/src/transport/relay-credential-eligibility.ts new file mode 100644 index 00000000000..e0c831975c9 --- /dev/null +++ b/mobile/src/transport/relay-credential-eligibility.ts @@ -0,0 +1,41 @@ +type RelayCredentialLease = { expiresAt: number; version: number } + +export class RelayCredentialEligibility { + private readonly rejectedVersions = new Set() + + constructor(private readonly now: () => number) {} + + hasRejected(): boolean { + return this.rejectedVersions.size > 0 + } + + clearRejected(): void { + this.rejectedVersions.clear() + } + + isRejected(version: number): boolean { + return this.rejectedVersions.has(version) + } + + recordRejected(version: number): void { + this.rejectedVersions.add(version) + } + + hasDialable(...credentials: (RelayCredentialLease | null | undefined)[]): boolean { + return credentials.some((credential) => this.isDialable(credential)) + } + + eligible(...credentials: (T | null | undefined)[]): T[] { + return credentials.filter((credential): credential is T => this.isDialable(credential)) + } + + private isDialable( + credential: T | null | undefined + ): credential is T { + return Boolean( + credential && + credential.expiresAt > this.now() && + !this.rejectedVersions.has(credential.version) + ) + } +} diff --git a/mobile/src/transport/rpc-client-context-contract.ts b/mobile/src/transport/rpc-client-context-contract.ts index 77ef745eef0..cf78c6f3546 100644 --- a/mobile/src/transport/rpc-client-context-contract.ts +++ b/mobile/src/transport/rpc-client-context-contract.ts @@ -21,6 +21,7 @@ export type RpcClientContextValue = { getReconnectAttempt: (hostId: string) => number getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath + getPendingPath: (hostId: string) => MobileConnectionPath | null subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void getAllClients: () => { hostId: string; client: RpcClient }[] subscribeAllHosts: (listener: () => void) => () => void diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index ea5dfef717c..132530440ce 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -4,7 +4,8 @@ import type { RpcClient } from './rpc-client' import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { createStableLogicalRpcClient, - LogicalClientCutoverError + LogicalClientCutoverError, + type MobileConnectionPath } from './stable-logical-rpc-client' class FakeSession implements RpcClient { @@ -259,10 +260,13 @@ describe('stable logical RPC client', () => { const client = createStableLogicalRpcClient(direct, 'lan') direct.setState('reconnecting') const states: ConnectionState[] = [] + const paths: (MobileConnectionPath | null)[] = [] client.onStateChange((next) => states.push(next)) + client.onConnectionPathChange(() => paths.push(client.getPendingPath())) const migrating = client.migrateTo(replacement, 'relay') expect(client.getPendingPath()).toBe('relay') + expect(paths).toEqual(['relay']) replacement.setState('connecting') replacement.setState('handshaking') @@ -278,6 +282,37 @@ describe('stable logical RPC client', () => { expect(client.getActivePath()).toBe('relay') }) + it('publishes recovery-path changes and keeps Relay pending between failed dials', async () => { + const direct = new FakeSession('reconnecting') + const replacement = new FakeSession('connecting') + const client = createStableLogicalRpcClient(direct, 'tailscale') + const paths: (MobileConnectionPath | null)[] = [] + client.onConnectionPathChange(() => paths.push(client.getPendingPath())) + + client.setRecoveryPath('relay') + const migrating = client.migrateTo(replacement, 'relay') + replacement.setState('disconnected') + await expect(migrating).rejects.toThrow(/disconnected/) + + expect(client.getPendingPath()).toBe('relay') + expect(paths).toEqual(['relay']) + + client.setRecoveryPath(null) + expect(client.getPendingPath()).toBeNull() + expect(paths).toEqual(['relay', null]) + }) + + it('does not revive a stale recovery path after a connection later drops', () => { + const direct = new FakeSession('reconnecting') + const client = createStableLogicalRpcClient(direct, 'tailscale') + + client.setRecoveryPath('relay') + direct.setState('connected') + direct.setState('reconnecting') + + expect(client.getPendingPath()).toBeNull() + }) + it('drops the pending path when the previous session recovers mid-dial', async () => { const direct = new FakeSession('connected') const replacement = new FakeSession('connecting') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 1ee47fcda5a..0a1062eeadc 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -6,6 +6,7 @@ import { } from './migration-dial-state-forwarder' import { waitForAuthenticated } from './replacement-session-authentication' import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection' +import { LogicalClientConnectionPath } from './logical-client-connection-path' export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' @@ -47,9 +48,10 @@ export type StableLogicalRpcClient = RpcClient & { ): Promise suspendActiveSession(): void getActivePath(): MobileConnectionPath - // Non-null only while a migration dial is publishing its own phases — the path the - // user is waiting on, which the still-bound active path can't name. + // The path the user is waiting on while migration or scheduled recovery is active. getPendingPath(): MobileConnectionPath | null + setRecoveryPath(path: MobileConnectionPath | null): void + onConnectionPathChange(listener: () => void): () => void getGeneration(): number } @@ -59,7 +61,6 @@ export function createStableLogicalRpcClient( ): StableLogicalRpcClient { let activeSession = initialSession let activePath = initialPath - let pendingPath: MobileConnectionPath | null = null let generation = 1 let closed = false let suspended = false @@ -69,6 +70,7 @@ export function createStableLogicalRpcClient( const pendingRequests = new Set() const stateListeners = new Set<(state: ConnectionState) => void>() let state = initialSession.getState() + const connectionPath = new LogicalClientConnectionPath(() => state === 'connected') bindActiveState(initialSession, generation) @@ -207,7 +209,7 @@ export function createStableLogicalRpcClient( // (direct dial fails) sits in 'reconnecting' — already amber, so forwarding adds // nothing, but the user still has no idea relay is what's being tried. if (suspended || state !== 'connected') { - pendingPath = path + connectionPath.setMigration(path) } const forwarder = forwardMigrationDialState({ session: nextSession, @@ -259,6 +261,7 @@ export function createStableLogicalRpcClient( } pendingRequests.clear() state = nextSession.getState() + connectionPath.clearAfterConnected() for (const listener of stateListeners) { listener(state) } @@ -268,7 +271,9 @@ export function createStableLogicalRpcClient( getActivePath: () => activePath, // Why: a previous session that recovers mid-dial makes the pending path a lie — // once we're connected the user is no longer waiting on anything. - getPendingPath: () => (state === 'connected' ? null : pendingPath), + getPendingPath: () => connectionPath.pending(), + setRecoveryPath: (path) => connectionPath.setRecovery(path), + onConnectionPathChange: (listener) => connectionPath.subscribe(listener), getGeneration: () => generation } @@ -276,7 +281,9 @@ export function createStableLogicalRpcClient( function endDialForwarding(forwarder: MigrationDialStateForwarder, failed: boolean): void { forwarder.stop() - pendingPath = null + if (failed) { + connectionPath.setMigration(null) + } // Why: only walk back phases we published ourselves — a 'connected' here came from // the still-live previous session and outranks the dead dial. if (failed && forwarder.forwarded() && state !== 'connected') { @@ -314,6 +321,9 @@ export function createStableLogicalRpcClient( return } state = next + if (next === 'connected') { + connectionPath.clearAfterConnected() + } for (const listener of stateListeners) { listener(next) } diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index 4f5d036c877..197f270f194 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -136,10 +136,19 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients client: RpcClient state: ConnectionState path: MobileConnectionPath + pendingPath: MobileConnectionPath | null }>((hostId) => { const client = clientsByHostId.get(hostId) return client - ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] + ? [ + { + hostId, + client, + state: ctx.getState(hostId), + path: ctx.getActivePath(hostId), + pendingPath: ctx.getPendingPath(hostId) + } + ] : [] }) }, [ctx, hostIds, tick]) From 378c60071a85450dfc844c5722eda61a132df6a1 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 16 Aug 2026 20:33:37 -0700 Subject: [PATCH 13/28] fix(grok): strip quotes from GROK_HOME in the Windows hook (#14221) (#14985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setx GROK_HOME "C:\path\"` stores `C:\path"` — the CRT turns the `\"` into a literal quote. That quote unbalanced the trailing-backslash `if` operand, so cmd aborted grok-hook.cmd with exit 255 before curl and every Grok hook event failed. A quote elsewhere in the value closed curl's `grokHome=` argument early, swallowing the `^` continuation and dropping the `payload@-` line. `"` is illegal in a Windows path, so strip it during the copy. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/grok/hook-service.test.ts | 4 +- .../grok/windows-grok-hook-script.test.ts | 39 +++++++++++++++++-- src/main/grok/windows-grok-hook-script.ts | 6 ++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index ba469506316..9a58aabaf24 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -118,7 +118,7 @@ describe('GrokHookService', () => { expect(script).toContain('set "ORCA_GROK_HOME="') expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') expect(script).toContain('%GROK_HOME:~4096,1%') - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"') expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') expect(script).toContain(':orca_grok_home_ready') expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') @@ -269,7 +269,7 @@ describe('GrokHookService', () => { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') // Why: windows-grok-hook-script.test.ts pins the GROK_HOME guard shape itself, // and does so on every platform rather than only on Windows runners. - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"') expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands diff --git a/src/main/grok/windows-grok-hook-script.test.ts b/src/main/grok/windows-grok-hook-script.test.ts index a8aea9065f0..98da524e7c7 100644 --- a/src/main/grok/windows-grok-hook-script.test.ts +++ b/src/main/grok/windows-grok-hook-script.test.ts @@ -26,8 +26,9 @@ function findUnguardedSubstringReads(lines: readonly string[]): string[] { defined.add(guarded[1]) } // A value is provably non-empty only if something outside an expansion survives: - // `set "V=%OTHER%"` may undefine V, `set "V=%V%."` cannot. - for (const [, name, value] of line.matchAll(/set "(\w+)=([^"]*)"/g)) { + // `set "V=%OTHER%"` may undefine V, `set "V=%V%."` cannot. The lazy value stops at + // the quote that closes the `set`, so a substitution like `%V:"=%` stays one token. + for (const [, name, value] of line.matchAll(/set "(\w+)=(.*?)"(?=\s|$)/g)) { if (value.replaceAll(/%[^%]*%/g, '') === '') { defined.delete(name) } else { @@ -93,6 +94,7 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32 grokHome: string | null paneKey: string | null worktreeId: string | null + payload: string | null } const PANE_KEY = 'tab!1:pane!2' @@ -149,7 +151,8 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32 stderr, grokHome: posted?.get('grokHome') ?? null, paneKey: posted?.get('paneKey') ?? null, - worktreeId: posted?.get('worktreeId') ?? null + worktreeId: posted?.get('worktreeId') ?? null, + payload: posted?.get('payload') ?? null }) ) }) @@ -188,6 +191,36 @@ describe.skipIf(process.platform !== 'win32')('buildWindowsGrokHookScript (win32 expect(result.grokHome).toBe('C:\\Users\\test\\.grok\\.') }) + // Why (#14221): `setx GROK_HOME "C:\path\"` stores `C:\path"` — the CRT turns the + // `\"` into a literal quote. That quote unbalanced the trailing-backslash `if`, so + // cmd aborted the whole script before curl and every Grok hook event failed. + it('exits 0 and strips a trailing quote from GROK_HOME', async () => { + const result = await runHook('C:\\Users\\test\\.grok"') + + expect(result.stderr).not.toContain('unexpected at this time') + expect(result.status).toBe(0) + expect(result.grokHome).toBe('C:\\Users\\test\\.grok') + expect(result.payload).toBe('{"session_id":"s"}') + }) + + // Why: an embedded quote closed curl's `grokHome=` argument early, which swallowed + // the `^` continuation and dropped the `payload@-` line — a silent, exit-0 failure. + it('strips an embedded quote without truncating the curl arguments', async () => { + const result = await runHook('C:\\Users\\a"b\\.grok') + + expect(result.status).toBe(0) + expect(result.grokHome).toBe('C:\\Users\\ab\\.grok') + expect(result.payload).toBe('{"session_id":"s"}') + }) + + it('posts an empty grokHome when GROK_HOME is only a quote', async () => { + const result = await runHook('"') + + expect(result.status).toBe(0) + expect(result.grokHome).toBe('') + expect(result.payload).toBe('{"session_id":"s"}') + }) + it('drops a GROK_HOME past the envelope limit', async () => { const result = await runHook(`C:\\${'a'.repeat(4094)}`) diff --git a/src/main/grok/windows-grok-hook-script.ts b/src/main/grok/windows-grok-hook-script.ts index 5a6e81ee633..e68c8914eeb 100644 --- a/src/main/grok/windows-grok-hook-script.ts +++ b/src/main/grok/windows-grok-hook-script.ts @@ -21,6 +21,7 @@ const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', * * - Guard substring ops behind `if defined` + goto (not a parenthesized block). * - Reject oversized values before copying them onto a cmd input line. + * - Strip `"` before the value reaches an `if` operand or the curl argument. */ export function buildWindowsGrokHookScript(): string { return [ @@ -35,7 +36,10 @@ export function buildWindowsGrokHookScript(): string { 'set "ORCA_GROK_HOME="', 'if not defined GROK_HOME goto :orca_grok_home_ready', `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`, - 'set "ORCA_GROK_HOME=%GROK_HOME%"', + // Why (#14221): `setx GROK_HOME "C:\path\"` stores a literal trailing quote, which + // unbalances the trailing-backslash `if` below (exit 255) and truncates the curl + // argument. `"` is illegal in a Windows path, so strip it rather than preserve it. + 'set "ORCA_GROK_HOME=%GROK_HOME:"=%"', 'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready', 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', // Why: the trailing-backslash safety sentinel counts toward the relay envelope. From e16a22ef586dd4329a8a80076f0cc2a1f0d22d8b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:35:34 -0700 Subject: [PATCH 14/28] fix(browser): return a real PermissionStatus from the query override (#14684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): shadow PermissionStatus state instead of proxying it Rebased onto main after #14685 landed in the same file. Both changes coexist: #14685's Firefox gating and narrowed promptPerms are preserved untouched, and this change replaces only the query-override implementation. The previous Proxy bound every callable property, which broke three observable things: - onchange assignment threw "Illegal invocation" — the native setter received the proxy rather than the branded target. - Listeners were delivered with the real target and the NATIVE state, so on a real permission change event.target.state read 'granted' while the returned status still read 'prompt'. - Method reads returned a new bound function each time, so name became 'bound addEventListener', toString lost its name, and identity was unstable. Separately, the notifications state was captured once as a string, so an already-returned status went stale after requestPermission updated it. Shadow only 'state' on the genuine PermissionStatus with a lazy provider. The object the site holds IS the real one, so identity, brand checks, method fingerprints and native event delivery survive with nothing to keep in sync. Verified against real Chromium (Chrome 151) rather than only the vm stand-in, because the stand-in cannot show whether defineProperty succeeds on a branded instance: the instance is extensible, the prototype 'state' accessor is configurable, defineProperty succeeds, instanceof survives, addEventListener keeps its native name and referential stability, and onchange assignment works. New tests live in their own file rather than merged into anti-detection.test.ts, whose harness diverged on main. The fallback test targets 'camera' because #14685 narrowed the intercepted set, so a name outside it never reaches the fallback. * test(browser): cover intercepted PermissionStatus identity --- .../anti-detection-permission-status.test.ts | 210 ++++++++++++++++++ src/main/browser/anti-detection.ts | 35 ++- 2 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/main/browser/anti-detection-permission-status.test.ts diff --git a/src/main/browser/anti-detection-permission-status.test.ts b/src/main/browser/anti-detection-permission-status.test.ts new file mode 100644 index 00000000000..d686e32c975 --- /dev/null +++ b/src/main/browser/anti-detection-permission-status.test.ts @@ -0,0 +1,210 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' + +import { ANTI_DETECTION_SCRIPT } from './anti-detection' + +type PermissionQueryResult = EventTarget & { + state: string + onchange: EventListener | null + marker: string +} + +type PermissionStatusConstructor = { + new (): PermissionQueryResult + prototype: PermissionQueryResult +} + +type AntiDetectionContext = { + Notification: { + permission: string + requestPermission: (callback?: (permission: string) => void) => Promise + } + PermissionStatus: PermissionStatusConstructor + dispatchPermissionChange: (name: string) => void + navigator: { + permissions: { + query: (descriptor: { name: string }) => Promise + } + } +} + +function createContext(args: { + nativeNotificationPermission: string + requestedNotificationPermission: string + rejectedPermissions?: string[] +}): AntiDetectionContext & Record { + class PermissionStatus extends EventTarget { + #state = 'denied' + #onchange: EventListener | null = null + marker = 'real-status' + + get state(): string { + return this.#state + } + + get onchange(): EventListener | null { + return this.#onchange + } + + set onchange(listener: EventListener | null) { + if (this.#onchange) { + super.removeEventListener('change', this.#onchange) + } + this.#onchange = typeof listener === 'function' ? listener : null + if (this.#onchange) { + super.addEventListener('change', this.#onchange) + } + } + } + + const statuses = new Map() + const rejectedPermissions = new Set(args.rejectedPermissions) + + class Permissions { + query(descriptor: { name: string }): Promise { + if (rejectedPermissions.has(descriptor.name)) { + return Promise.reject(new Error('Unsupported permission')) + } + const status = new PermissionStatus() + const permissionStatuses = statuses.get(descriptor.name) ?? [] + permissionStatuses.push(status) + statuses.set(descriptor.name, permissionStatuses) + return Promise.resolve(status) + } + } + + const Notification = { + permission: args.nativeNotificationPermission, + requestPermission(callback?: (permission: string) => void): Promise { + callback?.(args.requestedNotificationPermission) + return Promise.resolve(args.requestedNotificationPermission) + } + } + Object.defineProperty(Notification, 'permission', { + configurable: true, + get: () => args.nativeNotificationPermission + }) + + return { + Date, + Event, + EventTarget, + Object, + Promise, + Set, + performance: { now: () => 0 }, + // Why: the script's Firefox gate reads navigator.userAgent, so a non-Firefox UA keeps these + // tests on the ordinary-page path where the PermissionStatus override applies. + window: { chrome: {} }, + navigator: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36', + plugins: [], + languages: [], + permissions: new Permissions() + }, + Permissions, + PermissionStatus, + Notification, + dispatchPermissionChange(name: string): void { + for (const status of statuses.get(name) ?? []) { + status.dispatchEvent(new Event('change')) + } + } + } as AntiDetectionContext & Record +} + +describe('ANTI_DETECTION_SCRIPT — PermissionStatus', () => { + it('keeps an existing notification status current after permission changes', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'notifications' }) + + expect(context.Notification.permission).toBe('default') + expect(status.state).toBe('prompt') + + await expect(context.Notification.requestPermission()).resolves.toBe('granted') + + expect(context.Notification.permission).toBe('granted') + expect(status.state).toBe('granted') + }) + + it('preserves native PermissionStatus identity and methods', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'camera' }) + const expectedSource = Function.prototype.toString.call( + context.PermissionStatus.prototype.addEventListener + ) + + expect(status).toBeInstanceOf(context.PermissionStatus) + expect(status.state).toBe('prompt') + expect(status.constructor.name).toBe('PermissionStatus') + expect(status.marker).toBe('real-status') + expect(status.addEventListener.name).toBe('addEventListener') + expect(status.addEventListener).toBe(status.addEventListener) + expect(Function.prototype.toString.call(status.addEventListener)).toBe(expectedSource) + expect(expectedSource).toContain('addEventListener') + }) + + it('delivers change events through the returned status with the overridden state', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'notifications' }) + const events: { receiver: EventTarget; target: EventTarget | null; state: string }[] = [] + const recordEvent = function (this: EventTarget, event: Event): void { + events.push({ + receiver: this, + target: event.target, + state: (event.target as PermissionQueryResult).state + }) + } + + status.addEventListener('change', recordEvent) + expect(() => { + status.onchange = function (this: EventTarget, event): void { + recordEvent.call(this, event) + } + }).not.toThrow() + + await context.Notification.requestPermission() + context.dispatchPermissionChange('notifications') + + expect(events).toHaveLength(2) + expect(events).toEqual([ + { receiver: status, target: status, state: 'granted' }, + { receiver: status, target: status, state: 'granted' } + ]) + }) + + // Why: 'camera' rather than 'storage-access' — #14685 narrowed the intercepted set to + // camera/microphone, so a name outside it falls through to the real query and never reaches + // the fallback at all. + it('uses a non-enumerable EventTarget fallback when the native query rejects', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted', + rejectedPermissions: ['camera'] + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'camera' }) + + expect(status).toBeInstanceOf(EventTarget) + expect(status).not.toBeInstanceOf(context.PermissionStatus) + expect(status.state).toBe('prompt') + expect(Object.keys(status)).toEqual([]) + }) +}) diff --git a/src/main/browser/anti-detection.ts b/src/main/browser/anti-detection.ts index b955b200229..d725fbf965f 100644 --- a/src/main/browser/anti-detection.ts +++ b/src/main/browser/anti-detection.ts @@ -86,12 +86,43 @@ export const ANTI_DETECTION_SCRIPT = `(function() { 'camera', 'microphone' ]); const origQuery = Permissions.prototype.query; + // Why: sites must receive the genuine PermissionStatus so native events, brand checks and method + // identity survive. Shadow only state, and resolve it lazily so existing statuses stay current. + function withOverriddenState(realStatus, stateProvider) { + Object.defineProperty(realStatus, 'state', { + configurable: true, + get: stateProvider + }); + return realStatus; + } + // Why: some names the real implementation rejects outright; fall back to an EventTarget so + // listener registration still works instead of throwing. + function fallbackStatus(stateProvider) { + const status = new EventTarget(); + Object.defineProperties(status, { + state: { configurable: true, get: stateProvider }, + onchange: { configurable: true, value: null, writable: true } + }); + return status; + } + function queryWithState(permissions, desc, stateProvider) { + let real; + try { + real = origQuery.call(permissions, desc); + } catch { + return Promise.resolve(fallbackStatus(stateProvider)); + } + return Promise.resolve(real).then( + (status) => withOverriddenState(status, stateProvider), + () => fallbackStatus(stateProvider) + ); + } Permissions.prototype.query = function(desc) { if (desc.name === 'notifications') { - return Promise.resolve({ state: notificationPermissionState(), onchange: null }); + return queryWithState(this, desc, notificationPermissionState); } if (promptPerms.has(desc.name)) { - return Promise.resolve({ state: 'prompt', onchange: null }); + return queryWithState(this, desc, () => 'prompt'); } return origQuery.call(this, desc); }; From 5e9e38fa75d694fe5b59ad5b88d5f829f2bf36e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:47:02 -0700 Subject: [PATCH 15/28] fix(agent-status): announce Claude turn complete while background work runs (#14580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-status): announce Claude turn complete while background work runs Lead Stop/StopFailure already ends the turn, but resolveClaudePaneState keeps the pane working for subagents, background shells, and session crons. That erases the working→done edge that mints the completion banner: subagent turns notify late with a stale body, and shells/crons never notify at all. Stamp turnCompletedAt on the gated lead Stop, announce immediately from that row, and pair the later all-clear done to the same end time so it cannot double-fire or collapse consecutive turns onto the pinned stateStartedAt. Fixes #13245 * fix(agent-status): suppress stamped turn replays * fix(agent-status): notify paired clients at turn end * Fix late-paired completion notification arming * test(agent-status): make the notification-id test fail on the pre-fix ordering The stored row inherited the helper's default codex agentType while the event named claude, so agentSnapshotMatchesExplicitTitle dropped it and freshStoredAgentStatus was undefined — the assertion held under either side of the `??`. Name the stored row's agent so the pinned working row survives and the snapshot-first precedence is what the test actually pins. * Suppress stamped completion tail replays * Prevent cross-coordinator title replays * Preserve stamped tails across fallback signals * Keep remount replay state while sibling lives * Scope OSC turn stamp preservation * Forward paired host completion stamps * Deduplicate paired completion tails * Bind paired completion tails to their turn * Preserve paired completion tail ownership * Keep paired tail replay state across remounts * Seed paired recovery without replaying completions * Seed startup replay and release stale fallback dedupe * fix(notifications): preserve stamped OSC repaints * fix(notifications): retain paired client turn boundary * fix notifications module import safety * chore: keep main integration focused * fix: preserve completion re-enable boundary --- ...k-status-session-tabs-invalidation.test.ts | 16 + .../hook-status-session-tabs-invalidation.ts | 4 +- .../server-ingest-terminal-status.test.ts | 91 ++++ src/main/agent-hooks/server.ts | 44 +- ...ntime-hook-agent-status-projection.test.ts | 17 + src/main/runtime/orca-runtime.ts | 39 +- ...ompletion-coordinator-dispose-leak.test.ts | 72 +++ ...-coordinator-hook-title-precedence.test.ts | 11 + ...-coordinator-stamped-turn-boundary.test.ts | 409 ++++++++++++++++++ ...on-coordinator-stamped-turn-replay.test.ts | 388 +++++++++++++++++ .../agent-completion-coordinator-types.ts | 4 + .../agent-completion-coordinator.ts | 351 ++++++++++++++- .../agent-completion-snapshot-staleness.ts | 12 +- .../terminal-pane/pty-connection.ts | 1 + .../use-notification-dispatch.test.ts | 93 ++++ .../use-notification-dispatch.ts | 2 +- ...tion-background-turn-notifications.test.ts | 270 ++++++++++++ ...gent-hook-completion-fresh-working.test.ts | 122 ++++++ ...ok-completion-notifications-import.test.ts | 17 + ...gent-hook-completion-notifications.test.ts | 5 +- .../agent-hook-completion-notifications.ts | 52 ++- .../hooks/agent-hook-completion-store-sync.ts | 8 +- ...events-agent-status-store-test-fixtures.ts | 1 + ...vents-agent-status-turn-completion.test.ts | 92 ++++ src/renderer/src/hooks/useIpcEvents.ts | 6 +- .../src/runtime/web-runtime-session.ts | 2 +- ...abs-agent-completion-notifications.test.ts | 325 ++++++++++++++ .../src/runtime/web-session-tabs-sync.test.ts | 3 + .../src/runtime/web-session-tabs-sync.ts | 166 ++++++- ...ent-hook-listener-claude-subagents.test.ts | 68 ++- src/shared/agent-hook-listener.ts | 60 ++- .../agent-status-field-normalization.ts | 8 + src/shared/agent-status-types.test.ts | 36 ++ src/shared/agent-status-types.ts | 9 +- src/shared/runtime-types.ts | 2 + 35 files changed, 2720 insertions(+), 86 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts create mode 100644 src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts create mode 100644 src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index 14235654d37..5204c6b0b0b 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -43,6 +43,22 @@ describe('createHookStatusSessionTabsInvalidator', () => { expect(changed(working({}, payload))).toBe(true) }) + it('invalidates when the completion stamp is added, changed, or removed', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + + expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true) + expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true) + expect(changed(working())).toBe(true) + }) + + it('invalidates when the assistant body changes', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working({}, { lastAssistantMessage: 'First answer' })) + + expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true) + }) + it('ignores resume-identity rows, which the provider-session path owns', () => { const changed = createHookStatusSessionTabsInvalidator() diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 59b259fa849..3aa90b530f4 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -28,7 +28,9 @@ export function createHookStatusSessionTabsInvalidator(): { (previous.agentType ?? null) !== (next.agentType ?? null) || (previous.toolName ?? null) !== (next.toolName ?? null) || (previous.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) || - (previous.interrupted ?? false) !== (next.interrupted ?? false) + (previous.interrupted ?? false) !== (next.interrupted ?? false) || + (previous.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) || + (previous.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) ) } // Why: a cleared pane must re-arm, else the memo swallows the first event of the diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index b6182529628..8661701e1dc 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -27,6 +27,97 @@ afterEach(() => { }) describe('AgentHookServer ingestTerminalStatus', () => { + it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }, + 'conn-1' + ) + listener.mockClear() + server.ingestRemote( + { + paneKey: PANE, + payload: { + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + lastAssistantMessage: 'Review complete.', + turnCompletedAt: 1_700_000_005_000 + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' } + }) + + expect(listener).toHaveBeenCalledOnce() + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + lastAssistantMessage: 'Review complete.', + turnCompletedAt: 1_700_000_005_000 + }) + + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'done', prompt: 'review the PR', agentType: 'claude' } + }) + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ state: 'done' }) }) + ) + }) + + it('accepts an identical-prompt hook boundary after a stamped turn', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'Stop', + payload: { + state: 'working', + prompt: 'check status', + agentType: 'claude', + turnCompletedAt: 1_700_000_005_000 + } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'check status', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'working', + prompt: 'check status', + turnCompletedAt: undefined + }) + }) + ) + expect(server.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined() + }) + // Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row // erased the session id from persisted rows and from headless `orca serve` — which serves these // rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630). diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f819414ad4b..48ff387fad4 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -416,7 +416,8 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta // Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph. function equivalentParsedAgentStatusPayload( a: ParsedAgentStatusPayload, - b: ParsedAgentStatusPayload + b: ParsedAgentStatusPayload, + preserveActiveTurnStamp = false ): boolean { return ( a.state === b.state && @@ -425,11 +426,14 @@ function equivalentParsedAgentStatusPayload( a.toolName === b.toolName && a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && - a.lastAssistantMessage === b.lastAssistantMessage && + (a.lastAssistantMessage === b.lastAssistantMessage || + (preserveActiveTurnStamp && b.lastAssistantMessage === undefined)) && a.interrupted === b.interrupted && // Why: a session-boundary done must never be deduped against a cached real done — // the flag has to reach receivers deterministically (STA-3386). - a.sessionBoundary === b.sessionBoundary + a.sessionBoundary === b.sessionBoundary && + (a.turnCompletedAt === b.turnCompletedAt || + (preserveActiveTurnStamp && b.turnCompletedAt === undefined)) ) } @@ -630,6 +634,7 @@ export class AgentHookServer { private assistantMessageRetryTimers = new Map>() private codexSubagentPollTimers = new Map>() private promptSentDedupeByPaneKey = new Map() + private activeHookTurnCompletedAtByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() @@ -917,6 +922,9 @@ export class AgentHookServer { prompt: payload.prompt, agentType: payload.agentType, ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) @@ -1138,6 +1146,10 @@ export class AgentHookServer { payload: AgentHookEventPayload, onAccepted?: () => void ): EnrichedAgentHookEventPayload { + if (payload.hookEventName === 'UserPromptSubmit') { + // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. + this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) + } const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -1277,6 +1289,15 @@ export class AgentHookServer { } const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload) const enriched = this.attachStatusTiming(cachedPayload, now) + if ( + typeof enriched.payload.turnCompletedAt === 'number' && + Number.isFinite(enriched.payload.turnCompletedAt) + ) { + this.activeHookTurnCompletedAtByPaneKey.set( + enriched.paneKey, + enriched.payload.turnCompletedAt + ) + } // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. if (enriched.restoredUnconfirmed) { this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) @@ -1591,6 +1612,11 @@ export class AgentHookServer { if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { this.runtimeObservedStatusPaneKeys.add(toPaneKey) } + const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) + if (activeTurnCompletedAt !== undefined) { + this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) + this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) + } const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) if (authorityObservation) { const owner = parsePaneKey(toPaneKey) @@ -1646,6 +1672,7 @@ export class AgentHookServer { this.clearAssistantMessageRetry(key) this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) + this.activeHookTurnCompletedAtByPaneKey.delete(key) this.runtimeObservedStatusPaneKeys.delete(key) this.currentAuthorityObservations.delete(key) this.promptSentDedupeByPaneKey.delete(key) @@ -1679,6 +1706,7 @@ export class AgentHookServer { } this.legacyPaneKeyAliases.delete(legacyPaneKey) clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { @@ -1688,6 +1716,7 @@ export class AgentHookServer { if (shouldClearStablePaneKey) { // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. clearPaneCacheState(this.state, entry.stablePaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) this.currentAuthorityObservations.delete(entry.stablePaneKey) this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) @@ -1829,12 +1858,15 @@ export class AgentHookServer { const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined + const preserveActiveTurnStamp = + previous?.payload.turnCompletedAt !== undefined && + previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) if ( !previous?.restoredUnconfirmed && previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && - equivalentParsedAgentStatusPayload(previous.payload, event.payload) + equivalentParsedAgentStatusPayload(previous.payload, event.payload, preserveActiveTurnStamp) ) { return } @@ -2331,6 +2363,7 @@ export class AgentHookServer { return null } this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) if (!options?.preserveAuthority) { this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) @@ -2411,6 +2444,7 @@ export class AgentHookServer { this.clearAssistantMessageRetry(paneKey) this.clearCodexSubagentPoll(paneKey) clearPaneCacheState(this.state, paneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) this.runtimeObservedStatusPaneKeys.delete(paneKey) this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) @@ -2432,6 +2466,7 @@ export class AgentHookServer { this.clearAssistantMessageRetry(resolvedPaneKey) this.clearCodexSubagentPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) let clearedAlias = false @@ -2441,6 +2476,7 @@ export class AgentHookServer { paneKeys.add(legacyPaneKey) paneKeys.add(stablePaneKey.stablePaneKey) clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) clearedAlias = true diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 7981366cadb..11b9fc97afd 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -130,6 +130,23 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('publishes a gated turn end as event metadata, not stored agent status', async () => { + const turnCompletedAt = Date.now() + const runtime = await createRuntimeWithHookRows([ + hookRow({ + state: 'working', + interactivePrompt: undefined, + turnCompletedAt + }) + ]) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + + expect(tab).toMatchObject({ type: 'terminal', turnCompletedAt }) + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('turnCompletedAt') + }) + it('publishes no hook transport identity to clients', async () => { const agentStatus = await projectAgentStatus([ hookRow({ launchToken: 'lt-secret', promptInteractionKey: 'turn-1' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 69b7c342438..179a05e2f2b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11806,7 +11806,9 @@ export class OrcaRuntimeService { (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) + (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) || + (previous.payload.turnCompletedAt ?? null) !== (payload.turnCompletedAt ?? null) || + (previous.payload.lastAssistantMessage ?? null) !== (payload.lastAssistantMessage ?? null) ) } @@ -32428,6 +32430,31 @@ export class OrcaRuntimeService { : livePty ? this.issuePtyHandle(livePty) : null + const projectedAgentStatus = + agentStatus ?? + this.buildPtyMobileAgentStatus( + mobileStatusPty, + tab, + terminalHandle, + retainedAgentStatus, + getHookRowsForPane + ) + const projectedStatusEntry = projectedAgentStatus.agentStatus as + | (AgentStatusEntry & { turnCompletedAt?: number }) + | undefined + const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } = + projectedStatusEntry ?? {} + const clientAgentStatus = projectedStatusEntry + ? { agentStatus: clientStatusFields as AgentStatusEntry } + : {} + const rawTurnCompletedAt = + hookAgentStatus?.live?.payload.turnCompletedAt ?? + this.getHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ?? + projectedTurnCompletedAt + const turnCompletedAt = + typeof rawTurnCompletedAt === 'number' && Number.isFinite(rawTurnCompletedAt) + ? rawTurnCompletedAt + : undefined tabs.push({ type: 'terminal', id: tab.id, @@ -32437,14 +32464,8 @@ export class OrcaRuntimeService { ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), - ...(agentStatus ?? - this.buildPtyMobileAgentStatus( - mobileStatusPty, - tab, - terminalHandle, - retainedAgentStatus, - getHookRowsForPane - )), + ...clientAgentStatus, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.color != null ? { color: tab.color } : {}), diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts index 896ecc88548..ad1aab0ddcf 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts @@ -101,4 +101,76 @@ describe('agent completion coordinator identity map stays bounded (leak regressi coordinator.dispose() // isLive() still true -> remount, not teardown expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1) }) + + it('clears per-coordinator working boundaries on reset and dispose', () => { + const live = { value: true } + const coordinator = createAgentCompletionCoordinator( + makeOptions('tab-1:leaf-working-boundary', live) + ) + const working = { + state: 'working' as const, + prompt: '', + agentType: 'claude' as const, + stateStartedAt: 1_000 + } + + coordinator.observeHookStatus(working) + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1) + coordinator.resetCompletionState() + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0) + + coordinator.observeHookStatus(working) + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1) + coordinator.dispose() + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0) + }) + + it('retains stamped replay state when a mounted sibling disposes before remount', () => { + const paneKey = 'tab-1:leaf-production-remount' + const dispatchCompletion = vi.fn() + const hookLive = { value: true } + const paneLive = { value: true } + const hookCoordinator = createAgentCompletionCoordinator({ + ...makeOptions(paneKey, hookLive), + dispatchCompletion + }) + const paneCoordinator = createAgentCompletionCoordinator({ + ...makeOptions(paneKey, paneLive), + dispatchCompletion + }) + + hookCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_000 + }) + hookCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_000, + turnCompletedAt: 2_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + paneLive.value = false + paneCoordinator.dispose() + const remountedLive = { value: true } + const remounted = createAgentCompletionCoordinator({ + ...makeOptions(paneKey, remountedLive), + dispatchCompletion + }) + vi.advanceTimersByTime(2_000) + remounted.observeTitleWorking() + remounted.observeClassifiedTitleCompletion('Claude done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + hookLive.value = false + remountedLive.value = false + hookCoordinator.dispose() + remounted.dispose() + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0) + }) }) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts index 130d9e98edd..f28a3579a66 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts @@ -174,6 +174,17 @@ describe('agent completion coordinator', () => { await flushAsyncTicks() expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + result = processResult('codex') + vi.advanceTimersByTime(2_000) + await flushAsyncTicks() + result = processResult('zsh', false) + vi.advanceTimersByTime(750) + await flushAsyncTicks() + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) }) it('keeps duplicate done-only hooks inside replay guard suppressed after process inspection', async () => { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts new file mode 100644 index 00000000000..11d3cd0aac7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + HOOK_DONE_QUIET_MS, + useAgentCompletionCoordinatorLifecycle +} from './agent-completion-coordinator-test-harness' + +describe('agent completion coordinator stamped turn boundary', () => { + useAgentCompletionCoordinatorLifecycle() + + it('ignores a turn end time that cannot name a turn', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: Number.NaN + }) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('does not announce a gated Stop for a turn it never saw start', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('does not announce the all-clear after first seeing a stamped turn tail', () => { + const dispatchCompletion = vi.fn() + const firstCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + firstCoordinator.dispose() + + const remounted = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + remounted.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('announces a gated Stop immediately and treats the later all-clear as the same turn', () => { + const dispatchCompletion = vi.fn() + const dispatchHookLifecycle = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchHookLifecycle, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + lastAssistantMessage: 'Which cells need hand-verification?', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ + source: 'hook', + quietedHookDone: false, + agentStatus: expect.objectContaining({ + state: 'done', + lastAssistantMessage: 'Which cells need hand-verification?', + stateStartedAt: 1_700_000_005_000, + turnCompletedAt: 1_700_000_005_000 + }) + }) + ) + expect(dispatchHookLifecycle).toHaveBeenLastCalledWith( + expect.objectContaining({ state: 'working', turnCompletedAt: 1_700_000_005_000 }) + ) + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + lastAssistantMessage: 'Which cells need hand-verification?', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchHookLifecycle).toHaveBeenLastCalledWith( + expect.objectContaining({ state: 'done', turnCompletedAt: 1_700_000_005_000 }) + ) + }) + + it.each(['before', 'after'] as const)( + 'does not let a sibling OSC all-clear arriving %s the host stamp duplicate it', + (allClearOrder) => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 2_000 + }) + if (allClearOrder === 'before') { + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 5_500 + }) + } + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + lastAssistantMessage: 'Review complete.', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + if (allClearOrder === 'after') { + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 5_500 + }) + } + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + } + ) + + it('does not consume a fresh turn as the all-clear for an earlier stamped tail', () => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 6_000 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 6_500 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) + + it('keeps sibling tail suppression after the host stamped all-clear arrives first', () => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + hostCoordinator.observeHookStatus({ + state: 'done', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 3_000, + turnCompletedAt: 2_500 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_500 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_500 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('releases a stamped tail when its host reports a new same-state turn', () => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 5_500 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts new file mode 100644 index 00000000000..8e4fe3b45df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + HOOK_DONE_QUIET_MS, + useAgentCompletionCoordinatorLifecycle +} from './agent-completion-coordinator-test-harness' + +describe('agent completion coordinator stamped turn replay', () => { + useAgentCompletionCoordinatorLifecycle() + + it.each(['pty-first', 'host-first'] as const)( + 'releases stamped fallback dedupe when the next turn arrives %s', + (workingOrder) => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const observeHostNextTurn = () => + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + if (workingOrder === 'pty-first') { + localCoordinator.observeTitleWorking() + observeHostNextTurn() + } else { + observeHostNextTurn() + localCoordinator.observeTitleWorking() + } + localCoordinator.observeClassifiedTitleCompletion('Claude done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + } + ) + + it.each(['before', 'after'] as const)( + 'keeps sibling tail replay suppression when the pane remounts %s its all-clear', + (remountOrder) => { + const dispatchCompletion = vi.fn() + const createLocalCoordinator = () => + createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const localCoordinator = createLocalCoordinator() + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const localAllClear = { + state: 'done' as const, + prompt: 'first turn', + agentType: 'claude' as const, + stateStartedAt: 5_500 + } + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + if (remountOrder === 'after') { + localCoordinator.observeHookStatus(localAllClear) + } + localCoordinator.dispose() + const remounted = createLocalCoordinator() + remounted.observeHookStatus(localAllClear) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + } + ) + + it('does not replay a stamped turn or its all-clear after a working title blip', () => { + const dispatchCompletion = vi.fn() + const dispatchHookLifecycle = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchHookLifecycle, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2_000) + coordinator.observeTitleWorking() + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_050_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchHookLifecycle).toHaveBeenLastCalledWith( + expect.objectContaining({ state: 'done', turnCompletedAt: 1_700_000_005_000 }) + ) + }) + + it('does not double-fire after a live remount between the gated Stop and the all-clear', () => { + const dispatchCompletion = vi.fn() + const firstCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + firstCoordinator.dispose() + + const remounted = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + remounted.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('keeps stamped replay suppression through a working title and live remount', () => { + const dispatchCompletion = vi.fn() + const firstCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + firstCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2_000) + firstCoordinator.observeTitleWorking() + firstCoordinator.dispose() + + const remounted = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + remounted.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('does not let a pane title duplicate another coordinator stamped completion', () => { + const dispatchCompletion = vi.fn() + const hookCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const paneCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + hookCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + hookCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2_000) + paneCoordinator.observeTitleWorking() + paneCoordinator.observeClassifiedTitleCompletion('Claude done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + hookCoordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + paneCoordinator.observeTitleWorking() + paneCoordinator.observeClassifiedTitleCompletion('Claude done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) + + it('does not let a vetoed gated Stop swallow the later all-clear', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000 + }) + coordinator.observeClassifiedTitleCompletion('Claude done') + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_000_000, + turnCompletedAt: 1_700_000_005_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + coordinator.observeHookStatus({ + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_700_000_055_000, + turnCompletedAt: 1_700_000_005_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index a388ec17457..9614d0bbffc 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -5,6 +5,8 @@ import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-termina export type AgentCompletionStatusSnapshot = ParsedAgentStatusPayload & { stateStartedAt?: number + /** Renderer-local boundary used only to reject a delayed cross-host completion. */ + localStateStartedAt?: number } export type AgentCompletionDispatchMeta = { @@ -21,6 +23,7 @@ export type AgentAttentionDispatchMeta = { export type AgentCompletionCoordinatorOptions = { paneKey: string + statusLane?: 'hook' | 'pty' getPtyId: () => string | null getSettings: () => Pick | null | undefined inspectProcess: ( @@ -50,6 +53,7 @@ export type AgentCompletionCoordinator = { observeTitleWorking: () => void observeOutputActivity: () => void observeHookStatus: (payload: AgentCompletionStatusSnapshot) => void + seedHookStatus: (payload: AgentCompletionStatusSnapshot) => void startProcessTracking: () => void hasPendingHookDoneCompletion: () => boolean resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index b3059ca6390..f0802bd123a 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -31,10 +31,29 @@ type LastCompletionIdentity = { source: CompletionIdentitySource identity: string agentIdentity: string | null + /** End time of the Claude lead turn already announced while the pane stayed `working` for background inventory. The later all-clear `done` repeats this value and must not raise a second banner. */ + lastTurnCompletedAtNotified?: number } // Why: worktree switches remount a pane while the PTY/hook stream stays live, so stale completion replays must outlive one coordinator. const lastCompletionIdentityByPaneKey = new Map() +// Why: a late-pair stamped tail must suppress its replayed all-clear even though no banner was eligible. +const handledTurnCompletedAtsByPaneKey = new Map() +// Why: title fallback stays blocked only until the stamped background tail reaches its all-clear. +const pendingStampedTailByPaneKey = new Map< + string, + { + turnCompletedAt: number + originLane: string + eligibleWorkingBoundaryByLane: Map + consumedIdentityByLane: Map + tailOpen: boolean + } +>() +// Why: a sibling all-clear belongs to the stamped tail only while its exact working interval is still current. +const workingBoundaryByPaneKey = new Map>() +const coordinatorCountByPaneKey = new Map() +let nextCoordinatorId = 1 const IDLE_POLL_INTERVAL_MS = 2_000 const ACTIVE_POLL_INTERVAL_MS = 750 @@ -51,6 +70,7 @@ const COMPLETION_REPLAY_GUARD_MS = 1_000 const HOOK_DONE_QUIET_MS = 1_500 // Why: under "Approve for me" Codex resumes almost immediately, so debounce the OS attention notification so a self-resolving pause raises no false banner (#8387). const CODEX_ATTENTION_QUIET_MS = 1_500 +const HANDLED_TURN_STAMP_LIMIT = 16 const POLL_TIER_INTERVAL_MS: Record = { active: ACTIVE_POLL_INTERVAL_MS, @@ -59,6 +79,10 @@ const POLL_TIER_INTERVAL_MS: Record = { 'no-evidence': NO_EVIDENCE_POLL_INTERVAL_MS } +function isFiniteTurnCompletedAt(value: number | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + function isCompletionHookState(state: ParsedAgentStatusPayload['state']): boolean { // Why: only a genuine 'done' ends a turn; 'waiting'/'blocked' go through isAttentionHookState. return state === 'done' @@ -72,6 +96,15 @@ function isAttentionHookState(state: ParsedAgentStatusPayload['state']): boolean export function createAgentCompletionCoordinator( options: AgentCompletionCoordinatorOptions ): AgentCompletionCoordinator { + const coordinatorId = nextCoordinatorId++ + const coordinatorLane = options.statusLane ?? `coordinator:${coordinatorId}` + let inheritedWorkingBoundary = pendingStampedTailByPaneKey + .get(options.paneKey) + ?.eligibleWorkingBoundaryByLane.get(coordinatorLane) + coordinatorCountByPaneKey.set( + options.paneKey, + (coordinatorCountByPaneKey.get(options.paneKey) ?? 0) + 1 + ) let disposed = false let agentIdentityEstablished = false let hasAgentRunEvidence = false @@ -169,15 +202,113 @@ export function createAgentCompletionCoordinator( return `${source}:${currentTurn}:${processSession}` } + function completionIdentityFor( + state: string, + agentType: string | undefined, + timestamp: number + ): string { + return [state, agentType ?? '', String(Math.trunc(timestamp))].join(':') + } + function hookCompletionIdentity(payload: AgentCompletionStatusSnapshot): string | null { - if (typeof payload.stateStartedAt !== 'number' || !Number.isFinite(payload.stateStartedAt)) { + // Why: `stateStartedAt` is pinned while the reported state does not change. A Claude pane held at `working` by background inventory would otherwise give every turn in the run the same identity. + const timestamp = isFiniteTurnCompletedAt(payload.turnCompletedAt) + ? payload.turnCompletedAt + : payload.stateStartedAt + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { return null } - return [ - payload.state, - payload.agentType ?? '', - String(Math.trunc(payload.stateStartedAt)) - ].join(':') + return completionIdentityFor(payload.state, payload.agentType, timestamp) + } + + function turnCompletedAtAlreadyHandled(turnCompletedAt: number): boolean { + return ( + handledTurnCompletedAtsByPaneKey.get(options.paneKey)?.includes(turnCompletedAt) === true || + lastCompletionIdentityByPaneKey.get(options.paneKey)?.lastTurnCompletedAtNotified === + turnCompletedAt + ) + } + + function rememberHandledTurnCompletedAt(turnCompletedAt: number): void { + const handled = handledTurnCompletedAtsByPaneKey.get(options.paneKey) ?? [] + if (handled.includes(turnCompletedAt)) { + return + } + handled.push(turnCompletedAt) + if (handled.length > HANDLED_TURN_STAMP_LIMIT) { + handled.shift() + } + handledTurnCompletedAtsByPaneKey.set(options.paneKey, handled) + } + + function recordWorkingBoundary(stateStartedAt: number | undefined): void { + let boundaries = workingBoundaryByPaneKey.get(options.paneKey) + if (!isFiniteTurnCompletedAt(stateStartedAt)) { + boundaries?.delete(coordinatorLane) + if (boundaries?.size === 0) { + workingBoundaryByPaneKey.delete(options.paneKey) + } + return + } + if (!boundaries) { + boundaries = new Map() + workingBoundaryByPaneKey.set(options.paneKey, boundaries) + } + inheritedWorkingBoundary = undefined + boundaries.set(coordinatorLane, stateStartedAt) + } + + function clearWorkingBoundary(): void { + inheritedWorkingBoundary = undefined + const boundaries = workingBoundaryByPaneKey.get(options.paneKey) + boundaries?.delete(coordinatorLane) + if (boundaries?.size === 0) { + workingBoundaryByPaneKey.delete(options.paneKey) + } + } + + function consumePendingStampedTailForAgent( + agentIdentity: string | null, + completionIdentity: string | null + ): boolean { + const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) + const stampedCompletion = lastCompletionIdentityByPaneKey.get(options.paneKey) + const currentWorkingBoundary = workingBoundaryByPaneKey + .get(options.paneKey) + ?.get(coordinatorLane) + const eligibleWorkingBoundary = + pendingStampedTail?.eligibleWorkingBoundaryByLane.get(coordinatorLane) + if ( + pendingStampedTail === undefined || + stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail.turnCompletedAt || + agentIdentity === null || + stampedCompletion.agentIdentity !== agentIdentity || + eligibleWorkingBoundary === undefined || + (currentWorkingBoundary !== eligibleWorkingBoundary && + inheritedWorkingBoundary !== eligibleWorkingBoundary) + ) { + return false + } + pendingStampedTail.eligibleWorkingBoundaryByLane.delete(coordinatorLane) + inheritedWorkingBoundary = undefined + if (completionIdentity) { + pendingStampedTail.consumedIdentityByLane.set(coordinatorLane, completionIdentity) + } + pendingStampedTail.tailOpen = pendingStampedTail.eligibleWorkingBoundaryByLane.size > 0 + return true + } + + function consumeStampedTailForCurrentCoordinator(turnCompletedAt: number): void { + const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) + if (pendingStampedTail?.turnCompletedAt !== turnCompletedAt) { + return + } + pendingStampedTail.eligibleWorkingBoundaryByLane.delete(coordinatorLane) + pendingStampedTail.tailOpen = pendingStampedTail.eligibleWorkingBoundaryByLane.size > 0 + } + + function hasUnconsumedStampedTail(): boolean { + return pendingStampedTailByPaneKey.get(options.paneKey)?.tailOpen === true } function hookCompletionAgentIdentity(payload: AgentCompletionStatusSnapshot): string | null { @@ -247,12 +378,27 @@ export function createAgentCompletionCoordinator( if (!completionIdentity) { return false } + if ( + completionIdentity.source === 'hook' && + pendingStampedTailByPaneKey + .get(options.paneKey) + ?.consumedIdentityByLane.get(coordinatorLane) === completionIdentity.identity + ) { + return true + } const previous = lastCompletionIdentityByPaneKey.get(options.paneKey) if (!previous) { return false } if (previous.source === completionIdentity.source) { - return previous.identity === completionIdentity.identity + return ( + previous.identity === completionIdentity.identity || + (completionIdentity.source === 'hook' && + consumePendingStampedTailForAgent( + completionIdentity.agentIdentity, + completionIdentity.identity + )) + ) } return ( previous.agentIdentity !== null && @@ -269,24 +415,26 @@ export function createAgentCompletionCoordinator( terminalIdleConfirmed?: boolean agentStatus?: AgentCompletionStatusSnapshot completionIdentity?: LastCompletionIdentity | null + /** Announce only. The pane is still genuinely `working` (Claude background inventory), so the synthetic `done` must not run pane lifecycle. */ + notifyWithoutLifecycle?: boolean } = {} - ): void { + ): boolean { if (source !== 'hook' && pendingHookDoneTimer !== null) { - return + return false } if (requiresFreshWorking || lastCompletedTurn === currentTurn) { - return + return false } if (!options.isLive() || !hasAgentRunEvidence) { - return + return false } const now = Date.now() const token = completionToken(source) if (token === lastCompletionToken && now - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS) { - return + return false } if (completionIdentityAlreadyNotified(optionsOverride.completionIdentity)) { - return + return false } lastCompletionToken = token lastCompletionAt = now @@ -297,8 +445,19 @@ export function createAgentCompletionCoordinator( clearPendingCodexAttention() if (optionsOverride.completionIdentity) { lastCompletionIdentityByPaneKey.set(options.paneKey, optionsOverride.completionIdentity) + if (optionsOverride.completionIdentity.lastTurnCompletedAtNotified !== undefined) { + rememberHandledTurnCompletedAt( + optionsOverride.completionIdentity.lastTurnCompletedAtNotified + ) + } else { + pendingStampedTailByPaneKey.delete(options.paneKey) + } } - if (source === 'hook' && optionsOverride.agentStatus) { + if ( + source === 'hook' && + optionsOverride.agentStatus && + optionsOverride.notifyWithoutLifecycle !== true + ) { options.dispatchHookLifecycle?.(optionsOverride.agentStatus) } if (optionsOverride.quietedHookDone === true || source === 'process-exit') { @@ -309,9 +468,17 @@ export function createAgentCompletionCoordinator( ...(optionsOverride.terminalIdleConfirmed === true ? { terminalIdleConfirmed: true } : {}), ...(optionsOverride.agentStatus ? { agentStatus: optionsOverride.agentStatus } : {}) }) + } else if (optionsOverride.notifyWithoutLifecycle === true && optionsOverride.agentStatus) { + // Why: the pane is still `working`; the synthetic done must carry its own snapshot or the notification would read the pinned working row. + options.dispatchCompletion(title, { + source, + quietedHookDone: false, + agentStatus: optionsOverride.agentStatus + }) } else { options.dispatchCompletion(title) } + return true } function dispatchAttentionNotification(payload: AgentCompletionStatusSnapshot): void { @@ -449,6 +616,17 @@ export function createAgentCompletionCoordinator( function handleRecognizedProcess(process: RecognizedAgentProcess): void { pendingProcessExitAgent = null + const replayIdentity = lastCompletionIdentityByPaneKey.get(options.paneKey) + if ( + !lastForegroundAgent && + processSession > 0 && + !hasUnconsumedStampedTail() && + replayIdentity?.source === 'hook' && + replayIdentity.agentIdentity === process.agent + ) { + // Why: the prior observed process exited and this is a new same-agent process session, so its fallback completion cannot replay the old hook turn. + lastCompletionIdentityByPaneKey.delete(options.paneKey) + } if (lastForegroundAgent?.agent !== process.agent) { if (lastForegroundAgent && hasAgentRunEvidence) { if ( @@ -509,7 +687,8 @@ export function createAgentCompletionCoordinator( const exited = lastForegroundAgent pendingProcessExitAgent = null if (options.shouldSuppressConfirmedProcessExitCompletion?.(exited) !== true) { - dispatchCompletion('process-exit', exited.processName, { + const replayIdentityBeforeExit = lastCompletionIdentityByPaneKey.get(options.paneKey) + const committed = dispatchCompletion('process-exit', exited.processName, { terminalIdleConfirmed: true, completionIdentity: { source: 'process-exit', @@ -517,6 +696,15 @@ export function createAgentCompletionCoordinator( agentIdentity: exited.agent } }) + if ( + !committed && + !hasUnconsumedStampedTail() && + replayIdentityBeforeExit?.source === 'hook' && + replayIdentityBeforeExit.agentIdentity === exited.agent + ) { + // Why: this confirmed exit consumed the hook turn's process backstop; a later process instance must be allowed to complete independently. + lastCompletionIdentityByPaneKey.delete(options.paneKey) + } } lastForegroundAgent = null clearAgentRunEvidence() @@ -697,7 +885,9 @@ export function createAgentCompletionCoordinator( clearPendingCodexAttention() workingStatusObserved = true requiresFreshWorking = false - lastCompletionIdentityByPaneKey.delete(options.paneKey) + if (!hasUnconsumedStampedTail()) { + lastCompletionIdentityByPaneKey.delete(options.paneKey) + } currentTurn += 1 dropPendingTitle() return true @@ -792,6 +982,67 @@ export function createAgentCompletionCoordinator( establishAgentEvidence() } if (payload.state === 'working') { + const turnCompletedAt = isFiniteTurnCompletedAt(payload.turnCompletedAt) + ? payload.turnCompletedAt + : undefined + if (turnCompletedAt !== undefined) { + const alreadyHandled = turnCompletedAtAlreadyHandled(turnCompletedAt) + if (!alreadyHandled) { + const eligibleWorkingBoundaryByLane = new Map( + workingBoundaryByPaneKey.get(options.paneKey) + ) + eligibleWorkingBoundaryByLane.delete(coordinatorLane) + pendingStampedTailByPaneKey.set(options.paneKey, { + turnCompletedAt, + originLane: coordinatorLane, + eligibleWorkingBoundaryByLane, + consumedIdentityByLane: new Map(), + tailOpen: true + }) + } + // Why: Claude's lead Stop already ended the turn; the pane stays `working` only for background inventory. Announce now, keep lifecycle on the reported working row. + if ( + workingStatusObserved && + !alreadyHandled && + lastCompletionIdentity?.lastTurnCompletedAtNotified !== turnCompletedAt + ) { + const completionSnapshot: AgentCompletionStatusSnapshot = { + ...payload, + state: 'done', + stateStartedAt: turnCompletedAt, + turnCompletedAt + } + const completionIdentity: LastCompletionIdentity = { + source: 'hook', + identity: completionIdentityFor('done', payload.agentType, turnCompletedAt), + agentIdentity: hookCompletionAgentIdentity(payload), + lastTurnCompletedAtNotified: turnCompletedAt + } + const committed = dispatchCompletion('hook', payload.agentType ?? options.paneKey, { + notifyWithoutLifecycle: true, + agentStatus: completionSnapshot, + completionIdentity + }) + if (committed) { + lastCompletionIdentity = completionIdentity + } + } else if (!workingStatusObserved) { + rememberHandledTurnCompletedAt(turnCompletedAt) + } + options.dispatchHookLifecycle?.(payload) + return + } + const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) + if (pendingStampedTail?.originLane === coordinatorLane) { + pendingStampedTailByPaneKey.delete(options.paneKey) + if ( + lastCompletionIdentityByPaneKey.get(options.paneKey)?.lastTurnCompletedAtNotified === + pendingStampedTail.turnCompletedAt + ) { + lastCompletionIdentityByPaneKey.delete(options.paneKey) + } + } + recordWorkingBoundary(payload.stateStartedAt) clearPendingHookDone() // Why: resumed work cancels the debounced attention so a self-resolving pause never notifies. clearPendingCodexAttention() @@ -824,6 +1075,35 @@ export function createAgentCompletionCoordinator( establishAgentEvidence() } const hookIdentity = hookCompletionIdentity(payload) + const turnCompletedAt = isFiniteTurnCompletedAt(payload.turnCompletedAt) + ? payload.turnCompletedAt + : undefined + if ( + turnCompletedAt === undefined && + consumePendingStampedTailForAgent(hookCompletionAgentIdentity(payload), hookIdentity) + ) { + // Why: paired host hooks carry the stamp while the sibling OSC coordinator sees only the matching all-clear. + lastCompletionIdentity = hookIdentity + ? { + source: 'hook', + identity: hookIdentity, + agentIdentity: hookCompletionAgentIdentity(payload) + } + : null + options.dispatchHookLifecycle?.(payload) + return + } + if ( + turnCompletedAt !== undefined && + (turnCompletedAtAlreadyHandled(turnCompletedAt) || + lastCompletionIdentity?.lastTurnCompletedAtNotified === turnCompletedAt || + lastCompletedTurn === currentTurn) + ) { + // Why: this `done` is the background all-clear of a turn already announced (or completed from another source). Keep pane lifecycle; do not raise a second banner. + consumeStampedTailForCurrentCoordinator(turnCompletedAt) + options.dispatchHookLifecycle?.(payload) + return + } if ( hookIdentity && lastCompletionIdentity?.source === 'hook' && @@ -869,6 +1149,14 @@ export function createAgentCompletionCoordinator( } } + function seedHookStatus(payload: AgentCompletionStatusSnapshot): void { + const { turnCompletedAt, ...unstampedPayload } = payload + if (isFiniteTurnCompletedAt(turnCompletedAt)) { + rememberHandledTurnCompletedAt(turnCompletedAt) + } + observeHookStatus(unstampedPayload) + } + function markTitleCompletionNotified(title: string): void { lastCompletionIdentity = { source: 'title', @@ -903,17 +1191,31 @@ export function createAgentCompletionCoordinator( lastForegroundAgent = null requiresFreshWorking = options.requireFreshWorking ?? false inspectionGeneration += 1 + clearWorkingBoundary() } function dispose(): void { + if (disposed) { + return + } disposed = true clearPollTimer() clearPendingHookDone() clearPendingCodexAttention() dropPendingTitle() - // Why: module-scoped identity survives a live-stream remount; evict only on genuine teardown (isLive() false) so it can't leak per closed pane. - if (!options.isLive()) { + clearWorkingBoundary() + const remainingCoordinators = (coordinatorCountByPaneKey.get(options.paneKey) ?? 1) - 1 + if (remainingCoordinators > 0) { + coordinatorCountByPaneKey.set(options.paneKey, remainingCoordinators) + } else { + coordinatorCountByPaneKey.delete(options.paneKey) + } + // Why: one pane can have hook and mounted coordinators; a remount disposal must not erase replay state still owned by its live sibling. + if (remainingCoordinators <= 0 && !options.isLive()) { lastCompletionIdentityByPaneKey.delete(options.paneKey) + handledTurnCompletedAtsByPaneKey.delete(options.paneKey) + pendingStampedTailByPaneKey.delete(options.paneKey) + workingBoundaryByPaneKey.delete(options.paneKey) } } @@ -923,6 +1225,7 @@ export function createAgentCompletionCoordinator( observeTitleWorking, observeOutputActivity, observeHookStatus, + seedHookStatus, startProcessTracking, hasPendingHookDoneCompletion, resetCompletionState, @@ -932,8 +1235,18 @@ export function createAgentCompletionCoordinator( export function resetAgentCompletionCoordinatorIdentitiesForTest(): void { lastCompletionIdentityByPaneKey.clear() + handledTurnCompletedAtsByPaneKey.clear() + pendingStampedTailByPaneKey.clear() + workingBoundaryByPaneKey.clear() + coordinatorCountByPaneKey.clear() + nextCoordinatorId = 1 } export function getAgentCompletionCoordinatorIdentityCountForTest(): number { - return lastCompletionIdentityByPaneKey.size + return new Set([ + ...lastCompletionIdentityByPaneKey.keys(), + ...handledTurnCompletedAtsByPaneKey.keys(), + ...pendingStampedTailByPaneKey.keys(), + ...workingBoundaryByPaneKey.keys() + ]).size } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts b/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts index f93542cf196..7b973c4a0be 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-snapshot-staleness.ts @@ -8,16 +8,20 @@ export function isSupersededAgentCompletionSnapshot( if (!storedAgentStatus || !snapshot) { return false } - if (typeof snapshot.stateStartedAt !== 'number') { + const comparableStateStartedAt = snapshot.localStateStartedAt ?? snapshot.stateStartedAt + if (typeof comparableStateStartedAt !== 'number') { return storedAgentStatus.state !== snapshot.state } // Why: hook completion notifications are delayed by a quiet window; by the // time they fire, the same pane may already belong to a newer agent turn. - if (storedAgentStatus.stateStartedAt > snapshot.stateStartedAt) { + if (storedAgentStatus.stateStartedAt > comparableStateStartedAt) { return true } + const hasStampedTurn = + typeof snapshot.turnCompletedAt === 'number' && Number.isFinite(snapshot.turnCompletedAt) return ( - storedAgentStatus.stateStartedAt === snapshot.stateStartedAt && - storedAgentStatus.state !== snapshot.state + storedAgentStatus.stateStartedAt === comparableStateStartedAt && + storedAgentStatus.state !== snapshot.state && + !hasStampedTurn ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 397d5bf84a7..1010bb579d8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2425,6 +2425,7 @@ export function connectPanePty( const agentCompletionCoordinator = createAgentCompletionCoordinator({ paneKey: cacheKey, + statusLane: 'pty', getPtyId: () => transport.getPtyId(), getSettings: () => useAppStore.getState().settings, inspectProcess: inspectRuntimeTerminalProcess, diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 0fa4e74dd88..1267b986827 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -190,6 +190,99 @@ describe('dispatchTerminalNotification', () => { expect(mockState.markTerminalPaneUnread).toHaveBeenCalledWith(paneKey) }) + it('builds the notification id from a completion snapshot, not the pinned working row', () => { + const pinnedWorkingStartedAt = Date.now() - 60_000 + // Why: the stored row must name the event's agent, or it is dropped for identity mismatch + // and the assertion would hold whichever side of the `??` wins. + mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, { + state: 'working', + stateStartedAt: pinnedWorkingStartedAt, + agentType: 'claude', + terminalTitle: 'claude' + }) + const turnCompletedAt = Date.now() + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'claude', + paneKey, + agentStatusSnapshot: { + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: turnCompletedAt + } + }) + + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + notificationId: buildAgentNotificationId({ + worktreeId: 'wt-primary', + paneKey, + stateStartedAt: turnCompletedAt + }) + }) + ) + }) + + it.each([ + { clientStateStartedAt: 5_000, hostTurnCompletedAt: 2_000 }, + { clientStateStartedAt: 2_000, hostTurnCompletedAt: 5_000 } + ])( + 'accepts a host-stamped completion across client/host clock skew %#', + ({ clientStateStartedAt, hostTurnCompletedAt }) => { + mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, { + state: 'working', + stateStartedAt: clientStateStartedAt, + agentType: 'claude', + terminalTitle: 'claude' + }) + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'claude', + paneKey, + agentStatusSnapshot: { + state: 'done', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: hostTurnCompletedAt, + localStateStartedAt: clientStateStartedAt, + turnCompletedAt: hostTurnCompletedAt + } + }) + + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + } + ) + + it('drops a host-stamped completion after a newer client turn starts', () => { + mockState.agentStatusByPaneKey[paneKey] = makeAgentStatus(paneKey, { + state: 'working', + stateStartedAt: 6_000, + agentType: 'claude', + terminalTitle: 'claude' + }) + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'claude', + paneKey, + agentStatusSnapshot: { + state: 'done', + prompt: 'previous turn', + agentType: 'claude', + stateStartedAt: 2_000, + localStateStartedAt: 5_000, + turnCompletedAt: 2_000 + } + }) + + expect(window.api.notifications.dispatch).not.toHaveBeenCalled() + }) + it('uses a live pane key when inactive worktree tab membership is not hydrated', () => { mockState.tabsByWorktree = {} diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index 20a0ddadf89..29b4ace2c1f 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -121,7 +121,7 @@ export function dispatchTerminalNotification( return } const agentNotificationStateStartedAt = - freshStoredAgentStatus?.stateStartedAt ?? eventAgentStatusSnapshot?.stateStartedAt + eventAgentStatusSnapshot?.stateStartedAt ?? freshStoredAgentStatus?.stateStartedAt // Why: main-process hook IPC can update inactive/unmounted worktrees before // the renderer's live-PTY map catches up. A fresh accepted hook snapshot is // authoritative for agent completion; title/BEL-only paths still need PTY liveness. diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts new file mode 100644 index 00000000000..8583c3bb0de --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { createHookListenerState, normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { makePaneKey } from '../../../shared/stable-pane-id' + +const dispatchTerminalNotification = vi.fn() +const dispatchAgentHookTerminalLifecycle = vi.fn() + +const HOOK_DONE_QUIET_MS = 1_500 +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +const WORKTREE_ID = 'wt-1' + +type MockStoreState = { + settings: { + experimentalTerminalAttention?: boolean + notifications: { + enabled: boolean + agentTaskComplete: boolean + } + } + ptyIdsByTabId: Record + suppressedPtyExitIds: Record + tabsByWorktree: Record + terminalLayoutsByTabId: Record + agentLaunchConfigByPaneKey: Record + agentStatusByPaneKey: Record + getAgentLaunchConfigForStatusEntry: () => undefined + getAgentLaunchConfigForStatusMetadata: () => undefined +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState + } +})) + +vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/components/terminal-pane/agent-hook-terminal-lifecycle', () => ({ + dispatchAgentHookTerminalLifecycle +})) + +type HookRow = ParsedAgentStatusPayload & { stateStartedAt: number } + +function pinStateStartedAt( + previous: HookRow | undefined, + payload: ParsedAgentStatusPayload, + now: number +): HookRow { + return { + ...payload, + stateStartedAt: previous && previous.state === payload.state ? previous.stateStartedAt : now + } +} + +describe('Claude background-turn completion notifications', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + dispatchTerminalNotification.mockClear() + dispatchAgentHookTerminalLifecycle.mockClear() + mockStoreState = { + settings: { + experimentalTerminalAttention: false, + notifications: { + enabled: true, + agentTaskComplete: true + } + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + }, + suppressedPtyExitIds: {}, + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', ptyId: 'pty-1' }] + }, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {}, + getAgentLaunchConfigForStatusEntry: () => undefined, + getAgentLaunchConfigForStatusMetadata: () => undefined + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function play(events: { at: number; payload: Record }[]): Promise<{ + banners: { at: number; body: string | undefined; stateStartedAt: number | undefined }[] + rows: HookRow[] + }> { + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + const listener = createHookListenerState() + const rows: HookRow[] = [] + let previous: HookRow | undefined + const banners: { + at: number + body: string | undefined + stateStartedAt: number | undefined + }[] = [] + + dispatchTerminalNotification.mockImplementation((_worktreeId, event) => { + banners.push({ + at: Date.now(), + body: event.agentStatusSnapshot?.lastAssistantMessage, + stateStartedAt: event.agentStatusSnapshot?.stateStartedAt + }) + }) + + for (const event of events) { + vi.setSystemTime(event.at) + const normalized = normalizeHookPayload( + listener, + 'claude', + { paneKey: PANE_KEY, payload: event.payload }, + 'production' + ) + if (!normalized) { + continue + } + const row = pinStateStartedAt(previous, normalized.payload, event.at) + previous = row + rows.push(row) + observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + payload: row + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + } + + return { banners, rows } + } + + it('announces a subagent turn at Stop with that turn’s text, not when the child later drains', async () => { + const { banners, rows } = await play([ + { + at: 1_700_000_000_000, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' } + }, + { + at: 1_700_000_001_000, + payload: { + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + } + }, + { + at: 1_700_000_006_000, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'Which cells need hand-verification?', + background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }] + } + }, + { + at: 1_700_000_055_000, + payload: { hook_event_name: 'SubagentStop', agent_id: 'a1' } + } + ]) + + const stop = rows.find((row) => row.lastAssistantMessage?.includes('hand-verification')) + expect(stop?.state).toBe('working') + expect(stop?.turnCompletedAt).toBe(1_700_000_006_000) + expect(banners).toHaveLength(1) + expect(banners[0]?.at).toBe(1_700_000_006_000) + expect(banners[0]?.body).toBe('Which cells need hand-verification?') + expect(banners[0]?.stateStartedAt).toBe(1_700_000_006_000) + }) + + it('notifies each background-shell turn with a distinct notification identity', async () => { + const { banners } = await play([ + { + at: 1_700_000_000_000, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'start the server' } + }, + { + at: 1_700_000_001_000, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'Dev server is up.', + background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] + } + }, + { + at: 1_700_000_010_000, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'add a route' } + }, + { + at: 1_700_000_011_000, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'Route added.', + background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] + } + } + ]) + + expect(banners).toHaveLength(2) + expect(banners[0]).toMatchObject({ + at: 1_700_000_001_000, + body: 'Dev server is up.', + stateStartedAt: 1_700_000_001_000 + }) + expect(banners[1]).toMatchObject({ + at: 1_700_000_011_000, + body: 'Route added.', + stateStartedAt: 1_700_000_011_000 + }) + expect(banners[0]?.stateStartedAt).not.toBe(banners[1]?.stateStartedAt) + }) + + it('notifies every turn under a persistent session cron with distinct ids', async () => { + const cron = [{ id: 'cron-1' }] + const { banners } = await play([ + { + at: 1_700_000_000_000, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } + }, + { + at: 1_700_000_001_000, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'First done.', + session_crons: cron + } + }, + { + at: 1_700_000_002_000, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'second' } + }, + { + at: 1_700_000_003_000, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'Second done.', + session_crons: cron + } + }, + { + at: 1_700_000_004_000, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'third' } + }, + { + at: 1_700_000_005_000, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'Third done.', + session_crons: cron + } + } + ]) + + expect(banners.map((banner) => banner.body)).toEqual([ + 'First done.', + 'Second done.', + 'Third done.' + ]) + expect(new Set(banners.map((banner) => banner.stateStartedAt)).size).toBe(3) + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts b/src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts new file mode 100644 index 00000000000..006a848fdab --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { makePaneKey } from '../../../shared/stable-pane-id' + +const dispatchTerminalNotification = vi.fn() +const dispatchAgentHookTerminalLifecycle = vi.fn() + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const WORKTREE_ID = 'wt-1' + +type MockStoreState = { + settings: { + experimentalTerminalAttention: boolean + notifications: { enabled: boolean; agentTaskComplete: boolean } + } + ptyIdsByTabId: Record + suppressedPtyExitIds: Record + tabsByWorktree: Record + terminalLayoutsByTabId: Record + agentLaunchConfigByPaneKey: Record + agentStatusByPaneKey: Record + getAgentLaunchConfigForStatusEntry: () => undefined + getAgentLaunchConfigForStatusMetadata: () => undefined +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/components/terminal-pane/agent-hook-terminal-lifecycle', () => ({ + dispatchAgentHookTerminalLifecycle +})) + +function working(turnCompletedAt?: number): ParsedAgentStatusPayload { + return { + state: 'working', + prompt: 'implement notifications', + agentType: 'claude', + lastAssistantMessage: turnCompletedAt === undefined ? undefined : 'Turn complete.', + turnCompletedAt + } +} + +describe('agent hook completion fresh-working gate', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalNotification.mockClear() + dispatchAgentHookTerminalLifecycle.mockClear() + mockStoreState = { + settings: { + experimentalTerminalAttention: false, + notifications: { enabled: true, agentTaskComplete: false } + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + suppressedPtyExitIds: {}, + tabsByWorktree: { [WORKTREE_ID]: [{ id: 'tab-1', ptyId: 'pty-1' }] }, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {}, + getAgentLaunchConfigForStatusEntry: () => undefined, + getAgentLaunchConfigForStatusMetadata: () => undefined + } + }) + + afterEach(() => vi.useRealTimers()) + + it('stays gated through a stamped working completion after re-enable', async () => { + const { + observeAgentHookCompletionForNotification, + syncAgentHookCompletionNotificationSettings + } = await import('./agent-hook-completion-notifications') + + syncAgentHookCompletionNotificationSettings() + observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + payload: { ...working(), stateStartedAt: 1_000 } + }) + + mockStoreState.settings.notifications.agentTaskComplete = true + syncAgentHookCompletionNotificationSettings() + observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + payload: working(1_500) + }) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + payload: { ...working(), stateStartedAt: 2_000 } + }) + observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + payload: { ...working(2_500), stateStartedAt: 2_000 } + }) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ + source: 'agent-task-complete', + paneKey: PANE_KEY, + agentStatusSnapshot: expect.objectContaining({ + state: 'done', + turnCompletedAt: 2_500, + lastAssistantMessage: 'Turn complete.' + }) + }) + ) + }) +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts new file mode 100644 index 00000000000..390439d894c --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts @@ -0,0 +1,17 @@ +import { expect, it, vi } from 'vitest' + +vi.mock('@/store', () => ({ + useAppStore: vi.fn() +})) + +vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({ + dispatchTerminalNotification: vi.fn() +})) + +vi.mock('@/components/terminal-pane/agent-hook-terminal-lifecycle', () => ({ + dispatchAgentHookTerminalLifecycle: vi.fn() +})) + +it('does not read the Zustand store while importing completion notifications', async () => { + await expect(import('./agent-hook-completion-notifications')).resolves.toBeDefined() +}) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 4803731fb70..63c33c7f26e 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -167,9 +167,7 @@ describe('agent hook completion notifications', () => { } }) - afterEach(() => { - vi.useRealTimers() - }) + afterEach(() => vi.useRealTimers()) it('requires fresh working after notifications start disabled and later re-enable', async () => { mockStoreState.settings.notifications.agentTaskComplete = false @@ -178,6 +176,7 @@ describe('agent hook completion notifications', () => { syncAgentHookCompletionNotificationSettings } = await import('./agent-hook-completion-notifications') + syncAgentHookCompletionNotificationSettings() mockStoreState.settings.notifications.agentTaskComplete = true syncAgentHookCompletionNotificationSettings() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 125c0994514..7d226331eed 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -11,6 +11,7 @@ import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-seriali import { createCodexAutoApprovalHookCompletionSuppressor } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' import { dispatchAgentHookTerminalLifecycle } from '@/components/terminal-pane/agent-hook-terminal-lifecycle' import { + isAgentHookCompletionTrackingEnabled, shouldSyncAgentHookCompletionForStoreUpdate, type AgentHookCompletionStoreSnapshot } from './agent-hook-completion-store-sync' @@ -33,8 +34,8 @@ type PaneCoordinatorLivenessSnapshot = Pick< const coordinatorsByPaneKey = new Map() const paneKeysRequiringFreshWorking = new Set() -let wasAgentTaskCompleteTrackingEnabled = isAgentTaskCompleteTrackingEnabled() -let requireFreshWorkingForNewTrackingCoordinators = !wasAgentTaskCompleteTrackingEnabled +let wasAgentTaskCompleteTrackingEnabled: boolean | undefined +let requireFreshWorkingForNewTrackingCoordinators = false let lastPrunedLivenessSnapshot: PaneCoordinatorLivenessSnapshot | null = null function disposeCoordinatorForPaneKey(paneKey: string): void { @@ -111,9 +112,12 @@ function isAgentTaskCompleteTrackingEnabled(): boolean { return isAgentTaskCompleteNotificationEnabled() || isTerminalAttentionEnabled() } -export function syncAgentHookCompletionNotificationSettings(): boolean { - pruneClosedPaneCoordinators() - const enabled = isAgentTaskCompleteTrackingEnabled() +function syncAgentTaskCompleteTrackingEnabled(enabled: boolean): void { + if (wasAgentTaskCompleteTrackingEnabled === undefined) { + wasAgentTaskCompleteTrackingEnabled = enabled + requireFreshWorkingForNewTrackingCoordinators = !enabled + return + } if (enabled !== wasAgentTaskCompleteTrackingEnabled) { requireFreshWorkingForNewTrackingCoordinators = true for (const paneKey of coordinatorsByPaneKey.keys()) { @@ -121,6 +125,12 @@ export function syncAgentHookCompletionNotificationSettings(): boolean { } } wasAgentTaskCompleteTrackingEnabled = enabled +} + +export function syncAgentHookCompletionNotificationSettings(): boolean { + pruneClosedPaneCoordinators() + const enabled = isAgentTaskCompleteTrackingEnabled() + syncAgentTaskCompleteTrackingEnabled(enabled) return enabled } @@ -133,6 +143,9 @@ export function syncAgentHookCompletionNotificationsForStoreUpdate( if (!shouldSyncAgentHookCompletionForStoreUpdate(current, previous)) { return false } + if (wasAgentTaskCompleteTrackingEnabled === undefined) { + syncAgentTaskCompleteTrackingEnabled(isAgentHookCompletionTrackingEnabled(previous)) + } syncAgentHookCompletionNotificationSettings() return true } @@ -231,6 +244,7 @@ function paneCanReceiveHookCompletion(paneKey: string, tabIndex?: TabIndex): boo function createCoordinator(paneKey: string, worktreeId: string): AgentCompletionCoordinator { return createAgentCompletionCoordinator({ paneKey, + statusLane: 'hook', getPtyId: () => getPtyIdForPaneKey(paneKey), getSettings: () => useAppStore.getState().settings, inspectProcess: async (): Promise => ({ @@ -272,18 +286,28 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion export function observeAgentHookCompletionForNotification({ paneKey, worktreeId, - payload + payload, + seedOnly }: { paneKey: string worktreeId: string payload: AgentCompletionStatusSnapshot + seedOnly?: boolean }): void { - pruneClosedPaneCoordinators() - if (!paneCanReceiveHookCompletion(paneKey)) { - return + // Why: replay seeds already passed indexed snapshot ownership; re-resolving every row makes startup batches quadratic. + if (seedOnly !== true) { + pruneClosedPaneCoordinators() + if (!paneCanReceiveHookCompletion(paneKey)) { + return + } } - const trackingEnabled = syncAgentHookCompletionNotificationSettings() + const trackingEnabled = isAgentTaskCompleteTrackingEnabled() + if (seedOnly === true) { + syncAgentTaskCompleteTrackingEnabled(trackingEnabled) + } else { + syncAgentHookCompletionNotificationSettings() + } let entry = coordinatorsByPaneKey.get(paneKey) if (!entry || entry.worktreeId !== worktreeId) { @@ -299,10 +323,14 @@ export function observeAgentHookCompletionForNotification({ } // Why: notification preferences may suppress alerts, but accepted hooks must // still release pane-owned cursor/cache effects after the quiet window. - if (payload.state === 'working' && trackingEnabled) { + if (payload.state === 'working' && payload.turnCompletedAt === undefined && trackingEnabled) { paneKeysRequiringFreshWorking.delete(paneKey) } - entry.coordinator.observeHookStatus(payload) + if (seedOnly === true) { + entry.coordinator.seedHookStatus(payload) + } else { + entry.coordinator.observeHookStatus(payload) + } } export function resetAgentHookCompletionNotificationCoordinators(): void { diff --git a/src/renderer/src/hooks/agent-hook-completion-store-sync.ts b/src/renderer/src/hooks/agent-hook-completion-store-sync.ts index e900c5b0c5d..fc47a20205a 100644 --- a/src/renderer/src/hooks/agent-hook-completion-store-sync.ts +++ b/src/renderer/src/hooks/agent-hook-completion-store-sync.ts @@ -24,7 +24,9 @@ export type AgentHookCompletionStoreSnapshot = { type TabVisit = () => void -function isTrackingEnabled(state: AgentHookCompletionStoreSnapshot): boolean { +export function isAgentHookCompletionTrackingEnabled( + state: AgentHookCompletionStoreSnapshot +): boolean { const notifications = state.settings?.notifications const notificationEnabled = notifications?.enabled !== false && notifications?.agentTaskComplete !== false @@ -80,7 +82,9 @@ function shouldSync( previous: AgentHookCompletionStoreSnapshot, visitTab?: TabVisit ): boolean { - if (isTrackingEnabled(current) !== isTrackingEnabled(previous)) { + if ( + isAgentHookCompletionTrackingEnabled(current) !== isAgentHookCompletionTrackingEnabled(previous) + ) { return true } if ( diff --git a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts index 4a05afbdd11..609b3fb562c 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts @@ -31,6 +31,7 @@ export type AgentStatusSetData = { lastAssistantMessage?: string interrupted?: boolean sessionBoundary?: boolean + turnCompletedAt?: number terminalHandle?: string launchToken?: string providerSession?: { key: 'session_id'; id: string } diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts new file mode 100644 index 00000000000..0a9cb1a6c30 --- /dev/null +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildStoreState, + FUTURE_PANE_KEY, + type AgentStatusSetData, + type StoreLike +} from './ipc-events-agent-status-store-test-fixtures' +import { + buildWindowApi, + stubAuxiliaryModules, + stubReactSyncEffect +} from './ipc-events-agent-status-window-test-fixtures' + +describe('useIpcEvents agent status turn completion', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + }) + + it('forwards turnCompletedAt through the agent-status IPC whitelist', async () => { + const setAgentStatus = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Claude' }] + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + lastAssistantMessage: 'Which cells need hand-verification?', + turnCompletedAt: 1_700_000_005_000, + receivedAt: 1_700_000_005_000, + stateStartedAt: 1_700_000_000_000 + }) + + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: FUTURE_PANE_KEY, + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'working', + agentType: 'claude', + turnCompletedAt: 1_700_000_005_000, + lastAssistantMessage: 'Which cells need hand-verification?' + }) + }) + ) + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 9817519819a..55d9cd062eb 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3203,6 +3203,7 @@ export function useIpcEvents(): void { lastAssistantMessage: data.lastAssistantMessage, interrupted: data.interrupted, sessionBoundary: data.sessionBoundary, + turnCompletedAt: data.turnCompletedAt, // Why: same trap as interactivePrompt — this rebuild is a field whitelist, so subagent child rows vanish if omitted. subagents: data.subagents }) @@ -3406,7 +3407,7 @@ export function useIpcEvents(): void { : undefined } const applyPostCommitNotification = (): void => { - if (options?.replay !== true && statusWorktreeId) { + if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { // Why: local Codex/Claude hooks arrive via this main-process IPC path, not the PTY OSC fallback, so task-complete notifications must observe accepted hook state here too. const notificationPayload = typeof data.stateStartedAt === 'number' @@ -3415,7 +3416,8 @@ export function useIpcEvents(): void { observeAgentHookCompletionForNotification({ paneKey, worktreeId: statusWorktreeId, - payload: notificationPayload + payload: notificationPayload, + ...(options?.replay === true ? { seedOnly: true } : {}) }) } } diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 0f4bae03f6a..f7c65b84c29 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -853,7 +853,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( // Why: eager refreshes can resolve after the user switched worktrees; update tabs without stealing focus. const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, environmentId) return patch === state ? state : patch - }) + }, snapshot) } catch (error) { if (options.errorMode === 'throw') { throw error diff --git a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts new file mode 100644 index 00000000000..124625efd17 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -0,0 +1,325 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' + +const mocks = vi.hoisted(() => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: mocks.observeAgentHookCompletionForNotification +})) + +import { useAppStore } from '@/store' +import { + createAgentCompletionCoordinator, + resetAgentCompletionCoordinatorIdentitiesForTest +} from '@/components/terminal-pane/agent-completion-coordinator' +import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' +import { + markRendererOwnedAgentStatusWrite, + registerRendererOwnedAgentStatusPane, + resetRendererOwnedAgentStatusPanesForTests +} from '@/components/terminal-pane/renderer-owned-agent-status-registry' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch, + resetWebSessionTabsSnapshotFreshnessForTests +} from './web-session-tabs-sync' + +const ENVIRONMENT_ID = 'web-env-1' +const WORKTREE_ID = 'repo::/worktree' +const HOST_TAB_ID = 'host-tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const NOW = 1_700_000_000_000 +const initialState = useAppStore.getInitialState() + +function makeAgentSnapshot( + snapshotVersion: number, + updatedAt: number, + turnCompletedAt?: number, + state: 'working' | 'done' = 'working' +): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion, + activeGroupId: 'host-group-1', + activeTabId: HOST_TAB_ID, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${HOST_TAB_ID}::${LEAF_ID}`, + title: 'Claude working', + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), + agentStatus: { + state, + prompt: 'review the PR', + updatedAt, + stateStartedAt: NOW, + agentType: 'claude', + paneKey: makePaneKey(HOST_TAB_ID, LEAF_ID), + tabId: HOST_TAB_ID, + worktreeId: WORKTREE_ID, + stateHistory: [] + } + } + ] + } +} + +function applySnapshot(snapshot: RuntimeMobileSessionTabsResult, live: boolean): void { + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, NOW), + snapshot, + live + ) +} + +describe('paired session-tab agent completion notifications', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.observeAgentHookCompletionForNotification.mockReset() + resetAgentCompletionCoordinatorIdentitiesForTest() + resetWebSessionTabsSnapshotFreshnessForTests() + resetRendererOwnedAgentStatusPanesForTests() + resetAgentCompletionCoordinatorIdentitiesForTest() + useAppStore.setState(initialState, true) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + resetWebSessionTabsSnapshotFreshnessForTests() + resetRendererOwnedAgentStatusPanesForTests() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('forwards accepted working snapshots and live statuses', () => { + applySnapshot(makeAgentSnapshot(1, NOW), false) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) + + applySnapshot(makeAgentSnapshot(2, NOW + 1_000), true) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(2) + + const turnCompletedAt = NOW + 2_000 + applySnapshot(makeAgentSnapshot(3, NOW + 2_000, turnCompletedAt), true) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenLastCalledWith({ + paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), + worktreeId: WORKTREE_ID, + payload: expect.objectContaining({ + state: 'working', + stateStartedAt: NOW, + turnCompletedAt + }) + }) + + applySnapshot(makeAgentSnapshot(4, NOW + 3_000, turnCompletedAt + 1_000), false) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(4) + }) + + it('announces one live stamped completion after a late-pair working seed', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload, seedOnly }) => + seedOnly ? coordinator.seedHookStatus(payload) : coordinator.observeHookStatus(payload) + ) + + applySnapshot(makeAgentSnapshot(1, NOW), false) + applySnapshot(makeAgentSnapshot(2, NOW + 1_000, NOW + 1_000), true) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('announces a host-stamped turn while client OSC status owns the pane', () => { + const paneKey = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID) + registerRendererOwnedAgentStatusPane(paneKey, ENVIRONMENT_ID) + markRendererOwnedAgentStatusWrite(paneKey) + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'review the PR', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'claude', + paneKey, + tabId: toWebTerminalSurfaceTabId(HOST_TAB_ID), + worktreeId: WORKTREE_ID, + stateHistory: [] + } + } + }) + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey, + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload, seedOnly }) => + seedOnly ? coordinator.seedHookStatus(payload) : coordinator.observeHookStatus(payload) + ) + + applySnapshot(makeAgentSnapshot(1, NOW - 2_000), true) + const turnCompletedAt = NOW + 20_000 + applySnapshot(makeAgentSnapshot(2, NOW - 1_000, turnCompletedAt), true) + applySnapshot(makeAgentSnapshot(3, NOW, turnCompletedAt, 'done'), true) + + expect(useAppStore.getState().agentStatusByPaneKey[paneKey]?.state).toBe('working') + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + payload: expect.objectContaining({ + localStateStartedAt: NOW, + turnCompletedAt + }) + }) + ) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ + source: 'hook', + quietedHookDone: false, + agentStatus: expect.objectContaining({ + state: 'done', + prompt: 'review the PR', + turnCompletedAt + }) + }) + ) + }) + + it('keeps a delayed host stamp bound to the prior client turn', () => { + const paneKey = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID) + registerRendererOwnedAgentStatusPane(paneKey, ENVIRONMENT_ID) + markRendererOwnedAgentStatusWrite(paneKey) + const setClientTurn = (stateStartedAt: number, prompt: string): void => { + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt, + updatedAt: NOW, + stateStartedAt, + agentType: 'claude', + paneKey, + tabId: toWebTerminalSurfaceTabId(HOST_TAB_ID), + worktreeId: WORKTREE_ID, + stateHistory: [] + } + } + }) + } + + setClientTurn(5_000, 'first turn') + applySnapshot(makeAgentSnapshot(1, NOW - 2_000), true) + setClientTurn(6_000, 'second turn') + applySnapshot(makeAgentSnapshot(2, NOW - 1_500), true) + const turnCompletedAt = NOW + 20_000 + applySnapshot(makeAgentSnapshot(3, NOW - 1_000, turnCompletedAt), true) + + const delayedPayload = + mocks.observeAgentHookCompletionForNotification.mock.calls.at(-1)?.[0]?.payload + expect(delayedPayload).toMatchObject({ + localStateStartedAt: 5_000, + turnCompletedAt + }) + + const notificationDispatch = vi.fn() + vi.stubGlobal('window', { api: { notifications: { dispatch: notificationDispatch } } }) + dispatchTerminalNotification(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: 'Claude working', + paneKey, + agentCompletionSource: 'hook', + agentStatusSnapshot: delayedPayload + }) + + expect(notificationDispatch).not.toHaveBeenCalled() + }) + + it('suppresses a stamped reconnect tail and its live all-clear', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload, seedOnly }) => + seedOnly ? coordinator.seedHookStatus(payload) : coordinator.observeHookStatus(payload) + ) + + applySnapshot(makeAgentSnapshot(1, NOW, NOW), false) + applySnapshot(makeAgentSnapshot(2, NOW + 1_000, NOW, 'done'), true) + + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(2) + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('seeds an older restarted-host stamp without replaying its completion', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload, seedOnly }) => + seedOnly ? coordinator.seedHookStatus(payload) : coordinator.observeHookStatus(payload) + ) + const firstTurnCompletedAt = NOW + 100_000 + const secondTurnCompletedAt = NOW + 200_000 + + applySnapshot(makeAgentSnapshot(1, NOW), false) + applySnapshot(makeAgentSnapshot(2, NOW + 1_000, firstTurnCompletedAt), true) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + applySnapshot(makeAgentSnapshot(3, NOW + 2_000), true) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3) + applySnapshot(makeAgentSnapshot(4, NOW + 3_000, secondTurnCompletedAt), true) + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + applySnapshot(makeAgentSnapshot(5, NOW + 4_000), true) + + applySnapshot( + { + ...makeAgentSnapshot(1, NOW + 5_000, firstTurnCompletedAt), + publicationEpoch: 'epoch-2' + }, + false + ) + applySnapshot( + { + ...makeAgentSnapshot(2, NOW + 6_000, firstTurnCompletedAt, 'done'), + publicationEpoch: 'epoch-2' + }, + true + ) + vi.advanceTimersByTime(1_500) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 372c4eb4d5e..e6ddab72906 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -35,6 +35,9 @@ vi.mock('../store', () => ({ } })) +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) describe('applyWebSessionTabsSnapshot', () => { beforeEach(resetWebSessionTabsSyncTestState) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 2a5eb362dfb..4daf9d82399 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -6,9 +6,11 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { AGENT_STATUS_STALE_AFTER_MS, + pickParsedAgentStatusPayload, type AgentStatusEntry } from '../../../shared/agent-status-types' import { agentEntryCompletionAt } from '../../../shared/agent-completion-time' +import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-field-normalization' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { RuntimeMobileSessionTabsResult, @@ -96,6 +98,7 @@ import { resetWebSessionBrowserPlacementsForTests } from './web-session-browser-placement' import { suppressE2eWebRuntimeBrowserSnapshot } from './web-runtime-browser-creation-e2e-fault' +import { observeAgentHookCompletionForNotification } from '@/hooks/agent-hook-completion-notifications' import { buildWebSessionExistingTabIndex, type WebSessionExistingTabIndex @@ -158,6 +161,17 @@ const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< string, Map> >() +// Why: a delayed host stamp belongs to the client boundary seen with its preceding ordered frame. +const hostWorkingClientBoundaryByPaneKey = new Map< + string, + { + hostStateStartedAt: number + hostPrompt: string + clientStateStartedAt: number + stamped: boolean + } +>() +const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512 let receivedSessionTabsFrameSequence = 0 type TerminalSurface = RuntimeMobileSessionTerminalClientTab @@ -644,6 +658,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { lastHostTerminalTabCountByWorktree.clear() hostSessionTabIdByLocalKey.clear() hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() + hostWorkingClientBoundaryByPaneKey.clear() resetWebSessionBrowserPlacementsForTests() } @@ -3407,18 +3422,138 @@ function applyWebSessionTabsSnapshotOperations( } export function applyWebSessionTabsStorePatch( - buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial + buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, + agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], + allowCompletionNotification = false ): void { let mirroredAgentStatusChanged = false + const acceptedNotificationStatuses: { + paneKey: string + worktreeId: string + seedOnly?: true + payload: ReturnType & { + stateStartedAt: number + localStateStartedAt?: number + } + }[] = [] useAppStore.setState((state) => { const patch = buildPatch(state) mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') + if (agentStatusSnapshots) { + const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey + const snapshots = Array.isArray(agentStatusSnapshots) + ? agentStatusSnapshots + : [agentStatusSnapshots] + for (const snapshot of snapshots) { + for (const surface of snapshot.tabs) { + if (surface.type !== 'terminal') { + continue + } + const remapped = remapHostAgentStatus(surface) + const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined + const turnCompletedAt = remapped + ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) + : undefined + if (remapped?.state === 'done' && turnCompletedAt === undefined) { + hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) + } + // Why: client OSC owns display state, but only the host hook stream carries background-turn stamps. + const clientOwnedNotification = Boolean( + remapped && + isClientAuthoritativeAgentStatusPane(remapped.paneKey) && + (remapped.state === 'working' || turnCompletedAt !== undefined) + ) + if ( + !remapped || + (!clientOwnedNotification && + (!accepted || + accepted === state.agentStatusByPaneKey[remapped.paneKey] || + !agentStatusEntryEqual(accepted, remapped))) + ) { + continue + } + const notificationStatus = clientOwnedNotification ? remapped : accepted + if (!notificationStatus) { + continue + } + const currentClientStateStartedAt = clientOwnedNotification + ? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt + : undefined + let localStateStartedAt = currentClientStateStartedAt + if ( + clientOwnedNotification && + notificationStatus.state === 'working' && + currentClientStateStartedAt !== undefined + ) { + if (turnCompletedAt === undefined) { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + !retainedBoundary || + retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt || + retainedBoundary.hostPrompt !== notificationStatus.prompt || + retainedBoundary.stamped + ) { + hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey) + hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, { + hostStateStartedAt: notificationStatus.stateStartedAt, + hostPrompt: notificationStatus.prompt, + clientStateStartedAt: currentClientStateStartedAt, + stamped: false + }) + if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { + const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value + if (oldestPaneKey !== undefined) { + hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) + } + } + } + } else { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt && + retainedBoundary.hostPrompt === notificationStatus.prompt + ) { + localStateStartedAt = retainedBoundary.clientStateStartedAt + retainedBoundary.stamped = true + } + } + } + if (!allowCompletionNotification && notificationStatus.state !== 'working') { + continue + } + acceptedNotificationStatuses.push({ + paneKey: notificationStatus.paneKey, + worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, + ...(!allowCompletionNotification ? { seedOnly: true as const } : {}), + payload: { + ...pickParsedAgentStatusPayload({ + ...notificationStatus, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }), + stateStartedAt: notificationStatus.stateStartedAt, + ...(clientOwnedNotification + ? { + localStateStartedAt + } + : {}) + } + }) + } + } + } return patch }) // Why: paired-web snapshots bypass setAgentStatus, so arm the stale-boundary timer explicitly like local hook events do. if (mirroredAgentStatusChanged) { useAppStore.getState().scheduleAgentStatusFreshness() } + for (const status of acceptedNotificationStatuses) { + observeAgentHookCompletionForNotification(status) + } } function loadInitialWebSessionTabs( @@ -3485,8 +3620,9 @@ function loadInitialWebSessionTabs( receivedFrames[index]! ) ) - applyWebSessionTabsStorePatch((state) => - applyFreshWebSessionTabsSnapshots(state, applicable, environmentId) + applyWebSessionTabsStorePatch( + (state) => applyFreshWebSessionTabsSnapshots(state, applicable, environmentId), + applicable ) } finally { for (const finishRecovery of finishRecoveries) { @@ -3793,8 +3929,9 @@ export function useWebSessionTabsSync(): void { batch.deferredRepairWorktrees.delete(worktreeId) } if (operations.length > 0) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshotOperations(state, operations) + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshotOperations(state, operations), + operations.map(({ snapshot }) => snapshot) ) } finishVisibilityResumeBatchIfIdle(batch) @@ -4103,8 +4240,10 @@ export function useWebSessionTabsSync(): void { : [] ) if (freshSnapshots.length > 0) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId) + applyWebSessionTabsStorePatch( + (state) => + applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + freshSnapshots ) } const freshSnapshotSet = new Set(freshSnapshots) @@ -4173,8 +4312,10 @@ export function useWebSessionTabsSync(): void { acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) } if (shouldApplyWebSessionTabsSnapshot(recovered, environmentId)) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshot(state, recovered, environmentId) + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + recovered, + event.type === 'updated' && !replayed ) recordVisibilityResumeSnapshot(environmentId, recovered, receivedFrame) } @@ -4296,8 +4437,11 @@ export function useWebSessionTabsSync(): void { skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) }) if (fresh) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshot(state, recovered, environmentId) + const replayed = isRuntimeSubscriptionReplayResponse(response) + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + recovered, + event.type === 'updated' && !replayed ) recordVisibilityResumeSnapshotRef.current(environmentId, recovered, receivedFrame) } diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 15f0ac57252..5278247cf8d 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -37,6 +37,64 @@ describe('shared agent-hook-listener', () => { const stop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }) expect(stop?.payload.state).toBe('done') expect(stop?.payload.subagents).toBeUndefined() + expect(stop?.payload.turnCompletedAt).toBeUndefined() + }) + + it('stamps turnCompletedAt on a gated lead Stop and repeats it on the all-clear', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_700_000_005_000) + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + }) + const stop = claudeEvent({ + hook_event_name: 'Stop', + last_assistant_message: 'Which cells need hand-verification?', + background_tasks: [ + { + id: 'a1', + type: 'subagent', + status: 'running', + description: 'Review loop', + agent_type: 'general-purpose' + } + ] + }) + expect(stop?.payload.state).toBe('working') + expect(stop?.payload.turnCompletedAt).toBe(1_700_000_005_000) + expect(stop?.payload.lastAssistantMessage).toBe('Which cells need hand-verification?') + + vi.setSystemTime(1_700_000_055_000) + const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(drained?.payload.state).toBe('done') + expect(drained?.payload.turnCompletedAt).toBe(1_700_000_005_000) + + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'next turn' }) + const nextStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }) + expect(nextStop?.payload.state).toBe('done') + expect(nextStop?.payload.turnCompletedAt).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('does not stamp an interrupted Stop even while a child still gates the pane', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + }) + const stop = claudeEvent({ + hook_event_name: 'Stop', + is_interrupt: true, + background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }] + }) + expect(stop?.payload.state).toBe('working') + expect(stop?.payload.turnCompletedAt).toBeUndefined() }) it.each([ @@ -631,8 +689,14 @@ describe('shared agent-hook-listener', () => { // Why: the lead already finished; the answer resumes the child, so the // emitted state is gated up to working only while that child still runs. - expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) - expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({ state: 'done' }) + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ + state: 'working', + turnCompletedAt: expect.any(Number) + }) + expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({ + state: 'done', + turnCompletedAt: expect.any(Number) + }) const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) expect(drained?.payload.state).toBe('done') diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 82c9bfc9fec..ec9a947b94c 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -156,8 +156,10 @@ export type ClaudeLeadTurnState = { waitingAgentId?: string /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ waitingToolUseId?: string + /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ + turnCompletedAt?: number /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ - stateBeforeWait?: Pick + stateBeforeWait?: Pick } type CodexLeadTurnState = { @@ -2753,7 +2755,7 @@ function clearClaudePendingWaitForAgent( export function clearClaudeAnsweredQuestionWait( state: HookListenerState, paneKey: string -): Pick { +): Pick { const lead = state.claudeLeadStateByPaneKey.get(paneKey) const restored = lead?.state === 'waiting' @@ -2768,7 +2770,15 @@ export function clearClaudeAnsweredQuestionWait( : {} ) const effectiveState = resolveClaudePaneState(state, paneKey, restored) - return effectiveState === restored.state ? restored : { state: effectiveState } + return effectiveState === restored.state + ? restored + : { + state: effectiveState, + ...(restored.interrupted ? { interrupted: true as const } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}) + } } /** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ @@ -2802,7 +2812,9 @@ function buildClaudeCachedLeadStatusPayload( interrupted: lead?.interrupted }), updateToolSnapshot: false, - interrupted: lead?.interrupted + interrupted: lead?.interrupted, + // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. + turnCompletedAt: lead?.turnCompletedAt }) } @@ -2969,7 +2981,8 @@ function normalizeClaudeEvent( return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: resolveClaudePaneState(state, paneKey, restored), updateToolSnapshot: true, - interrupted: restored.interrupted + interrupted: restored.interrupted, + turnCompletedAt: restored.turnCompletedAt }) } @@ -3001,17 +3014,14 @@ function normalizeClaudeEvent( ? previousLead.stateBeforeWait : { state: previousLead.state, - ...(previousLead.interrupted ? { interrupted: true as const } : {}) + ...(previousLead.interrupted ? { interrupted: true as const } : {}), + // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. + ...(previousLead.turnCompletedAt !== undefined + ? { turnCompletedAt: previousLead.turnCompletedAt } + : {}) } : undefined const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId - state.claudeLeadStateByPaneKey.set(paneKey, { - state: reportedStateName, - ...(interrupted ? { interrupted } : {}), - ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), - ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), - ...(stateBeforeWait ? { stateBeforeWait } : {}) - }) if (interrupted && eventAgentId === undefined) { state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) @@ -3022,6 +3032,25 @@ function normalizeClaudeEvent( state: reportedStateName, interrupted }) + // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. + const turnCompletedAt = + eventAgentId === undefined && + isTurnBoundary && + reportedStateName === 'done' && + effectiveState === 'working' && + interrupted !== true + ? Date.now() + : undefined + + state.claudeLeadStateByPaneKey.set(paneKey, { + state: reportedStateName, + ...(interrupted ? { interrupted } : {}), + ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), + ...(stateBeforeWait ? { stateBeforeWait } : {}), + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }) + const effectiveRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) if ( isTurnBoundary && @@ -3039,7 +3068,8 @@ function normalizeClaudeEvent( return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: effectiveState, updateToolSnapshot: true, - interrupted + interrupted, + turnCompletedAt }) } @@ -3054,6 +3084,7 @@ function buildClaudeStatusPayload( updateToolSnapshot: boolean interrupted?: boolean sessionBoundary?: boolean + turnCompletedAt?: number } ): ParsedAgentStatusPayload | null { // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. @@ -3078,6 +3109,7 @@ function buildClaudeStatusPayload( lastAssistantMessage: snapshot.lastAssistantMessage, interrupted: options.interrupted, sessionBoundary: options.sessionBoundary, + turnCompletedAt: options.turnCompletedAt, subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) }) } diff --git a/src/shared/agent-status-field-normalization.ts b/src/shared/agent-status-field-normalization.ts index a56d4c412b4..98b830a7375 100644 --- a/src/shared/agent-status-field-normalization.ts +++ b/src/shared/agent-status-field-normalization.ts @@ -211,3 +211,11 @@ export function normalizeOptionalMultilineField( const normalized = normalizeMultilineField(value, maxLength) return normalized.length > 0 ? normalized : undefined } + +/** Keep a lead turn's end time only on the gated `working` row and that turn's later all-clear `done`. */ +export function normalizeTurnCompletedAtField(value: unknown, state: string): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return state === 'working' || state === 'done' ? value : undefined +} diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 1dea3dc70fe..f82932f3aeb 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -4,6 +4,7 @@ import { isFreshNonDoneAgentStatus, parseAgentStatusPayload, normalizeAgentStatusPayload, + pickParsedAgentStatusPayload, AGENT_STATUS_JSON_STRUCTURE_LIMITS, AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_MAX_SUBAGENTS, @@ -435,6 +436,41 @@ Fix dispatch fallback preview for normalized status prompts` ).toBeUndefined() }) + it('keeps turnCompletedAt on the gated working row and its all-clear done, nowhere else', () => { + for (const state of ['working', 'done'] as const) { + expect( + parseAgentStatusPayload(`{"state":"${state}","turnCompletedAt":1767225601000}`)! + .turnCompletedAt + ).toBe(1767225601000) + } + for (const state of ['blocked', 'waiting'] as const) { + expect( + parseAgentStatusPayload(`{"state":"${state}","turnCompletedAt":1767225601000}`)! + .turnCompletedAt + ).toBeUndefined() + } + for (const raw of ['"1767225601000"', 'null', 'true']) { + expect( + parseAgentStatusPayload(`{"state":"done","turnCompletedAt":${raw}}`)!.turnCompletedAt + ).toBeUndefined() + } + for (const value of [Number.NaN, Number.POSITIVE_INFINITY]) { + expect( + normalizeAgentStatusPayload({ state: 'done', turnCompletedAt: value })!.turnCompletedAt + ).toBeUndefined() + } + }) + + it('carries turnCompletedAt through the client-visible payload projection', () => { + expect( + pickParsedAgentStatusPayload({ + state: 'working', + prompt: 'run the build', + turnCompletedAt: 1767225601000 + }).turnCompletedAt + ).toBe(1767225601000) + }) + it('requires strict boolean true for interrupted (rejects truthy non-boolean)', () => { // Why: parser uses `=== true`, so truthy string/number sentinels don't count. expect( diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 80ce1a4f823..c1e3db6e606 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -7,7 +7,8 @@ import { normalizeInteractivePromptField, normalizeOptionalField, normalizeOptionalMultilineField, - normalizePromptField + normalizePromptField, + normalizeTurnCompletedAtField } from './agent-status-field-normalization' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' @@ -184,6 +185,10 @@ export type AgentStatusPayload = { * completions (notifications, automation runs, unread badges, finished timestamps) * must ignore it. Only meaningful on `done`. */ sessionBoundary?: boolean + /** Wall-clock ms when the lead turn ended while Claude background inventory kept the pane `working`. + * `stateStartedAt` stays pinned for that whole working run, so this is the per-turn identity. + * Present on the gated `working` row and that turn's later all-clear `done`. Event-only — not stored on AgentStatusEntry. */ + turnCompletedAt?: number /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] } @@ -217,6 +222,7 @@ export function pickParsedAgentStatusPayload( : {}), ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), + ...(row.turnCompletedAt !== undefined ? { turnCompletedAt: row.turnCompletedAt } : {}), ...(row.subagents !== undefined ? { subagents: row.subagents } : {}) } } @@ -417,6 +423,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | // Why: only meaningful on `done`; coerce to undefined elsewhere so it can't leak stale truth across transitions. interrupted: obj.interrupted === true && state === 'done' ? true : undefined, sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, + turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), subagents: normalizeSubagentsField(obj.subagents) } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index d6b257ca85f..69c8c972e36 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -181,6 +181,8 @@ export type RuntimeMobileSessionTerminalTab = { ptyId?: string | null terminalTheme?: RuntimeMobileTerminalTheme agentStatus?: AgentStatusEntry | null + /** Event-only lead-turn end time for paired clients; never persisted in AgentStatusEntry. */ + turnCompletedAt?: number launchAgent?: TuiAgent startupCwd?: string parentLayout?: TerminalLayoutSnapshot From 02ba70a847b60e59fa5cef79896415eea17a3ec0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 16 Aug 2026 20:48:26 -0700 Subject: [PATCH 16/28] fix(agent-hooks): make the Windows managed hook survive Claude-hooks-compat consumers (#14825) * fix(agent-hooks): make the Windows managed hook survive Claude-hooks-compat consumers `~/.claude/settings.json` is not read only by Claude Code. Third-party Claude-hooks-compat layers (cursor-agent, Devin) import the same file and reimplement hook execution, so Orca's entry has to survive consumers that support strictly less than the documented schema. Three separate defects came from assuming otherwise. 1. The entry depended on `args`, which a compat consumer ignores. `args` is valid Claude Code syntax, but cursor-agent spawns `command` alone -- so `conhost.exe` ran bare, which opens an interactive console that never closes. Hook payloads were typed into those stranded shells (#14815). The entry is now one self-contained `command` string that depends on nothing optional. 2. `conhost.exe --headless` never relayed anything. It implements the ConPTY server protocol, not a generic no-window wrapper: it does not wait for the hosted process and relays neither exit code nor stdout. Measured directly -- `conhost --headless cmd /c "echo X& exit /b 42"` yields empty stdout and no exit code, while the replacement returns both and waits. So every hook was fire-and-forget, and whatever it printed was discarded. Replaced with `-WindowStyle Hidden`, which suppresses the window and keeps wait/exit-code/stdout intact. 3. The hook never wrote anything to stdout. Guards exited silently and curl's output went to nul. Claude Code documents empty stdout as "no decision", but cursor-agent treats PreToolUse as a permission gate, fails to parse empty stdout as JSON, and blocks the tool call -- so every shell command in every cursor-agent session on Windows failed (#14818). The script now writes `{}` first, on both the Windows and POSIX branches, which is documented to be identical to writing nothing for real Claude Code. Gemini and Antigravity already did this. Defects 2 and 3 are causally linked: `{}` cannot reach any consumer while conhost is swallowing stdout, so neither fix works without the other. Also fixed while establishing the contract: - The launcher's own missing-script fallback returned empty stdout, reproducing #14818 whenever `~/.orca` was cleaned or an install was half-finished. It now emits `{}` too. - PowerShell serializes progress records to stderr as CLIXML when stderr is redirected; a consumer merging stderr into stdout would see those bytes before the JSON. Every encoded payload now silences progress. - `runtime-home-hook-command.ts` built its own launcher without window suppression -- exactly the drift #14815 asks to prevent. All launcher construction now goes through `windows-powershell-hook-launcher.ts`, so the switch list cannot be present in one installer and missing in another. - Renamed `usesWindowsHeadlessHook` to `usesWindowsPowerShellLauncher`; nothing is headless anymore, and the flag selects a launcher. Testing: the new regression test asserts the effect a consumer observes -- it runs the exact `command` string from settings.json through both cmd.exe and Git Bash, across the guard-exit, reached-curl, and missing-script paths, and parses stdout. Verified it fails when `conhost --headless` is reintroduced. The previous tests all asserted installer intent, which is why they passed through all three defects. * fix(agent-hooks): close hook launcher review gaps --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/installer-utils.test.ts | 68 +++++++++++++--- src/main/agent-hooks/installer-utils.ts | 16 ++-- .../managed-hook-stdin-lifecycle.test.ts | 76 +++++++++++++++++- .../agent-hooks/runtime-home-hook-command.ts | 26 +++++-- .../windows-powershell-hook-launcher.ts | 27 +++++++ src/main/claude/hook-service.test.ts | 77 +++++++++++++------ src/main/claude/hook-service.ts | 4 + src/main/claude/hook-settings.ts | 77 +++++++++++++------ .../hook-service-managed-install.test.ts | 23 ++++-- src/main/command-code/hook-service.test.ts | 7 +- src/main/cursor/hook-service.test.ts | 2 +- src/main/droid/hook-service.test.ts | 2 +- src/main/gemini/hook-service.test.ts | 2 +- src/main/grok/hook-service.test.ts | 8 +- 15 files changed, 322 insertions(+), 94 deletions(-) create mode 100644 src/main/agent-hooks/windows-powershell-hook-launcher.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index d7e251d0d86..7ccdd5acf2d 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -14,6 +14,7 @@ "../src/main/agent-hooks/managed-agent-hook-registry.ts", "../src/main/agent-hooks/managed-hook-script-refresh.ts", "../src/main/agent-hooks/runtime-home-hook-command.ts", + "../src/main/agent-hooks/windows-powershell-hook-launcher.ts", "../src/main/amp/agent-status-plugin-source.ts", "../src/main/amp/hook-service.ts", "../src/main/amp/managed-plugin-install-status.ts", diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 2013865aa96..f219125b78d 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -71,7 +71,10 @@ describe('readHooksJsonWithRaw', () => { for (const contents of [`\uFEFF\uFEFF${body}`, ` \uFEFF${body}`, `{\uFEFF"hooks": {}}`]) { writeFileSync(configPath, contents, 'utf-8') - expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: contents, config: null }) + expect(readHooksJsonWithRaw(configPath)).toEqual({ + raw: contents, + config: null + }) } }) @@ -82,7 +85,10 @@ describe('readHooksJsonWithRaw', () => { it('keeps the raw bytes when the contents are not a JSON object', () => { writeFileSync(configPath, 'not json\n', 'utf-8') - expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: 'not json\n', config: null }) + expect(readHooksJsonWithRaw(configPath)).toEqual({ + raw: 'not json\n', + config: null + }) }) }) @@ -95,7 +101,9 @@ describe('writeHooksJson', () => { writeHooksJson(configPath, { hooks: { Stop: [] } }) expect(lstatSync(configPath).isSymbolicLink()).toBe(true) - expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({ hooks: { Stop: [] } }) + expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({ + hooks: { Stop: [] } + }) }) it('does not replace a dangling hook config symlink', () => { @@ -182,9 +190,15 @@ describe('writeHooksJson', () => { }) it('updates the .bak file to the previous version on each write', () => { - const v1: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v1' }] }] } } - const v2: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v2' }] }] } } - const v3: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v3' }] }] } } + const v1: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v1' }] }] } + } + const v2: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v2' }] }] } + } + const v3: HooksConfig = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'v3' }] }] } + } writeHooksJson(configPath, v1) writeHooksJson(configPath, v2) @@ -310,7 +324,10 @@ describe('removeManagedCommands', () => { [ { hooks: [ - { type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' }, + { + type: 'command', + command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' + }, { type: 'command', command: 'echo keep me' } ] } @@ -381,7 +398,14 @@ describe('hookDefinitionHasManagedCommand', () => { ).toBe(true) expect( hookDefinitionHasManagedCommand( - { hooks: [{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' }] }, + { + hooks: [ + { + type: 'command', + command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' + } + ] + }, match ) ).toBe(true) @@ -539,7 +563,7 @@ describe('wrapPosixHookCommand', () => { }) const qualifiedWindowsPowerShellCommand = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ function decodeWindowsHookCommand(command: string): string { const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] @@ -549,7 +573,8 @@ function decodeWindowsHookCommand(command: string): string { function expectedDecodedWindowsHookCommand(scriptPath: string): string { const quoted = `'${scriptPath.replaceAll("'", "''")}'` - return `if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + // Why: PowerShell progress CLIXML corrupts consumers that merge stderr into JSON stdout. + return `$ProgressPreference='SilentlyContinue'; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` } describe('wrapWindowsHookCommand', () => { @@ -725,11 +750,32 @@ describe('wrapRuntimeHomeHookCommand', () => { process.platform === 'win32' ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') : '/bin/sh' - const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') }) + const result = spawnSync(shell, ['-c', command], { + input: Buffer.alloc(1_000_000, 'x') + }) expect(result.error).toBeUndefined() expect(result.status).toBe(0) }) + + it('emits neutral JSON when a lifecycle script is missing', () => { + const shell = + process.platform === 'win32' + ? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : '/bin/sh' + const result = spawnSync( + shell, + ['-c', wrapRuntimeHomeHookCommand('missing-orca-hook', { neutralJsonWhenMissing: true })], + { + env: { ...process.env, HOME: tmpDir.replaceAll('\\', '/') }, + input: Buffer.alloc(1_000_000, 'x') + } + ) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr.toString()).toBe(0) + expect(JSON.parse(result.stdout.toString().trim())).toEqual({}) + }) }) describe('buildWindowsAgentHookPostCommand', () => { diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 8861d41f0cc..0bab6e22c2a 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -16,6 +16,7 @@ import { grantDirAcl, isPermissionError } from '../win32-utils' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' import { resolveHooksJsonWritePath } from './hook-config-write-path' import { writeRollingFileBackup } from '../rolling-file-backup' +import { wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher' export type HookCommandConfig = { type: 'command' @@ -120,16 +121,14 @@ export function wrapPosixHookCommand(scriptPath: string, env: Record `$env:${key} = ${quotePowerShellString(value)}; `) .join('') const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` - const encodedCommand = Buffer.from(command, 'utf16le').toString('base64') - return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` + return wrapWindowsPowerShellEncodedCommand(command) } export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/ diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index f805e6c25f1..3232c702dfd 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -69,6 +69,7 @@ vi.mock('os', async (importOriginal) => { import { AntigravityHookService } from '../antigravity/hook-service' import { ClaudeHookService } from '../claude/hook-service' +import { getRemoteManagedCommand } from '../claude/hook-settings' import { CodexHookService } from '../codex/hook-service' import { CommandCodeHookService } from '../command-code/hook-service' import { CopilotHookService } from '../copilot/hook-service' @@ -155,6 +156,7 @@ const LOCAL_INSTALLERS = [ type HookRun = { exitCode: number | null stdinErrors: NodeJS.ErrnoException[] + stderr: string stdout: string } @@ -164,8 +166,9 @@ function runHookProcess( env: NodeJS.ProcessEnv ): Promise { return new Promise((resolve, reject) => { - const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'ignore'] }) + const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }) const stdinErrors: NodeJS.ErrnoException[] = [] + let stderr = '' let stdout = '' const timeout = setTimeout(() => { child.kill('SIGKILL') @@ -178,10 +181,13 @@ function runHookProcess( child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) child.stdin.on('error', (error: NodeJS.ErrnoException) => stdinErrors.push(error)) child.on('close', (exitCode) => { clearTimeout(timeout) - resolve({ exitCode, stdinErrors, stdout }) + resolve({ exitCode, stdinErrors, stderr, stdout }) }) child.stdin.end(LARGE_PAYLOAD) }) @@ -401,9 +407,75 @@ describe('Windows managed hook stdin structure', () => { } } ) + + // Why: command-shape tests missed conhost discarding the JSON consumers observe (#14818). + it.skipIf(process.platform !== 'win32')( + 'emits parseable JSON on stdout from the registered Claude hook command, through cmd.exe and Git Bash', + async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdout-json-')) + homedirMock.mockReturnValue(home) + try { + expect(new ClaudeHookService().install().state).toBe('installed') + const settings = JSON.parse( + readFileSync(join(home, '.claude', 'settings.json'), 'utf8') + ) as { hooks: Record } + const entry = settings.hooks.PreToolUse[0].hooks[0] + expect(entry.args).toBeUndefined() + + // Why: MSYS rewrites switches and paths, so the command must survive both shells (#14815). + const gitBash = findGitBash() + const shells = [ + { name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c', entry.command] }, + { name: 'Git Bash', executable: gitBash, args: ['-c', entry.command] } + ] + // Why: cover guard exit, reached curl, and the launcher's missing-script fallback. + const environments = [ + { name: 'no Orca env', env: hookEnvironment({ USERPROFILE: home }) }, + { + name: 'Orca env with dead listener', + env: hookEnvironment({ + USERPROFILE: home, + ORCA_AGENT_HOOK_PORT: '59999', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + }) + }, + { + name: 'missing managed script', + env: hookEnvironment({ USERPROFILE: join(home, 'absent') }) + } + ] + for (const shell of shells) { + for (const environment of environments) { + const label = `${shell.name} / ${environment.name}` + const result = await runHookProcess(shell.executable, shell.args, environment.env) + expect(result.exitCode, `${label} exit code`).toBe(0) + expect(result.stderr, `${label} stderr`).toBe('') + expect(() => JSON.parse(result.stdout.trim()), `${label} stdout is JSON`).not.toThrow() + expect(JSON.parse(result.stdout.trim()), `${label} stdout`).toEqual({}) + } + } + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }, + // Why: six shell launches can overrun the default while the suite competes for cores. + 60_000 + ) }) describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () => { + it('emits neutral JSON when the Claude lifecycle script is missing', async () => { + const command = getRemoteManagedCommand('/home/dev/.orca/agent-hooks/claude-hook.sh') + const result = await runPosixHook(command) + + expect(result.exitCode).toBe(0) + expect(result.stdinErrors).toHaveLength(0) + expect(result.stderr).toBe('') + expect(JSON.parse(result.stdout.trim())).toEqual({}) + }) + it('captures stdin before every possible whole-script success exit', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 3ae887fc054..6d594c14d19 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -1,22 +1,32 @@ import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' +import { + encodeWindowsPowerShellHookCommand, + WINDOWS_POWERSHELL_HOOK_SWITCHES +} from './windows-powershell-hook-launcher' const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/ const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*' -export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string { +export function wrapRuntimeHomeHookCommand( + scriptBaseName: string, + options: { neutralJsonWhenMissing?: boolean } = {} +): string { if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) { throw new Error(`Invalid managed script base name: ${scriptBaseName}`) } const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"` const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"` const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND + const missingScriptFallback = options.neutralJsonWhenMissing ? `${drain}; printf '{}\\n'` : drain const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"' - const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` - const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64') - const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` - const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi` - const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi` - const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi` + const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : '' + const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null${powershellFallback}; exit 0` + const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand) + // Why: the Git Bash and native Windows launchers must suppress windows identically (#14815). + const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` + const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${missingScriptFallback}; fi` + const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${missingScriptFallback}; fi` + const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${missingScriptFallback}; fi` // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. - return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` + return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts new file mode 100644 index 00000000000..e3974ef8ff1 --- /dev/null +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -0,0 +1,27 @@ +// Why: centralizing the launcher keeps window suppression consistent across installers (#14815). + +// Why: an absolute forward-slash path avoids PATH hijacking and survives cmd.exe and Git Bash. +export function getWindowsSystem32Path(relativePath: string): string { + const systemRoot = process.env.SystemRoot || 'C:\\Windows' + return `${systemRoot.replaceAll('\\', '/')}/System32/${relativePath}` +} + +export function getWindowsPowerShellExecutablePath(): string { + return getWindowsSystem32Path('WindowsPowerShell/v1.0/powershell.exe') +} + +// Why: unlike conhost, hidden PowerShell relays hook output and exit status (#14818). +export const WINDOWS_POWERSHELL_HOOK_SWITCHES = + '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden' + +// Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON output. +const HOOK_PROGRESS_SILENCER = "$ProgressPreference='SilentlyContinue'; " + +// Why: encoding shields paths and switches from cmd.exe and MSYS rewriting (#6078, #14815). +export function encodeWindowsPowerShellHookCommand(command: string): string { + return Buffer.from(`${HOOK_PROGRESS_SILENCER}${command}`, 'utf16le').toString('base64') +} + +export function wrapWindowsPowerShellEncodedCommand(command: string): string { + return `${getWindowsPowerShellExecutablePath()} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodeWindowsPowerShellHookCommand(command)}` +} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index b3fa02debc5..6f8eafb46e3 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -34,14 +34,28 @@ function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined } describe('getWindowsManagedLifecycleHook', () => { - it('resolves the managed script from the runtime Windows profile', () => { + it('resolves the managed script from the runtime Windows profile, as a single command string', () => { const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd' const hook = getWindowsManagedLifecycleHook(scriptPath) - expect(hook.args?.[0]).toBe('--headless') - expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i) - expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd') - expect(hook.args).not.toContain(scriptPath) + expect(hook.args).toBeUndefined() + expect(hook.command).toMatch( + /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + ) + expect(hook.command).not.toContain(scriptPath) + // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. + expect(hook.command.replace(/-EncodedCommand \S+$/, '')).not.toMatch(/\\| \/[a-zA-Z]+( |$)/) + + const encoded = hook.command.match(/-EncodedCommand (\S+)$/)?.[1] + const decoded = Buffer.from(encoded ?? '', 'base64').toString('utf16le') + expect(decoded).toContain('$env:USERPROFILE') + expect(decoded).toContain('.orca\\agent-hooks\\claude-hook.cmd') + }) + + it('is still recognized as managed by createManagedCommandMatcher (#14825)', () => { + const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + const hook = getWindowsManagedLifecycleHook(scriptPath) + expect(isClaudeManagedCommand(hook.command)).toBe(true) }) }) @@ -199,9 +213,17 @@ describe('ClaudeHookService.install', () => { expect(hasManagedCommand(legacy.hooks.StopFailure[0].hooks[0], isClaudeManagedCommand)).toBe( true ) - expect( - readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8') - ).toContain('DEVIN_PROJECT_DIR') + const managedScript = readFileSync( + join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), + 'utf-8' + ) + expect(managedScript).toContain('DEVIN_PROJECT_DIR') + // Why: guard and Devin-skip paths must still return neutral JSON (#14818). + expect(managedScript).toMatch( + process.platform === 'win32' + ? /^@echo off\r\nsetlocal\r\necho \{\}\r\n/ + : /^#!\/bin\/sh\nprintf "\{\}\\n"\n/ + ) } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -255,7 +277,12 @@ describe('ClaudeHookService.install', () => { mkdirSync(join(tmpHome, '.claude'), { recursive: true }) writeFileSync( settingsPath, - JSON.stringify({ statusLine: { type: 'command', command: '/usr/local/bin/my-statusline' } }) + JSON.stringify({ + statusLine: { + type: 'command', + command: '/usr/local/bin/my-statusline' + } + }) ) expect(new ClaudeHookService().install().state).toBe('installed') @@ -339,7 +366,7 @@ describe('ClaudeHookService.install', () => { }) it.skipIf(process.platform !== 'win32')( - 'runs portable managed hooks through headless exec form', + 'runs portable managed hooks through a single headless command string', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces ')) vi.stubEnv('HOME', tmpHome) @@ -351,24 +378,20 @@ describe('ClaudeHookService.install', () => { readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') ) as { hooks: Record } - const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME) - const runtimeScriptPath = join( - '%USERPROFILE%', - '.orca', - 'agent-hooks', - CLAUDE_SCRIPT_FILE_NAME - ) for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] - expect(hook).toEqual({ - type: 'command', - command: join(system32, 'conhost.exe'), - args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], - timeout: 10 - }) - expect(hook.args).not.toContain(scriptPath) + expect(hook?.args).toBeUndefined() + expect(hook?.command).toMatch( + /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + ) + expect(hook?.command).not.toContain(scriptPath) + + const encoded = hook?.command.match(/-EncodedCommand (\S+)$/)?.[1] + const decoded = Buffer.from(encoded ?? '', 'base64').toString('utf16le') + expect(decoded).toContain('$env:USERPROFILE') + expect(decoded).toContain(`.orca\\agent-hooks\\${CLAUDE_SCRIPT_FILE_NAME}`) } } finally { vi.unstubAllEnvs() @@ -393,6 +416,8 @@ describe('ClaudeHookService.install', () => { expect(script).toContain('--data-urlencode "payload@-"') expect(script).toContain('/hook/claude') expect(script).not.toMatch(/Invoke-WebRequest/i) + // Why: guard and Devin-skip paths must still return neutral JSON (#14818). + expect(script.split('\r\n')[2]).toBe('echo {}') } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -436,6 +461,10 @@ describe('ClaudeHookService.installRemote', () => { const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') expect(script).toContain('#!/bin/sh') expect(script).toContain('DEVIN_PROJECT_DIR') + // Why: remote guard paths must still return neutral JSON (#14818). + expect(script!.indexOf('printf "{}\\n"')).toBe( + script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length + ) // Why: payload is piped to curl via stdin (`payload@-`) so it never lands // on the curl command line (EDR oversized-command-line false positive), // matching the Windows curl.exe hook post. diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 47269b1e17d..57270575c5c 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -65,6 +65,8 @@ function getManagedScript( return [ '@echo off', 'setlocal', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'echo {}', // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, @@ -86,6 +88,8 @@ function getManagedScript( return [ '#!/bin/sh', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), ...(options.skipWhenDevinImportsClaude ? [ diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index d39b8e27bcb..3447c62ffa5 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -6,7 +6,9 @@ import { getSharedManagedScriptPath, isPlainObject, MANAGED_HOOK_TIMEOUT_SECONDS, + quotePowerShellString, removeManagedCommands, + wrapWindowsPowerShellEncodedCommand, type HookCommandConfig, type HookDefinition, type HooksConfig @@ -16,38 +18,59 @@ import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-com export type ClaudeCompatibleHookSettings = { configDirName: '.claude' | '.openclaude' scriptBaseName: 'claude-hook' | 'openclaude-hook' - supportsExecHookArgs: boolean + usesWindowsPowerShellLauncher: boolean } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.claude', scriptBaseName: 'claude-hook', - supportsExecHookArgs: true + usesWindowsPowerShellLauncher: true } export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.openclaude', scriptBaseName: 'openclaude-hook', - supportsExecHookArgs: false + usesWindowsPowerShellLauncher: false } export const CLAUDE_EVENTS = [ // Why: SessionStart is the only event a resumed/idle session emits before the // first prompt; without it the sidebar row can't exist until the user types (STA-3386). - { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'SessionStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'UserPromptSubmit', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'Stop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, // Why: OpenClaude skips normal Stop hooks after API/model errors and emits // StopFailure instead; without this hook Orca leaves the turn spinning. - { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'StopFailure', + definition: { hooks: [{ type: 'command', command: '' }] } + }, // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps // a pane 'working' while background children outlive the lead's turn. // TeammateIdle parks turn-based teammates without trusting their permanently // "running" background_tasks entry to gate the pane. // Older Claude builds ignore unregistered event names (StopFailure precedent). - { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'TeammateIdle', definition: { hooks: [{ type: 'command', command: '' }] } }, + { + eventName: 'SubagentStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'SubagentStop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'TeammateIdle', + definition: { hooks: [{ type: 'command', command: '' }] } + }, // Why: PreToolUse gives the dashboard a live readout of the in-flight tool // (name + input preview) before it completes. { @@ -108,11 +131,15 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S return `${remoteHome.replace(/\/$/, '')}/${settings.configDirName}/settings.json` } -export function getManagedCommand(scriptPath: string): string { +export function getManagedCommand( + scriptPath: string, + options: { neutralJsonWhenMissing?: boolean } = {} +): string { const scriptFileName = basename(scriptPath) const extension = extname(scriptFileName) return wrapRuntimeHomeHookCommand( - extension ? scriptFileName.slice(0, -extension.length) : scriptFileName + extension ? scriptFileName.slice(0, -extension.length) : scriptFileName, + options ) } @@ -120,25 +147,25 @@ export function getManagedLifecycleHook( scriptPath: string, settings = CLAUDE_HOOK_SETTINGS ): HookCommandConfig { - if (process.platform !== 'win32' || !settings.supportsExecHookArgs) { - return buildManagedCommandHook(getManagedCommand(scriptPath)) + if (process.platform !== 'win32' || !settings.usesWindowsPowerShellLauncher) { + return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })) } return getWindowsManagedLifecycleHook(scriptPath) } +// Why: some Claude-compatible consumers ignore `args`, so the invocation must be self-contained. export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig { - const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32') - const runtimeScriptPath = win32.join( - '%USERPROFILE%', - '.orca', - 'agent-hooks', - win32.basename(scriptPath) - ) - // Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console. + const scriptFileName = win32.basename(scriptPath) + // Why: runtime profile resolution keeps the managed entry portable across users (STA-3348). + const quotedRelativePath = quotePowerShellString(`.orca\\agent-hooks\\${scriptFileName}`) + // Why: compat consumers require neutral JSON even when the managed script is missing (#14818). + const innerCommand = + `$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` + + 'if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; ' + + "[Console]::In.ReadToEnd() | Out-Null; Write-Output '{}'; exit 0" return { type: 'command', - command: win32.join(system32, 'conhost.exe'), - args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath], + command: wrapWindowsPowerShellEncodedCommand(innerCommand), timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } @@ -154,7 +181,7 @@ export function hasSameManagedHookInvocation( } export function getRemoteManagedCommand(scriptPath: string): string { - return getManagedCommand(scriptPath) + return getManagedCommand(scriptPath, { neutralJsonWhenMissing: true }) } export function applyManagedHooks( diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 4f53c51513f..22f6bf698c5 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -30,7 +30,7 @@ vi.mock('os', async (importOriginal) => { import { CodexHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ const homes = setupCodexHookHomes(homedirMock, getPathMock) @@ -235,24 +235,31 @@ describe('CodexHookService', () => { // would block the event loop and starve this handler, so the child is // spawned asynchronously while the server drains the request concurrently. let resolveReceived: (value: { headers: Record; body: string }) => void - const receivedPromise = new Promise<{ headers: Record; body: string }>( - (resolve) => { - resolveReceived = resolve - } - ) + const receivedPromise = new Promise<{ + headers: Record + body: string + }>((resolve) => { + resolveReceived = resolve + }) const server = createServer((req, res) => { const chunks: Buffer[] = [] req.on('data', (c: Buffer) => chunks.push(c)) req.on('end', () => { res.end('ok') - resolveReceived({ headers: req.headers, body: Buffer.concat(chunks).toString('utf-8') }) + resolveReceived({ + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8') + }) }) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const port = (server.address() as AddressInfo).port try { - const payload = JSON.stringify({ prompt: '你好世界', hook_event_name: 'UserPromptSubmit' }) + const payload = JSON.stringify({ + prompt: '你好世界', + hook_event_name: 'UserPromptSubmit' + }) // Why: this suite may run inside an Orca-launched terminal whose env // already carries ORCA_AGENT_HOOK_ENDPOINT/PORT/TOKEN. The managed // script sources that endpoint file, so leave it out or the hook posts diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index 6e294516324..ce59e20b056 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -21,7 +21,7 @@ vi.mock('os', async () => { import { CommandCodeHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('CommandCodeHookService', () => { let homeDir: string @@ -136,7 +136,10 @@ describe('CommandCodeHookService', () => { body += chunk }) req.on('end', () => { - requests.push({ body, token: req.headers['x-orca-agent-hook-token'] }) + requests.push({ + body, + token: req.headers['x-orca-agent-hook-token'] + }) res.statusCode = 204 res.end() }) diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index f861d4f2a66..b723d947db3 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -31,7 +31,7 @@ const CURSOR_EVENTS = [ const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('CursorHookService', () => { let homeDir: string diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index ef31754a7e7..0a65facab21 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -25,7 +25,7 @@ vi.mock('os', async () => { import { DroidHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('DroidHookService', () => { let homeDir: string diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index 1a80c1568bf..fc5a302a7d1 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -26,7 +26,7 @@ vi.mock('os', async (importOriginal) => { import { GeminiHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ describe('GeminiHookService', () => { let homeDir: string diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 9a58aabaf24..f08779aed02 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -23,7 +23,7 @@ import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ type WindowsGrokHookRun = { status: number | null @@ -343,7 +343,11 @@ describe('GrokHookService', () => { `${JSON.stringify( { hooks: { - Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }] + Notification: [ + { + hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] + } + ] } }, null, From 226cf88ba6c9d85f332b0b23a50b1e64c830ee69 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:49:45 -0700 Subject: [PATCH 17/28] fix(terminal): inset the grid inside the xterm surface (#14583) * fix(terminal): inset the grid inside the xterm surface (#13252) Padding X/Y was applied as start-edge container margin, so the cell grid stayed flush on the trailing edges and a fractional background opacity stacked a darker gutter around the viewport. Put the setting on .xterm so FitAddon insets both axes and the themed background fills the pad. * fix(terminal): normalize padding before fit * fix(terminal): align stored and fitted padding * test(terminal): lock padding before fit * fix terminal padding opacity compositing * fix live terminal padding backgrounds * fix(terminal): preserve source-over alpha blending * fix(terminal): restore WebGL alpha blending * test(terminal): complete hidden retention pane fixture --- src/main/ghostty/mapper-extended.test.ts | 4 +- src/main/ghostty/numeric-config-values.ts | 7 +- .../terminal-container-geometry.test.ts | 30 ++++++- src/renderer/src/assets/terminal.css | 50 ++++++++++-- .../TerminalWindowSection.padding.test.tsx | 81 +++++++++++++++++++ .../settings/TerminalWindowSection.tsx | 13 ++- .../terminal-pane/terminal-appearance.test.ts | 69 ++++++++++++++++ .../terminal-pane/terminal-appearance.ts | 30 ++++--- .../pane-background-compositing.test.ts | 57 +++++++++++++ .../pane-background-compositing.ts | 30 +++++++ .../lib/pane-manager/pane-lifecycle.test.ts | 1 + .../src/lib/pane-manager/pane-lifecycle.ts | 6 ++ .../pane-manager/pane-webgl-renderer.test.ts | 68 +++++++++++++++- .../lib/pane-manager/pane-webgl-renderer.ts | 48 +++++++++++ .../terminal-webgl-hidden-retention.test.ts | 1 + src/shared/terminal-padding-settings.test.ts | 14 ++++ src/shared/terminal-padding-settings.ts | 6 ++ 17 files changed, 481 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx create mode 100644 src/renderer/src/lib/pane-manager/pane-background-compositing.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-background-compositing.ts create mode 100644 src/shared/terminal-padding-settings.test.ts create mode 100644 src/shared/terminal-padding-settings.ts diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 402e9253f21..189e5bf4217 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -90,9 +90,9 @@ describe('mapGhosttyToOrca — window-padding', () => { expect(result.unsupportedKeys).toEqual([]) }) - it('preserves odd-sum dual-value padding as a fractional average', () => { + it('rounds odd-sum dual-value padding to the nearest fitted pixel', () => { const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' }) - expect(result.diff).toEqual({ terminalPaddingX: 1.5 }) + expect(result.diff).toEqual({ terminalPaddingX: 2 }) expect(result.unsupportedKeys).toEqual([]) }) diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts index f8a7ce7b468..5ced69afa48 100644 --- a/src/main/ghostty/numeric-config-values.ts +++ b/src/main/ghostty/numeric-config-values.ts @@ -1,3 +1,5 @@ +import { normalizeTerminalPadding } from '../../shared/terminal-padding-settings' + // Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty // config with `window-padding-x = 1e9` would sail through the mapper and land // an absurd value in the store. Restrict to plain decimal integers. @@ -11,8 +13,7 @@ export const parseStrictInt = (v: string): number | null => { } // Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings, -// but Orca stores a single value per axis — average the pair so the total -// padding along the axis stays the same. +// but Orca stores an integer per axis — preserve the nearest total inset. export const parsePaddingValue = (v: string): number | null => { const parts = v.split(',') if (parts.length > 2) { @@ -26,5 +27,5 @@ export const parsePaddingValue = (v: string): number | null => { } nums.push(num) } - return nums.reduce((sum, num) => sum + num, 0) / nums.length + return normalizeTerminalPadding(nums.reduce((sum, num) => sum + num, 0) / nums.length) } diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..5e2bfcc0258 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -5,9 +5,7 @@ const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), describe('terminal container geometry', () => { it('keeps the hidden link tooltip out of the fitted terminal height', () => { - expect(terminalCss).toMatch( - /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s - ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*height:\s*100%;/s) expect(terminalCss).toMatch( /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s ) @@ -15,4 +13,30 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('insets the xterm grid on both axes from pane padding', () => { + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-top:\s*var\(--pane-padding-y, 4px\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-right:\s*var\(--pane-padding-x, 4px\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-bottom:\s*var\(--pane-padding-y, 4px\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding-left:\s*var\(--pane-padding-x, 4px\);/s + ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*width:\s*100%;/s) + expect(terminalCss).not.toMatch(/\.xterm-container\s*{[^}]*margin-left:/s) + }) + + it('matches live DOM and WebGL background layers across translucent padding bands', () => { + expect(terminalCss).toMatch( + /\.xterm-container \.xterm::before\s*{[^}]*border-color:\s*var\(--orca-terminal-live-background, transparent\);/s + ) + expect(terminalCss).toMatch( + /\.xterm-container\[data-terminal-renderer='webgl'\] \.xterm::after\s*{[^}]*border-color:\s*var\(--orca-terminal-live-background, transparent\);/s + ) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ee5e0af01f5..66a8a18828a 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -30,6 +30,42 @@ width: 100%; } +.xterm-container .xterm { + box-sizing: border-box; + height: 100%; + width: 100%; + /* Why on .xterm, not the container: FitAddon subtracts this element's + padding when proposing cols/rows, and the themed background (including + opacity) fills the inset. Container margins only framed the start edges + and stacked a darker gutter at fractional opacity. */ + padding-top: var(--pane-padding-y, 4px); + padding-right: var(--pane-padding-x, 4px); + padding-bottom: var(--pane-padding-y, 4px); + padding-left: var(--pane-padding-x, 4px); +} + +/* xterm paints the live viewport background again under the grid. Mirror that + layer in the padding bands, including runtime OSC 11 color changes. */ +.xterm-container .xterm::before, +.xterm-container[data-terminal-renderer='webgl'] .xterm::after { + box-sizing: border-box; + position: absolute; + inset: 0; + content: ''; + pointer-events: none; + border-style: solid; + border-width: var(--pane-padding-y, 4px) var(--pane-padding-x, 4px); +} + +.xterm-container .xterm::before { + border-color: var(--orca-terminal-live-background, transparent); +} + +/* WebGL adds one source-over background layer beyond the DOM renderer. */ +.xterm-container[data-terminal-renderer='webgl'] .xterm::after { + border-color: var(--orca-terminal-live-background, transparent); +} + .pane-manager-root .xterm-viewport { /* Why: xterm/VS Code keep the scrollbar gutter stable. `auto` lets Linux DOM scrollbars appear/disappear, which can feed resize/reflow jitter. */ @@ -177,7 +213,7 @@ .pane[data-terminal-attention] .xterm-container::after { content: ''; position: absolute; - inset: 0 4px 4px 0; + inset: 0; z-index: 5; pointer-events: none; border-radius: 2px; @@ -498,16 +534,14 @@ border-color: var(--ring); } -/* Baseline xterm-container layout — padding is driven by CSS custom properties - so Ghostty-imported window-padding-x/y can be applied without inline styles. - The fallback 4px preserves the legacy default. */ +/* Container fills the pane so the .xterm background (and its padding) is the + terminal surface. Title offset stays here because the title overlay is a + sibling of the container, not of .xterm. */ .xterm-container { box-sizing: border-box; position: relative; - width: calc(100% - var(--pane-padding-x, 4px)); - height: calc(100% - var(--pane-padding-y, 4px)); - margin-top: var(--pane-padding-y, 4px); - margin-left: var(--pane-padding-x, 4px); + width: 100%; + height: 100%; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx new file mode 100644 index 00000000000..5f54fb38a9e --- /dev/null +++ b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { getDefaultSettings } from '../../../../shared/constants' +import { TerminalWindowSection } from './TerminalWindowSection' + +type NumberFieldProps = { + label: string + value: number + onChange: (value: number) => void +} + +const numberFields = vi.hoisted(() => new Map()) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, defaultValue: string) => defaultValue +})) + +vi.mock('./SettingsFormControls', () => ({ + ColorField: function ColorField() { + return null + }, + NumberField: function NumberField(props: NumberFieldProps) { + numberFields.set(props.label, props) + return null + } +})) + +vi.mock('./SearchableSetting', () => ({ + SearchableSetting: function SearchableSetting({ children }: { children?: ReactNode }) { + return children ?? null + } +})) + +vi.mock('../ui/button', () => ({ Button: () => null })) +vi.mock('../ui/label', () => ({ Label: () => null })) +vi.mock('../ui/switch', () => ({ Switch: () => null })) + +describe('TerminalWindowSection padding', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + numberFields.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + it('displays and persists the same integer padding used by terminal fitting', () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + terminalPaddingX: 1.5, + terminalPaddingY: 2.5 + } as GlobalSettings + + act(() => + root.render() + ) + + const horizontal = numberFields.get('Horizontal Padding') + const vertical = numberFields.get('Vertical Padding') + expect(horizontal?.value).toBe(2) + expect(vertical?.value).toBe(3) + + act(() => horizontal?.onChange(3.5)) + act(() => vertical?.onChange(4.5)) + expect(updateSettings).toHaveBeenNthCalledWith(1, { terminalPaddingX: 4 }) + expect(updateSettings).toHaveBeenNthCalledWith(2, { terminalPaddingY: 5 }) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 863d7fa11b0..5a09073cc27 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from 'react' import { RotateCw } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Switch } from '../ui/switch' @@ -185,13 +186,15 @@ export function TerminalWindowSection({ 'Horizontal Padding' )} description="" - value={settings.terminalPaddingX ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingX ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingX: normalizeTerminalPadding(value) }) + } /> @@ -212,13 +215,15 @@ export function TerminalWindowSection({ 'Vertical Padding' )} description="" - value={settings.terminalPaddingY ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingY ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingY: normalizeTerminalPadding(value) }) + } /> diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 6f7bec8592b..8998b6a43af 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -150,6 +150,7 @@ describe('applyTerminalAppearance theme assignment', () => { getBoundingClientRect: () => ({ width: measurable ? 800 : 0, height: measurable ? 600 : 0 }) }, fitAddon: { + fit: vi.fn(), proposeDimensions: () => (measurable ? { cols: 80, rows: 24 } : undefined) } } as unknown as ManagedPane @@ -360,6 +361,74 @@ describe('applyTerminalAppearance theme assignment', () => { // Latest wins, exactly one write: intermediate hidden values never touch xterm. expect(writes).toEqual([21]) }) + + it('stamps settings-UI padding defaults when padding is unset', () => { + const pane = makePane(1) + const manager = makeManager([pane]) + applyTerminalAppearance( + manager, + getDefaultSettings('/tmp'), + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paddingX: 4, paddingY: 4 }) + ) + }) + + it('rounds imported half-pixel padding before fitting', () => { + const pane = makePane(1) + const manager = makeManager([pane]) + const settings = { + ...getDefaultSettings('/tmp'), + terminalPaddingX: 1.5, + terminalPaddingY: 2.5 + } + + applyTerminalAppearance( + manager, + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paddingX: 2, paddingY: 3 }) + ) + }) + + it('stamps padding before fitting the pane', () => { + const pane = makePane(1) + pane.fitAddon.proposeDimensions = () => ({ cols: 79, rows: 23 }) + const manager = makeManager([pane]) + + applyTerminalAppearance( + manager, + getDefaultSettings('/tmp'), + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + const setPaneStyleOptions = vi.mocked(manager.setPaneStyleOptions) + const fit = vi.mocked(pane.fitAddon.fit) + expect(setPaneStyleOptions).toHaveBeenCalledOnce() + expect(fit).toHaveBeenCalledOnce() + expect(setPaneStyleOptions.mock.invocationCallOrder[0]!).toBeLessThan( + fit.mock.invocationCallOrder[0]! + ) + }) }) describe('publishTerminalViewAttributesAtAppStart', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index bb61a50fbbc..f520d98baba 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -3,6 +3,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' import { getBuiltinTheme, resolvePaneStyleOptions, @@ -158,6 +159,23 @@ export function applyTerminalAppearance( settings.terminalLigatures, settings.terminalFontFamily ) + // FitAddon parses each CSS padding as an integer, so normalize imported half-pixels before styling and fitting. + const paddingX = normalizeTerminalPadding(settings.terminalPaddingX ?? 4) + const paddingY = normalizeTerminalPadding(settings.terminalPaddingY ?? 4) + + // Why before the pane loop: FitAddon subtracts live .xterm padding, so the + // CSS vars must be stamped before safeFit or a padding shrink leaves a stale grid. + manager.setPaneStyleOptions({ + splitBackground: paneBackground, + paneBackground, + inactivePaneOpacity: paneStyles.inactivePaneOpacity, + activePaneOpacity: paneStyles.activePaneOpacity, + opacityTransitionMs: paneStyles.opacityTransitionMs, + dividerThicknessPx: paneStyles.dividerThicknessPx, + focusFollowsMouse: paneStyles.focusFollowsMouse, + paddingX, + paddingY + }) for (const pane of manager.getPanes()) { // Why value-gated: writing options.theme rebuilds the palette, discarding TUI OSC 4/10/11/12 mutations; skip on no-op change. @@ -227,16 +245,4 @@ export function applyTerminalAppearance( safeFit(pane) } } - - manager.setPaneStyleOptions({ - splitBackground: paneBackground, - paneBackground, - inactivePaneOpacity: paneStyles.inactivePaneOpacity, - activePaneOpacity: paneStyles.activePaneOpacity, - opacityTransitionMs: paneStyles.opacityTransitionMs, - dividerThicknessPx: paneStyles.dividerThicknessPx, - focusFollowsMouse: paneStyles.focusFollowsMouse, - paddingX: settings.terminalPaddingX, - paddingY: settings.terminalPaddingY - }) } diff --git a/src/renderer/src/lib/pane-manager/pane-background-compositing.test.ts b/src/renderer/src/lib/pane-manager/pane-background-compositing.test.ts new file mode 100644 index 00000000000..c7540a68c1d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-background-compositing.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { + disposePaneTerminalBackgroundObserver, + observePaneTerminalBackground +} from './pane-background-compositing' + +const observers: { callback: MutationCallback; disconnect: ReturnType }[] = [] + +beforeEach(() => { + observers.length = 0 + vi.stubGlobal( + 'MutationObserver', + class { + readonly disconnect = vi.fn() + + constructor(readonly callback: MutationCallback) { + observers.push(this) + } + + observe(): void {} + } + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('terminal padding background compositing', () => { + it('tracks live xterm background changes and releases the observer', () => { + const terminalElement = { style: { backgroundColor: 'rgba(0, 0, 0, 0.5)' } } + const setProperty = vi.fn() + const pane = { + terminal: { element: terminalElement }, + xtermContainer: { + style: { setProperty } + } + } as unknown as ManagedPaneInternal + + observePaneTerminalBackground(pane) + expect(setProperty).toHaveBeenCalledWith( + '--orca-terminal-live-background', + 'rgba(0, 0, 0, 0.5)' + ) + + terminalElement.style.backgroundColor = 'rgb(255, 0, 0)' + observers[0]?.callback([], observers[0] as unknown as MutationObserver) + expect(setProperty).toHaveBeenLastCalledWith( + '--orca-terminal-live-background', + 'rgb(255, 0, 0)' + ) + + disposePaneTerminalBackgroundObserver(pane) + expect(observers[0]?.disconnect).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-background-compositing.ts b/src/renderer/src/lib/pane-manager/pane-background-compositing.ts new file mode 100644 index 00000000000..754315ae751 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-background-compositing.ts @@ -0,0 +1,30 @@ +import type { ManagedPaneInternal } from './pane-manager-types' + +const observers = new WeakMap() + +export function observePaneTerminalBackground(pane: ManagedPaneInternal): void { + disposePaneTerminalBackgroundObserver(pane) + const terminalElement = pane.terminal.element + if (!terminalElement) { + return + } + + let previous = '' + const sync = () => { + const background = terminalElement.style.backgroundColor + if (!background || background === previous) { + return + } + previous = background + pane.xtermContainer.style.setProperty('--orca-terminal-live-background', background) + } + const observer = new MutationObserver(sync) + observer.observe(terminalElement, { attributes: true, attributeFilter: ['style'] }) + observers.set(pane, observer) + sync() +} + +export function disposePaneTerminalBackgroundObserver(pane: ManagedPaneInternal): void { + observers.get(pane)?.disconnect() + observers.delete(pane) +} diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 9cb442a2073..89544daafca 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -497,6 +497,7 @@ describe('openTerminal — addon and provider wiring', () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), querySelector: vi.fn(() => null), + style: { backgroundColor: '' }, classList: { contains: vi.fn(() => false) } } as unknown as HTMLElement vi.stubGlobal( diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 3c89a6ec723..079925d1826 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -20,6 +20,10 @@ import { rebuildAttachedWebgl } from './pane-webgl-reattach' import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' import { TerminalLigaturesAddon } from './terminal-ligatures-addon' import { installTerminalImeCandidateAnchor } from './terminal-ime-candidate-anchor' +import { + disposePaneTerminalBackgroundObserver, + observePaneTerminalBackground +} from './pane-background-compositing' // --------------------------------------------------------------------------- // Pane creation, terminal open/close, addon management @@ -44,6 +48,7 @@ export function openTerminal(pane: ManagedPaneInternal): void { // Open terminal into DOM terminal.open(xtermContainer) + observePaneTerminalBackground(pane) // Why: terminal.element sits under the padded xterm container. Pane-level // placement keeps the hover URL on the true bottom-left window corner. container.appendChild(linkTooltip) @@ -175,6 +180,7 @@ export function disposePane( pane.pendingInitialFitRafId = null } cancelPendingWebglRefresh(pane) + disposePaneTerminalBackgroundObserver(pane) detachPaneFitResizeObserver(pane) if (pane.panePointerDownHandler) { pane.container.removeEventListener('pointerdown', pane.panePointerDownHandler) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index b3990f20400..ddaf330a48d 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -4,6 +4,7 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { attachWebgl, clearTerminalWebglAttachBackoff, + disposeWebgl, resetTerminalWebglSuggestion, resetWebglTextureAtlas } from './pane-webgl-renderer' @@ -11,7 +12,7 @@ import { notifyPaneFitSucceeded } from './pane-fit-webgl-attach-signal' import { safeFit } from './pane-fit' import { disposePane } from './pane-lifecycle' -function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { +function createPane(options: { loadAddon?: (addon: unknown) => void } = {}): ManagedPaneInternal { const leafId = '22222222-2222-4222-8222-222222222222' as never return { id: 1, @@ -24,7 +25,7 @@ function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneIntern loadAddon: vi.fn(options.loadAddon) } as never, container: {} as never, - xtermContainer: {} as never, + xtermContainer: { dataset: {} } as never, linkTooltip: {} as never, terminalGpuAcceleration: 'on', gpuRenderingEnabled: true, @@ -95,6 +96,69 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.webglAddon).toBeNull() }) + it('tracks the active renderer for WebGL padding compositing', () => { + const pane = createPane() + + attachWebgl(pane) + expect(pane.xtermContainer.dataset.terminalRenderer).toBe('webgl') + + disposeWebgl(pane) + expect(pane.xtermContainer.dataset.terminalRenderer).toBe('dom') + }) + + it('uses source-over alpha blending for colored translucent backgrounds', () => { + const blendFuncSeparate = vi.fn() + const gl = { + SRC_ALPHA: 1, + ONE_MINUS_SRC_ALPHA: 2, + ONE: 3, + blendFuncSeparate + } + const pane = createPane({ + loadAddon: (addon) => { + ;(addon as { _renderer: { _gl: typeof gl } })._renderer = { _gl: gl } + } + }) + + attachWebgl(pane) + + expect(blendFuncSeparate).toHaveBeenCalledWith(1, 2, 3, 2) + }) + + it('restores source-over alpha blending after WebGL context restoration', () => { + const blendFuncSeparate = vi.fn() + const canvas = new EventTarget() + const gl = { + SRC_ALPHA: 1, + ONE_MINUS_SRC_ALPHA: 2, + ONE: 3, + blendFuncSeparate + } + const pane = createPane({ + loadAddon: (addon) => { + canvas.addEventListener('webglcontextrestored', () => blendFuncSeparate(4, 5, 6, 7)) + ;(addon as { _renderer: { _canvas: EventTarget; _gl: typeof gl } })._renderer = { + _canvas: canvas, + _gl: gl + } + } + }) + + attachWebgl(pane) + blendFuncSeparate.mockClear() + canvas.dispatchEvent(new Event('webglcontextrestored')) + + expect(blendFuncSeparate).toHaveBeenCalledTimes(2) + expect(blendFuncSeparate).toHaveBeenNthCalledWith(1, 4, 5, 6, 7) + expect(blendFuncSeparate).toHaveBeenLastCalledWith(1, 2, 3, 2) + + disposeWebgl(pane) + blendFuncSeparate.mockClear() + canvas.dispatchEvent(new Event('webglcontextrestored')) + expect(blendFuncSeparate).toHaveBeenCalledOnce() + expect(blendFuncSeparate).toHaveBeenCalledWith(4, 5, 6, 7) + }) + it('disposes the previous addon before attaching a replacement', () => { const pane = createPane() attachWebgl(pane) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index f162052cebe..488bf32c94c 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -24,6 +24,10 @@ let suggestedRendererType: 'dom' | undefined type ReleasableWebglContext = { getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null isContextLost?: () => boolean + blendFuncSeparate?: (srcRgb: number, dstRgb: number, srcAlpha: number, dstAlpha: number) => void + readonly SRC_ALPHA: number + readonly ONE_MINUS_SRC_ALPHA: number + readonly ONE: number } type XtermWebglAddonInternals = { @@ -33,6 +37,11 @@ type XtermWebglAddonInternals = { } } +const webglContextRestoreHandlers = new WeakMap< + WebglAddon, + { canvas: HTMLCanvasElement; handler: EventListener } +>() + export function resetTerminalWebglSuggestion(): void { // Why: toggling GPU settings should let "auto" retry WebGL after an earlier // attach failure suggested DOM rendering for this app session. Per-pane @@ -89,6 +98,7 @@ export function disposeWebgl( options?: { refreshDimensions?: boolean } ): void { cancelPendingWebglRefresh(pane) + markPaneTerminalRenderer(pane, 'dom') if (!pane.webglAddon) { return } @@ -118,6 +128,7 @@ export function disposeWebgl( } function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void { + removeWebglContextRestoreHandler(webglAddon) try { // Why: xterm removes the canvas on dispose, but Windows/ANGLE can keep the // driver context alive long enough for rapid terminal activation to hit @@ -259,7 +270,10 @@ export function attachWebgl(pane: ManagedPaneInternal): void { disposeWebgl(pane, { refreshDimensions: true }) }) pane.terminal.loadAddon(addon) + normalizeWebglAlphaBlending(addon) + installWebglContextRestoreHandler(addon) pane.webglAddon = addon + markPaneTerminalRenderer(pane, 'webgl') refreshTerminalAfterWebglAttach(pane) } catch (err) { if (pane.terminalGpuAcceleration === 'auto') { @@ -278,3 +292,37 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.webglAddon = null } } + +function normalizeWebglAlphaBlending(webglAddon: WebglAddon): void { + const gl = (webglAddon as unknown as XtermWebglAddonInternals)._renderer?._gl + // xterm's shared blendFunc squares destination alpha. Standard source-over keeps colored translucent backgrounds pixel-identical to CSS. + gl?.blendFuncSeparate?.(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA) +} + +function installWebglContextRestoreHandler(webglAddon: WebglAddon): void { + const canvas = (webglAddon as unknown as XtermWebglAddonInternals)._renderer?._canvas + if (!canvas) { + return + } + const handler = () => normalizeWebglAlphaBlending(webglAddon) + canvas.addEventListener('webglcontextrestored', handler) + webglContextRestoreHandlers.set(webglAddon, { canvas, handler }) +} + +function removeWebglContextRestoreHandler(webglAddon: ManagedPaneInternal['webglAddon']): void { + if (!webglAddon) { + return + } + const registered = webglContextRestoreHandlers.get(webglAddon) + if (!registered) { + return + } + registered.canvas.removeEventListener('webglcontextrestored', registered.handler) + webglContextRestoreHandlers.delete(webglAddon) +} + +function markPaneTerminalRenderer(pane: ManagedPaneInternal, renderer: 'dom' | 'webgl'): void { + if (pane.xtermContainer.dataset) { + pane.xtermContainer.dataset.terminalRenderer = renderer + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts index cfe3fba4e37..4b749c50e75 100644 --- a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts @@ -10,6 +10,7 @@ import { function createPane(withAddon = true): ManagedPaneInternal { return { + xtermContainer: { dataset: {} }, terminal: { blur: vi.fn() }, webglAddon: withAddon ? ({ dispose: vi.fn() } as unknown as ManagedPaneInternal['webglAddon']) diff --git a/src/shared/terminal-padding-settings.test.ts b/src/shared/terminal-padding-settings.test.ts new file mode 100644 index 00000000000..ee7ac432f46 --- /dev/null +++ b/src/shared/terminal-padding-settings.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { normalizeTerminalPadding } from './terminal-padding-settings' + +describe('normalizeTerminalPadding', () => { + it('rounds fractional padding to the nearest fitted pixel', () => { + expect(normalizeTerminalPadding(1.5)).toBe(2) + expect(normalizeTerminalPadding(2.49)).toBe(2) + }) + + it('keeps padding inside the settings range', () => { + expect(normalizeTerminalPadding(-1)).toBe(0) + expect(normalizeTerminalPadding(513)).toBe(512) + }) +}) diff --git a/src/shared/terminal-padding-settings.ts b/src/shared/terminal-padding-settings.ts new file mode 100644 index 00000000000..1f18a46973f --- /dev/null +++ b/src/shared/terminal-padding-settings.ts @@ -0,0 +1,6 @@ +const MIN_TERMINAL_PADDING = 0 +const MAX_TERMINAL_PADDING = 512 + +export function normalizeTerminalPadding(value: number): number { + return Math.min(MAX_TERMINAL_PADDING, Math.max(MIN_TERMINAL_PADDING, Math.round(value))) +} From 8b6d0231b2b98317a95bb64c2951811228e1e6ff Mon Sep 17 00:00:00 2001 From: hwantage <82494320+hwantage@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:49:59 +0900 Subject: [PATCH 18/28] feat(i18n): localize Automations settings and navigation to Korean (#15004) --- src/renderer/src/i18n/locales/ko.json | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3060a5b34c0..adc869d8eb4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -825,7 +825,9 @@ "linearDescription": "Orca에서 Linear가 작동하는 방식, 설정 체크리스트, 에이전트 스킬, 프롬프트 예시입니다.", "pluginsTitle": "플러그인", "pluginsDescription": "실험적인 Orca 플러그인을 설치하고 관리합니다.", - "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요." + "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요.", + "automationsTitle": "자동화", + "automationsDescription": "에이전트 작업을 예약하고 사이드바에 자동화 표시 여부를 선택합니다." }, "useAppMenuPaste": { "pasteTooLarge": "붙여넣기 내용이 너무 큽니다." @@ -10144,6 +10146,22 @@ "saveFailed": "가시성 기본값을 저장할 수 없습니다.", "updateServer": "소스 기본값을 구성하려면 이 서버를 업데이트하세요.", "updateServerDefaults": "표시 기본값을 구성하려면 이 서버를 업데이트하세요." + }, + "automations": { + "title": "자동화", + "description": "에이전트 작업을 예약하고 사이드바에 자동화 표시 여부를 선택합니다.", + "showButton": "자동화 버튼 표시", + "showButtonDescription": "사이드바에 자동화 바로 가기를 표시합니다.", + "howItWorksTitle": "자동화 작동 방식", + "howItWorksDescription": "에이전트 작업을 한 번만 예약하면 Orca가 각 실행을 생성하고 결과를 한곳에 모아 관리합니다.", + "defineStepTitle": "작업 설명", + "defineStepDescription": "프로젝트, 에이전트, 프롬프트 및 일정을 선택합니다.", + "runStepTitle": "Orca가 각 실행 시작", + "runStepDescription": "예약 시간이 되면 선택된 에이전트에게 새로운 작업 공간이 할당됩니다.", + "reviewStepTitle": "결과 검토", + "reviewStepDescription": "최근 실행을 검토하고 필요할 때 언제든지 작업을 계속할 수 있습니다.", + "openAutomations": "자동화 열기", + "openAutomationsDescription": "일정을 생성하고 최근 실행을 확인합니다." } }, "right": { From 88b1a698240c619a792c27b541f2ae2b5f925922 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:57:11 -0700 Subject: [PATCH 19/28] Fix Windows horizontal computer-use scroll (#14727) --- ...ter-use-windows-horizontal-scroll.test.mjs | 48 +++++++++++++++++++ native/computer-use-windows/runtime.ps1 | 13 +++-- 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 config/scripts/computer-use-windows-horizontal-scroll.test.mjs diff --git a/config/scripts/computer-use-windows-horizontal-scroll.test.mjs b/config/scripts/computer-use-windows-horizontal-scroll.test.mjs new file mode 100644 index 00000000000..3d76908daac --- /dev/null +++ b/config/scripts/computer-use-windows-horizontal-scroll.test.mjs @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectDir = resolve(import.meta.dirname, '../..') + +function source(path) { + return readFileSync(join(projectDir, path), 'utf8') +} + +function sourceBetween(contents, startMarker, endMarker) { + const start = contents.indexOf(startMarker) + const end = contents.indexOf(endMarker, start + startMarker.length) + if (start === -1 || end === -1) { + throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`) + } + return contents.slice(start, end) +} + +describe('Windows computer-use horizontal scroll', () => { + it('routes left and right through the horizontal wheel with native signs', () => { + const windows = source('native/computer-use-windows/runtime.ps1') + const mouseEvents = sourceBetween(windows, '$MouseEvents = @{', 'function Write-OrcaJson') + const scroll = sourceBetween(windows, ' "scroll" {', ' "drag" {') + const left = sourceBetween( + scroll, + '} elseif ($Operation.direction -eq "left") {', + '} elseif ($Operation.direction -eq "right") {' + ) + const right = sourceBetween( + scroll, + '} elseif ($Operation.direction -eq "right") {', + '} elseif ($Operation.direction -ne "up") {' + ) + + expect(mouseEvents).toContain('HorizontalWheel = 0x01000') + expect(scroll).toContain('$mouseEvent = $MouseEvents.Wheel') + expect(left).toContain('$mouseEvent = $MouseEvents.HorizontalWheel') + expect(left).toContain('$delta = -1 * $delta') + expect(right).toContain('$mouseEvent = $MouseEvents.HorizontalWheel') + expect(right).not.toContain('$delta = -1 * $delta') + expect(scroll).toContain( + '[OrcaDesktopWin32]::mouse_event($mouseEvent, 0, 0, $delta, [UIntPtr]::Zero)' + ) + expect(scroll).not.toContain('mouse_event($MouseEvents.Wheel') + expect(scroll).toContain('throw "unsupported scroll direction: $($Operation.direction)"') + }) +}) diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 2aa581901e6..4b68525c7c6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -179,6 +179,7 @@ $MouseEvents = @{ MiddleDown = 0x0020 MiddleUp = 0x0040 Wheel = 0x0800 + HorizontalWheel = 0x01000 } function Write-OrcaJson($Payload) { @@ -1235,16 +1236,22 @@ function Invoke-OrcaOperation($Operation) { } "scroll" { $delta = 120 * [int][Math]::Ceiling((Get-OrcaPositiveNumber $Operation.pages "pages")) - if ($Operation.direction -eq "down" -or $Operation.direction -eq "right") { + $mouseEvent = $MouseEvents.Wheel + if ($Operation.direction -eq "down") { $delta = -1 * $delta - } elseif ($Operation.direction -ne "up" -and $Operation.direction -ne "left") { + } elseif ($Operation.direction -eq "left") { + $mouseEvent = $MouseEvents.HorizontalWheel + $delta = -1 * $delta + } elseif ($Operation.direction -eq "right") { + $mouseEvent = $MouseEvents.HorizontalWheel + } elseif ($Operation.direction -ne "up") { throw "unsupported scroll direction: $($Operation.direction)" } $point = Get-OrcaElementScreenPoint $element if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame } [void][OrcaDesktopWin32]::SetForegroundWindow($handle) [void][OrcaDesktopWin32]::SetCursorPos([int]$point.x, [int]$point.y) - [OrcaDesktopWin32]::mouse_event($MouseEvents.Wheel, 0, 0, $delta, [UIntPtr]::Zero) + [OrcaDesktopWin32]::mouse_event($mouseEvent, 0, 0, $delta, [UIntPtr]::Zero) $action = [pscustomobject]@{ path = "synthetic"; actionName = "scroll"; fallbackReason = $null } } "drag" { From 80b0ab16ff85aea4597cd216c36f8942998dfa04 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:07:33 -0700 Subject: [PATCH 20/28] test(crash-reporting): keep ambiguous whole-tree kills reportable (#14667) * test(crash-reporting): keep ambiguous tree kills reportable * test(crash-reporting): drop the stale sibling-settle deferral comment --- .../process-gone-killed-one-ordering.test.ts | 28 ++++++++++++++++++- .../process-gone-recorder.test.ts | 7 ++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts index 7f47e794252..72c897088f5 100644 --- a/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts +++ b/src/main/crash-reporting/process-gone-killed-one-ordering.test.ts @@ -17,6 +17,9 @@ import { import { ProcessGoneDedupe } from './process-gone-dedupe' import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder' +const noMinidump = async () => null +const attachDetails = async () => null + function event(overrides: Partial = {}): ProcessGoneCrashEvent { return { source: 'renderer', @@ -82,9 +85,32 @@ describe('recordProcessGoneCrash killed/1 ordering', () => { recordProcessGoneCrash({ record } as never, gpuKill, dedupe) recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe) - recordProcessGoneCrash({ record } as never, rendererKill, dedupe) + recordProcessGoneCrash( + { record, attachDetails } as never, + rendererKill, + dedupe, + noMinidump + ) expect(record).toHaveBeenCalledOnce() + // Timing proximity is evidence, not authority to discard an ambiguous report. + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ source: 'child', processType: 'GPU' }) + }), + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ + source: 'child', + serviceName: 'network.mojom.NetworkService' + }) + }) + ]) + }) + ) }) it('suppresses the fleet sequence only after independent session-end intent', () => { diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts index 8a0f77dd1e2..70e6ca93f04 100644 --- a/src/main/crash-reporting/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -436,10 +436,9 @@ describe('recordProcessGoneCrash', () => { } } - // Why child kills: the decode gate is source-agnostic, and a non-recoverable - // child persists synchronously on every branch of the crash-reporting stack - // (the renderer killed path defers behind a sibling-kill settle), so the - // platform stub is still in force when the gate reads process.platform. + // Why child kills: the decode gate is source-agnostic and reads + // process.platform synchronously at record time, so the platform stub is + // still in force when it runs. const nonRecoverableChildKill = (overrides: Partial): ProcessGoneCrashEvent => event({ source: 'child', From 71bbab72e14e822fd483d1a4f01e301a4379f2e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:12:14 -0700 Subject: [PATCH 21/28] fix(commit-message): keep Windows paths intact in agent command overrides (#14984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(commit-message): keep Windows paths intact in agent command overrides `tokenizeCustomCommandTemplate` applies POSIX backslash-escape rules on every platform. On Windows `\` is the path separator, so a native absolute path in an agent command override is silently destroyed: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -> C:WindowsSystem32WindowsPowerShellv1.0powershell.exe which is then reported as not found on PATH. The agent *startup* path already routes Windows shells to the Windows tokenizer, but the commit-message AI path still calls the generic tokenizer directly, so overrides, extra CLI args and custom commands there are all affected. The tokenizer gains an explicit `'escape' | 'literal'` mode rather than reading `process.platform`, because the same template can be parsed on one host and executed on another. `'escape'` stays the default, so POSIX behaviour — where `foo\ bar` is deliberately one token — is unchanged. `'literal'` is selected only where the command provably runs on native Windows: a LOCAL target, on win32, with no WSL distro. A WSL target runs a Linux binary inside the distro, and a remote target runs on a host whose platform this process cannot see; both keep POSIX escaping. Fixes #11375 * test: pin the platform decision for literal-backslash parsing commandBackslashMode is the only place that reads the platform, so it is where this can be wrong in the direction that matters — applying Windows rules to a command that will actually run under a POSIX shell. WSL and remote targets are pinned explicitly; both were previously untested. --- ...mit-message-command-backslash-mode.test.ts | 41 ++++++++++++++ .../commit-message-text-generation.ts | 48 ++++++++++++++-- src/shared/commit-message-plan.test.ts | 46 +++++++++++++++- src/shared/commit-message-plan.ts | 23 +++++--- src/shared/commit-message-prompt.test.ts | 55 +++++++++++++++++++ src/shared/commit-message-prompt.ts | 32 +++++++++-- 6 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 src/main/text-generation/commit-message-command-backslash-mode.test.ts diff --git a/src/main/text-generation/commit-message-command-backslash-mode.test.ts b/src/main/text-generation/commit-message-command-backslash-mode.test.ts new file mode 100644 index 00000000000..db828261817 --- /dev/null +++ b/src/main/text-generation/commit-message-command-backslash-mode.test.ts @@ -0,0 +1,41 @@ +/** + * The decision that turns on literal-backslash parsing (#11375). + * + * Why its own suite: this is the only place that reads the platform, so it is + * where the fix can be wrong in the direction that matters — applying Windows + * rules to a command that will actually run under a POSIX shell. + */ +import { describe, expect, it } from 'vitest' +import { commandBackslashMode } from './commit-message-text-generation' + +const LOCAL = { kind: 'local' as const, cwd: 'C:\\repo' } +const REMOTE = { + kind: 'remote' as const, + cwd: '/repo', + execute: async () => ({ ok: true }) as never, + missingBinaryLocation: 'the remote host' +} + +describe('commandBackslashMode', () => { + it('reads backslashes literally only for a native Windows local target', () => { + expect(commandBackslashMode(LOCAL, 'win32')).toBe('literal') + }) + + it.each(['darwin', 'linux'])('keeps POSIX escaping on %s', (platform) => { + expect(commandBackslashMode(LOCAL, platform)).toBe('escape') + }) + + it('keeps POSIX escaping for a WSL target, which runs a Linux binary', () => { + expect(commandBackslashMode({ ...LOCAL, wslDistro: 'Ubuntu' }, 'win32')).toBe('escape') + }) + + it('keeps POSIX escaping for a remote target, whose platform we cannot see', () => { + // A Windows client driving a Linux host is the case this protects. + expect(commandBackslashMode(REMOTE, 'win32')).toBe('escape') + }) + + it('defaults to this process platform when none is given', () => { + const expected = process.platform === 'win32' ? 'literal' : 'escape' + expect(commandBackslashMode(LOCAL)).toBe(expected) + }) +}) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 655c6de0e7d..6370117066c 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -43,6 +43,7 @@ import { planCommitMessageGeneration, type CommitMessagePlan } from '../../shared/commit-message-plan' +import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt' import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key' import { resolveSourceControlAiForOperation, @@ -295,13 +296,14 @@ function finalizeModelDiscoveryOutput( function planModelDiscovery( spec: AgentModelProbeSpec, - agentCommandOverride?: string + agentCommandOverride?: string, + backslash: CommandTemplateBackslash = 'escape' ): { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } { const modelDiscovery = spec.modelDiscovery if (!modelDiscovery) { return { ok: false, error: `${spec.label} does not support dynamic model discovery.` } } - const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride) + const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride, backslash) if (!command.ok) { return command } @@ -341,7 +343,15 @@ export async function discoverCommitMessageModelsLocal( const spawnEnv = env ?? process.env let discoveryStdin: string | null = null try { - const planned = planModelDiscovery(spec, agentCommandOverride) + const planned = planModelDiscovery( + spec, + agentCommandOverride, + commandBackslashMode({ + kind: 'local', + cwd: options.cwd ?? '', + wslDistro: options.wslDistro + }) + ) if (!planned.ok) { markProcessClosed() resolve({ success: false, error: planned.error }) @@ -823,6 +833,23 @@ function runLocalPlan( return { result, processClosed } } +/** + * How the user's command override should read `\`. + * + * `'literal'` only when the command provably runs on native Windows: a LOCAL + * target, on win32, with no WSL distro. A WSL target runs a Linux binary inside + * the distro, and a remote target runs on a host whose platform this process + * cannot see — POSIX escaping stays the default for both. + */ +export function commandBackslashMode( + target: CommitMessageGenerationTarget, + platform: NodeJS.Platform = process.platform +): CommandTemplateBackslash { + return platform === 'win32' && target.kind === 'local' && !target.wslDistro + ? 'literal' + : 'escape' +} + type LocalGenerationTarget = Extract function runLocalPlanForAgent( @@ -1101,7 +1128,10 @@ export async function generateCommitMessageFromContext( linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildCommitMessagePrompt(context, params.customPrompt ?? '') - const planned = planCommitMessageGeneration(params, prompt) + const planned = planCommitMessageGeneration( + { ...params, backslash: commandBackslashMode(target) }, + prompt + ) if (!planned.ok) { return { success: false, error: planned.error } } @@ -1173,7 +1203,10 @@ export async function generatePullRequestFieldsFromContext( linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildPullRequestFieldsPrompt(context, params.customPrompt ?? '') - const planned = planCommitMessageGeneration(params, prompt) + const planned = planCommitMessageGeneration( + { ...params, backslash: commandBackslashMode(target) }, + prompt + ) if (!planned.ok) { return { success: false, @@ -1223,7 +1256,10 @@ export async function generateBranchNameFromContext( assistantMessage: context.assistantMessage ?? '' }) : buildBranchNamePrompt(context, params.customPrompt ?? '') - const planned = planCommitMessageGeneration(params, prompt) + const planned = planCommitMessageGeneration( + { ...params, backslash: commandBackslashMode(target) }, + prompt + ) if (!planned.ok) { return { success: false, error: planned.error } } diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 4a9ad1e22b7..0b728307bf7 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { planCommitMessageGeneration } from './commit-message-plan' +import { planCommitMessageGeneration, planAgentBinary } from './commit-message-plan' describe('planCommitMessageGeneration', () => { it('plans Claude non-interactive generation with the prompt on stdin only', () => { @@ -584,3 +584,47 @@ describe('planCommitMessageGeneration', () => { }) }) }) + +describe('backslash mode reaches every command the user can type (#11375)', () => { + const WINDOWS_BINARY = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + + it('keeps an agent command override intact in literal mode', () => { + const posix = planAgentBinary('claude', WINDOWS_BINARY) + const literal = planAgentBinary('claude', WINDOWS_BINARY, 'literal') + + // The bug: POSIX escaping eats every separator, so the binary is not found. + expect(posix.ok && posix.binary).toBe('C:WindowsSystem32WindowsPowerShellv1.0powershell.exe') + expect(literal.ok && literal.binary).toBe(WINDOWS_BINARY) + }) + + it('keeps a quoted path containing spaces intact in literal mode', () => { + const literal = planAgentBinary('claude', '"C:\\Program Files\\nodejs\\node.exe"', 'literal') + + expect(literal.ok && literal.binary).toBe('C:\\Program Files\\nodejs\\node.exe') + }) + + it('keeps extra CLI args intact through planCommitMessageGeneration', () => { + const plan = planCommitMessageGeneration( + { + agentId: 'claude', + model: 'sonnet', + agentCommandOverride: WINDOWS_BINARY, + agentArgs: '--config C:\\Users\\me\\.claude.json', + backslash: 'literal' + }, + 'prompt' + ) + + expect(plan.ok && plan.plan.binary).toBe(WINDOWS_BINARY) + expect(plan.ok && plan.plan.args).toContain('C:\\Users\\me\\.claude.json') + }) + + it('defaults to POSIX escaping when no mode is given', () => { + const plan = planCommitMessageGeneration( + { agentId: 'claude', model: 'sonnet', agentArgs: '--dir /my\\ dir' }, + 'prompt' + ) + + expect(plan.ok && plan.plan.args).toContain('/my dir') + }) +}) diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts index a44f2fc3c22..83760f00ae1 100644 --- a/src/shared/commit-message-plan.ts +++ b/src/shared/commit-message-plan.ts @@ -1,3 +1,4 @@ +import type { CommandTemplateBackslash } from './commit-message-prompt' import { getCommitMessageAgentSpec, getCommitMessageModel, @@ -14,6 +15,10 @@ import type { TuiAgent } from './tui-agent' export type CommitMessagePlanInput = { agentId: TuiAgent | 'custom' + /** How to read `\` in the user's command override / args / custom command. + * Defaults to POSIX escaping; pass `'literal'` only when the command is known + * to run on native Windows, where `\` is the path separator (#11375). */ + backslash?: CommandTemplateBackslash model: string thinkingLevel?: string customAgentCommand?: string @@ -36,14 +41,15 @@ export type CommitMessagePlanResult = export function planAgentBinary( defaultBinary: string, - commandOverride: string | undefined + commandOverride: string | undefined, + backslash: CommandTemplateBackslash = 'escape' ): { ok: true; binary: string; prefixArgs: string[] } | { ok: false; error: string } { const command = commandOverride?.trim() if (!command) { return { ok: true, binary: defaultBinary, prefixArgs: [] } } - const tokenized = tokenizeCustomCommandTemplate(command) + const tokenized = tokenizeCustomCommandTemplate(command, backslash) if (!tokenized.ok) { return { ok: false, error: `Agent command override is invalid: ${tokenized.error}` } } @@ -55,13 +61,14 @@ export function planAgentBinary( } function planAdditionalAgentArgs( - agentArgs: string | null | undefined + agentArgs: string | null | undefined, + backslash: CommandTemplateBackslash = 'escape' ): { ok: true; args: string[] } | { ok: false; error: string } { const trimmed = agentArgs?.trim() if (!trimmed) { return { ok: true, args: [] } } - const tokenized = tokenizeCustomCommandTemplate(trimmed) + const tokenized = tokenizeCustomCommandTemplate(trimmed, backslash) if (!tokenized.ok) { return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` } } @@ -239,11 +246,11 @@ export function planCommitMessageGeneration( error: 'Custom command is empty. Add one in Settings → Git → AI Commit Messages.' } } - const planned = planCustomCommand(command, prompt) + const planned = planCustomCommand(command, prompt, input.backslash) if (!planned.ok) { return { ok: false, error: planned.error } } - const agentArgs = planAdditionalAgentArgs(input.agentArgs) + const agentArgs = planAdditionalAgentArgs(input.agentArgs, input.backslash) if (!agentArgs.ok) { return agentArgs } @@ -294,11 +301,11 @@ export function planCommitMessageGeneration( model: input.model, thinkingLevel: input.thinkingLevel }) - const agentArgs = planAdditionalAgentArgs(input.agentArgs) + const agentArgs = planAdditionalAgentArgs(input.agentArgs, input.backslash) if (!agentArgs.ok) { return agentArgs } - const command = planAgentBinary(spec.binary, input.agentCommandOverride) + const command = planAgentBinary(spec.binary, input.agentCommandOverride, input.backslash) if (!command.ok) { return { ok: false, error: command.error } } diff --git a/src/shared/commit-message-prompt.test.ts b/src/shared/commit-message-prompt.test.ts index ba2a975190f..f080aabc5a2 100644 --- a/src/shared/commit-message-prompt.test.ts +++ b/src/shared/commit-message-prompt.test.ts @@ -367,3 +367,58 @@ describe('planCustomCommand', () => { } }) }) + +describe('Windows command overrides keep native path separators (#11375)', () => { + const WINDOWS_PATH = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + + it('eats backslashes under the POSIX default, which is what broke Windows paths', () => { + // Pinned as the reason 'literal' exists, not as desired behavior. + const posix = tokenizeCustomCommandTemplate(WINDOWS_PATH) + + expect(posix.ok && posix.tokens).toEqual([ + 'C:WindowsSystem32WindowsPowerShellv1.0powershell.exe' + ]) + }) + + it('keeps a native absolute path intact in literal mode', () => { + const literal = tokenizeCustomCommandTemplate(WINDOWS_PATH, 'literal') + + expect(literal.ok && literal.tokens).toEqual([WINDOWS_PATH]) + }) + + it('still splits on whitespace and honours quotes in literal mode', () => { + const quoted = tokenizeCustomCommandTemplate( + '"C:\\Program Files\\Git\\bin\\bash.exe" --login -i', + 'literal' + ) + + expect(quoted.ok && quoted.tokens).toEqual([ + 'C:\\Program Files\\Git\\bin\\bash.exe', + '--login', + '-i' + ]) + }) + + it('leaves a trailing backslash alone instead of swallowing the delimiter', () => { + const trailing = tokenizeCustomCommandTemplate('C:\\tools\\ --flag', 'literal') + + expect(trailing.ok && trailing.tokens).toEqual(['C:\\tools\\', '--flag']) + }) + + it('keeps POSIX escaping the default so `foo\\ bar` stays one token', () => { + const posix = tokenizeCustomCommandTemplate('/usr/local/my\\ agent/bin --flag') + + expect(posix.ok && posix.tokens).toEqual(['/usr/local/my agent/bin', '--flag']) + }) + + it('substitutes {prompt} as one argument with a Windows binary path', () => { + const plan = planCustomCommand( + `${WINDOWS_PATH} -Command {prompt}`, + 'write a commit message', + 'literal' + ) + + expect(plan.ok && plan.binary).toBe(WINDOWS_PATH) + expect(plan.ok && plan.args).toEqual(['-Command', 'write a commit message']) + }) +}) diff --git a/src/shared/commit-message-prompt.ts b/src/shared/commit-message-prompt.ts index efd42f36e5a..164f4da6327 100644 --- a/src/shared/commit-message-prompt.ts +++ b/src/shared/commit-message-prompt.ts @@ -149,7 +149,23 @@ export type TokenizeCustomCommandResult = // "spawn this exact CLI" — adding shell semantics on top would create // surprising behavior across platforms (especially Windows) and a security // surface we don't need. -export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomCommandResult { +/** + * `'escape'` (default) is POSIX: a backslash quotes the next byte, so `foo\ bar` + * is one token. `'literal'` is for a command that will run on native Windows, + * where `\` is the path separator — eating it turns + * `C:\Windows\System32\powershell.exe` into `C:WindowsSystem32powershell.exe`, + * a path that then "cannot be found" (#11375). + * + * Opt-in rather than sniffed from `process.platform` here, because the same + * template can be parsed on one host and executed on another. + */ +export type CommandTemplateBackslash = 'escape' | 'literal' + +export function tokenizeCustomCommandTemplate( + template: string, + backslash: CommandTemplateBackslash = 'escape' +): TokenizeCustomCommandResult { + const backslashEscapes = backslash === 'escape' const tokens: string[] = [] const spans: CommandTokenSpan[] = [] let current = '' @@ -162,7 +178,7 @@ export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomC while (i < template.length) { const ch = template[i] if (quote) { - if (ch === '\\' && quote === '"' && i + 1 < template.length) { + if (backslashEscapes && ch === '\\' && quote === '"' && i + 1 < template.length) { // Why: inside double quotes the shell only consumes the backslash // before these; elsewhere it stays a literal byte this tokenizer drops. divergesFromShell ||= !'$`"\\'.includes(template[i + 1]) @@ -197,7 +213,7 @@ export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomC continue } - if (ch === '\\' && i + 1 < template.length) { + if (backslashEscapes && ch === '\\' && i + 1 < template.length) { // Why: an unquoted line continuation joins words the shell splits, so a // selector can hide inside the joined token and skip the gap check. divergesFromShell ||= template[i + 1] === '\n' @@ -227,7 +243,7 @@ export function tokenizeCustomCommandTemplate(template: string): TokenizeCustomC } // Why: a trailing unpaired escape swallows whatever a consumer appends // after the base, so the base is not safe to build on. - divergesFromShell ||= ch === '\\' && i + 1 >= template.length + divergesFromShell ||= backslashEscapes && ch === '\\' && i + 1 >= template.length divergesFromShell ||= ';&|<>`'.includes(ch) || (ch === '#' && !inToken) || @@ -260,8 +276,12 @@ export type CustomCommandPlan = * substituted prompt is always passed as a single argument regardless of * whether the template wrote `{prompt}` or `"{prompt}"`. */ -export function planCustomCommand(template: string, prompt: string): CustomCommandPlan { - const tokenized = tokenizeCustomCommandTemplate(template) +export function planCustomCommand( + template: string, + prompt: string, + backslash: CommandTemplateBackslash = 'escape' +): CustomCommandPlan { + const tokenized = tokenizeCustomCommandTemplate(template, backslash) if (!tokenized.ok) { return { ok: false, error: tokenized.error } } From 84784f5393d5ffd1aa1f6b375a5d7d0965c50d54 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:45:41 -0700 Subject: [PATCH 22/28] Split pull request page (#14853) * refactor: split PullRequestPage.tsx under 400 lines Move the 5888-line PR page into nested domain modules under src/renderer/src/components/pull-request-page/ and leave a thin public barrel. No intentional behavior change. * rm plan * Improve React stability and remove manual ref caching - Stabilize React keys in CheckDetailsPanel using content fields instead of array indices to prevent unnecessary remounting - Remove manual ref-based entries cache in PRFilesCombinedDiffViewer, rely on useMemo dependency (diffEntrySignature) instead - Move sectionsRef assignment to useLayoutEffect to avoid render-phase ref writes - Refactor usePRFileSectionLoader to destructure args for readability * Improve PR page stability: add error handling and fix race conditions - Add error handling with user feedback (toast notifications) for comment submission, review comments, diff loading, and file view syncing - Internationalize hardcoded strings for PR state labels and error messages - Fix race condition in reviewer submission by using a ref-based guard instead of render-time state - Fix scroll restoration to avoid overwriting target positions with intermediate clamp values - Add effectiveRepoId parameter for proper repo context in review operations - Disable reviewer picker during submission to prevent concurrent requests * Improve PR page stability: add timeout and stable list keys - Add 45s timeout for diff loading to prevent indefinite hangs - Fix React list key generation for annotations/jobs using content-based keys with occurrence tracking - Refactor scroll position caching to properly handle mid-restore teardown - Replace interpolated error messages with full locale-specific strings for close/reopen actions * Fix PR diff viewer cache isolation and list key collisions - Changed list key generation from string concatenation to JSON serialization to avoid collisions with actual content keys - Added host-aware scoping to diff view caches so local and remote execution don't share entries - Optimized virtualizer keys to use lightweight revision counter instead of full serialized signature * Extract PR file state into entry-scoped hooks Replace manual state resets with custom hooks that automatically clear section heights and active section when switching PR entries. This prevents state leakage between files and simplifies the diff viewer component. Also validates the active section key exists before passing it to child components. * Improve PR page error messages, accessibility, and stability - Show actual error messages from failed operations instead of generic fallbacks - Add aria attributes for combobox/listbox patterns and proper option identifiers - Consolidate duplicate label/assignee update logic and fix event listener passive mode - Memoize GitHub source runtime to prevent stale closure in checks callbacks - Extract filled state badge tone for reuse and fix workspace attachment type - Guard textarea shortcuts against concurrent saves and add mention query test * Add explicit PR file content cache eviction Extract PRFileContentRequestArgs type and create evictPRFileContentRequest function to handle cache eviction explicitly. Call eviction on load timeout so retries fetch fresh content. Adds tests for cache behavior. --- config/max-lines-baseline.txt | 1 - .../src/components/PullRequestPage.tsx | 5892 +---------------- .../repro-8784-ghe-avatar-fallback.test.ts | 2 +- .../pull-request-page-host-boundary.test.ts | 299 +- .../actions/merge-actions.ts | 272 + .../pull-request-page/actions/panel.tsx | 244 + .../cache/file-content.test.ts | 71 + .../pull-request-page/cache/file-content.ts | 155 + .../cache/files-diff-view.ts | 15 + .../cache/work-item-details.ts | 194 + .../checks/details-list-keys.ts | 19 + .../checks/details-request.ts | 98 + .../pull-request-page/checks/details.tsx | 243 + .../pull-request-page/checks/fix-dialog.tsx | 77 + .../pull-request-page/checks/fix-launch.ts | 138 + .../pull-request-page/checks/layouts.tsx | 135 + .../pull-request-page/checks/refresh.ts | 117 + .../pull-request-page/checks/rerun.ts | 86 + .../pull-request-page/checks/row.tsx | 59 + .../pull-request-page/checks/tab.tsx | 369 ++ .../pull-request-page/checks/toolbar.tsx | 203 + .../pull-request-page/comments/composer.tsx | 171 + .../pull-request-page/comments/reply-form.tsx | 110 + .../conversation/comment-card.tsx | 185 + .../conversation/comment-group.tsx | 110 + .../conversation/comments-list.tsx | 130 + .../conversation/description.tsx | 152 + .../pull-request-page/conversation/reply.ts | 77 + .../pull-request-page/conversation/tab.tsx | 367 + .../pull-request-page/edit/issue-updates.ts | 171 + .../pull-request-page/edit/section.tsx | 369 ++ .../pull-request-page/files/active-section.ts | 12 + .../files/combined-diff-viewer.tsx | 396 ++ .../files/inline-comments.ts | 36 + .../pull-request-page/files/line-comment.ts | 63 + .../files/section-heights.ts | 23 + .../pull-request-page/files/section-loader.ts | 265 + .../pull-request-page/files/toolbar.tsx | 81 + .../pull-request-page/files/view-restore.ts | 138 + .../mentions/options.test.ts | 55 + .../pull-request-page/mentions/options.ts | 53 + .../pull-request-page/mentions/query.test.ts | 26 + .../pull-request-page/mentions/query.ts | 14 + .../pull-request-page/mentions/textarea.tsx | 160 + .../pull-request-page/page-types.ts | 39 + .../pull-request-page/page/header.tsx | 269 + .../pull-request-page/page/surface.tsx | 386 ++ .../pull-request-page/page/tabs-shell.tsx | 225 + .../pull-request-page/page/use-details.ts | 244 + .../pull-request-page/page/viewed-sync.ts | 79 + .../presentation/state-badge.test.ts | 35 + .../presentation/state-badge.tsx | 56 + .../pull-request-page/reviewers/panel.tsx | 323 + .../reviewers/picker-row.tsx | 84 + .../pull-request-page/reviewers/picker.tsx | 196 + .../reviewers/request-actions.ts | 269 + .../reviewers/requested-list.tsx | 90 + src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 9 +- src/renderer/src/i18n/locales/ja.json | 9 +- src/renderer/src/i18n/locales/ko.json | 9 +- src/renderer/src/i18n/locales/zh.json | 9 +- 62 files changed, 8145 insertions(+), 6048 deletions(-) create mode 100644 src/renderer/src/components/pull-request-page/actions/merge-actions.ts create mode 100644 src/renderer/src/components/pull-request-page/actions/panel.tsx create mode 100644 src/renderer/src/components/pull-request-page/cache/file-content.test.ts create mode 100644 src/renderer/src/components/pull-request-page/cache/file-content.ts create mode 100644 src/renderer/src/components/pull-request-page/cache/files-diff-view.ts create mode 100644 src/renderer/src/components/pull-request-page/cache/work-item-details.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/details-list-keys.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/details-request.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/details.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/fix-launch.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/layouts.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/refresh.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/rerun.ts create mode 100644 src/renderer/src/components/pull-request-page/checks/row.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/tab.tsx create mode 100644 src/renderer/src/components/pull-request-page/checks/toolbar.tsx create mode 100644 src/renderer/src/components/pull-request-page/comments/composer.tsx create mode 100644 src/renderer/src/components/pull-request-page/comments/reply-form.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/comment-card.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/comment-group.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/comments-list.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/description.tsx create mode 100644 src/renderer/src/components/pull-request-page/conversation/reply.ts create mode 100644 src/renderer/src/components/pull-request-page/conversation/tab.tsx create mode 100644 src/renderer/src/components/pull-request-page/edit/issue-updates.ts create mode 100644 src/renderer/src/components/pull-request-page/edit/section.tsx create mode 100644 src/renderer/src/components/pull-request-page/files/active-section.ts create mode 100644 src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx create mode 100644 src/renderer/src/components/pull-request-page/files/inline-comments.ts create mode 100644 src/renderer/src/components/pull-request-page/files/line-comment.ts create mode 100644 src/renderer/src/components/pull-request-page/files/section-heights.ts create mode 100644 src/renderer/src/components/pull-request-page/files/section-loader.ts create mode 100644 src/renderer/src/components/pull-request-page/files/toolbar.tsx create mode 100644 src/renderer/src/components/pull-request-page/files/view-restore.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/options.test.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/options.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/query.test.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/query.ts create mode 100644 src/renderer/src/components/pull-request-page/mentions/textarea.tsx create mode 100644 src/renderer/src/components/pull-request-page/page-types.ts create mode 100644 src/renderer/src/components/pull-request-page/page/header.tsx create mode 100644 src/renderer/src/components/pull-request-page/page/surface.tsx create mode 100644 src/renderer/src/components/pull-request-page/page/tabs-shell.tsx create mode 100644 src/renderer/src/components/pull-request-page/page/use-details.ts create mode 100644 src/renderer/src/components/pull-request-page/page/viewed-sync.ts create mode 100644 src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts create mode 100644 src/renderer/src/components/pull-request-page/presentation/state-badge.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/panel.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/picker.tsx create mode 100644 src/renderer/src/components/pull-request-page/reviewers/request-actions.ts create mode 100644 src/renderer/src/components/pull-request-page/reviewers/requested-list.tsx diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 516bbeafcbb..d7af7e7e4d4 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -93,7 +93,6 @@ inline src/renderer/src/components/JiraIssueWorkspace.tsx inline src/renderer/src/components/LinearIssueWorkspace.tsx inline src/renderer/src/components/LinearItemDrawer.tsx inline src/renderer/src/components/NewWorkspaceComposerCard.tsx -inline src/renderer/src/components/PullRequestPage.tsx inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx inline src/renderer/src/components/UpdateCard.tsx diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 72f5d151eb2..12db9c71fd9 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1,5888 +1,4 @@ -/* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useSyncExternalStore -} from 'react' -import { useVirtualizer } from '@tanstack/react-virtual' -import { useShallow } from 'zustand/react/shallow' -import type { editor as monacoEditor } from 'monaco-editor' -import { - ArrowLeft, - ArrowRight, - Check, - ChevronDown, - ChevronLeft, - CircleDashed, - CircleDot, - Copy, - ExternalLink, - FileText, - FolderKanban, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - GitPullRequestDraft, - ListChecks, - LoaderCircle, - MessageSquare, - MessageSquarePlus, - PanelLeftOpen, - Pencil, - Plus, - RefreshCw, - Send, - Wrench, - X -} from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { Input } from '@/components/ui/input' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useConfirmationDialog } from '@/components/confirmation-dialog-context' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger -} from '@/components/ui/accordion' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { cn } from '@/lib/utils' -import { setWithLRU } from '@/lib/scroll-cache' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { DiffSectionItem } from '@/components/editor/DiffSectionItem' -import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' -import { - CombinedDiffFileTree, - createCombinedDiffSectionIndexMap, - handleCombinedDiffFileTreeNavigation -} from '@/components/editor/CombinedDiffFileTree' -import { - getDiffSectionEstimatedHeight, - isIntrinsicHeightImageDiff -} from '@/components/editor/diff-section-layout' -import type { DiffSection } from '@/components/editor/diff-section-types' -import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { - getCombinedDiffBranchEntriesInTreeOrder, - type CombinedDiffFileTreeEntry -} from '@/components/editor/combined-diff-file-tree-model' -import { - getStoredTextDiffContent, - getStoredTextDiffResult -} from '@/components/editor/large-diff-section-content' -import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content' -import { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog' -import { - beginGitHubChecksTabDetails, - createGitHubChecksTabState, - resetGitHubChecksTabForSource, - resolveGitHubChecksTabState, - settleGitHubChecksTabDetails, - toggleGitHubChecksTabExpandedKey, - updateGitHubChecksTabLocalChecks, - type CheckDetailsLoadState -} from '@/components/github-checks-tab-state' -import { - clearGitHubLinkCopied, - createGitHubLinkCopyState, - markGitHubLinkCopied, - resolveGitHubLinkCopyState -} from '@/components/github-link-copy-state' -import { - resolveGitHubBodyDraft, - shouldSyncGitHubBodyDraft -} from '@/components/github-body-draft-state' -import { - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - type PRCommentAudienceFilter -} from '../../../shared/pr-comment-audience' -import { - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters -} from '@/lib/pr-comment-audience-labels' -import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' -import { - getPRCommentGroupCount, - getPRCommentGroupId, - getPRCommentGroupRoot, - groupPRComments, - isResolvedPRCommentGroup, - type PRCommentGroup -} from '../../../shared/pr-comment-groups' -import { - PR_COMMENT_OPEN_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS -} from '@/lib/pr-comment-resolution-classes' -import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' -import { - attachPRReviewReplyParent, - canPostPRReviewThreadReply -} from '@/components/right-sidebar/pr-comments-ai-launch-ack' -import { buildPRCommentConversationReplyBody } from '@/components/right-sidebar/pr-comment-fixing-reply-body' -import { useAppStore } from '@/store' -import { useAllWorktrees } from '@/store/selectors' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' -import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' -import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { - getGitHubPRReviewerRows, - normalizeGitHubReviewerLogins, - parseGitHubReviewerInputLogins -} from '@/components/github-pr-reviewer-display' -import { - filterGitHubPRReviewerCandidates, - getGitHubPRReviewerQueryState -} from '@/components/github/github-pr-reviewer-candidate-filter' -import { GitHubUserAvatar } from '@/components/github/github-user-avatar' -import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter' -import { - getCommentBodySubmitState, - hasBoundedCommentBodyText -} from '@/lib/comment-body-submit-state' -import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' -import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' -import { - canUseGitHubRepoContext, - getGitHubMutationRoutingSettings, - getGitHubRuntimeRepoId, - getGitHubSourceRuntimeHost -} from '@/lib/github-source-runtime-context' -import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import { - GITHUB_PR_MERGE_METHOD_LABELS, - resolveGitHubPRMergeMethods -} from '../../../shared/github/pull-request-merge-methods' -import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key' -import { - findGithubPrWorkspaceAttachment, - getGithubPrWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' -import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' -import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' -import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt' -import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai' -import type { - SourceControlActionRecipe, - SourceControlLaunchActionId -} from '../../../shared/source-control-ai-actions' -import { - saveSourceControlActionRecipe, - type SourceControlAiWriteTarget -} from '../../../shared/source-control-ai-recipe-save' -import type { GitBranchChangeEntry, GitDiffResult } from '../../../shared/git-diff-compare-types' -import type { PRCheckDetail } from '../../../shared/github/check-types' -import type { PRComment } from '../../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubOwnerRepo, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState, - GitHubPRMergeMethod -} from '../../../shared/github/pull-request-types' -import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../shared/task-source-context' -import { translate } from '@/i18n/i18n' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' -import { - getCheckConclusion, - getCheckCountChips, - getCheckCounts, - getChecksSummaryLabel -} from '@/components/pr-check-counts' -import { - normalizeItemDialogTab, - parseOwnerRepoFromItemUrl, - resolvePullRequestRepo, - type GitHubWorkItemProjectOrigin, - type ItemDialogTab -} from '@/components/github/github-work-item-identity' -import { - addIssueCommentForRepo, - addPRReviewCommentForRepo, - addPRReviewCommentReplyForRepo, - notifyWorkItemDetailsMutation, - setPRFileViewedForRepo -} from '@/components/github/github-work-item-comment-mutations' -import { - runIssueUpdate, - runPullRequestStateUpdate, - runWorkItemBodyUpdate -} from '@/components/github/github-work-item-edit-mutations' -import { - PR_FILE_CONTENT_CACHE_MAX_BYTES, - getRetainedPRFileContentsByteCount, - isPRFileViewed -} from '@/components/github/pr-file-content-size' -import { - PR_DIFF_OVERSCAN, - getPRFileContentsRenderLimit, - getPRFileDiffResult, - getPRFileSectionKey, - gitHubPRFileToBranchEntry, - type PRFilesCombinedDiffViewerProps -} from '@/components/github/pr-file-diff-mapping' -import { - buildRequestedReviewUsers, - formatRelativeTime, - getStateLabel, - mergeReviewerSuggestions, - ReviewerAvatar -} from '@/components/github/work-item-state-presentation' -import { - formatCheckTimestamp, - getCheckDetailsKey, - getCheckStatusLabel -} from '@/components/github/pr-check-presentation' -import { CommentCodeContext } from '@/components/github/CommentCodeContext' -import { CommentReactions } from '@/components/github/CommentReactions' -import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' -import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' - -export type { ItemDialogTab } - -export type PullRequestPageProjectOrigin = GitHubWorkItemProjectOrigin - -type MentionOption = { - login: string - name?: string | null - avatarUrl?: string - source: string -} - -type MentionQuery = { - atIndex: number - query: string -} - -type PullRequestPageProps = { - workItem: GitHubWorkItem | null - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - initialTab?: ItemDialogTab - backLabel?: string - /** Called when the user clicks the primary CTA to start work from this item. */ - onUse: (item: GitHubWorkItem) => void - onReviewRequestsChange?: ( - itemKey: { id: string; repoId: string }, - reviewRequests: GitHubAssignableUser[] - ) => void - onClose: () => void - /** Optional Project-origin context; when set, slug-addressed IPCs route writes to the row's repo instead of `repoPath` (both may be set — slug wins for writes). */ - projectOrigin?: PullRequestPageProjectOrigin -} - -function findMentionQuery(value: string, caret: number): MentionQuery | null { - const beforeCaret = value.slice(0, caret) - const match = /(^|[\s([{,])@([A-Za-z0-9-]*)$/.exec(beforeCaret) - if (!match) { - return null - } - const query = match[2] ?? '' - return { - atIndex: beforeCaret.length - query.length - 1, - query - } -} - -function buildMentionOptions({ - item, - comments, - participants, - assignableUsers -}: { - item: GitHubWorkItem - comments: PRComment[] - participants: GitHubAssignableUser[] - assignableUsers: GitHubAssignableUser[] -}): MentionOption[] { - const byLogin = new Map() - const add = ( - login: string | null | undefined, - source: string, - avatarUrl?: string, - name?: string | null - ): void => { - if (!login || login === 'ghost') { - return - } - const key = login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - if (!existing.avatarUrl && avatarUrl) { - existing.avatarUrl = avatarUrl - } - if (!existing.name && name) { - existing.name = name - } - return - } - byLogin.set(key, { login, source, avatarUrl, name }) - } - - add(item.author, item.type === 'pr' ? 'PR author' : 'Issue author') - for (const comment of comments) { - add(comment.author, 'Commenter', comment.authorAvatarUrl) - } - for (const user of participants) { - add(user.login, 'Participant', user.avatarUrl, user.name) - } - for (const user of assignableUsers) { - add(user.login, 'Team member', user.avatarUrl, user.name) - } - - return Array.from(byLogin.values()) -} - -function getStateTone(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'border-purple-500/30 bg-purple-500/10 text-purple-600 dark:text-purple-300' - } - if (item.state === 'draft') { - return 'border-slate-500/30 bg-slate-500/10 text-slate-600 dark:text-slate-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' - } - if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' - } - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' -} - -function WorkItemStateBadge({ - item, - className -}: { - item: GitHubWorkItem - className?: string -}): React.JSX.Element { - return ( - - {getStateLabel(item)} - - ) -} - -function PRReviewersPanel({ - item, - loading, - repoPath, - sourceContext, - projectOrigin, - onReviewersRequested -}: { - item: GitHubWorkItem - loading: boolean - repoPath: string | null - sourceContext?: TaskSourceContext | null - projectOrigin?: PullRequestPageProjectOrigin - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const [open, setOpen] = useState(false) - const [reviewerInput, setReviewerInput] = useState('') - const [activeReviewerCursor, setActiveReviewerCursor] = useState({ - resetKey: '', - index: 0 - }) - const [submitting, setSubmitting] = useState(false) - const [localReviewRequests, setLocalReviewRequests] = useState( - () => item.reviewRequests ?? [] - ) - const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - })) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const reviewerInputRef = useRef(null) - const reviewerInputFocusFrameRef = useRef(null) - const reviewerPanelMountedRef = useRef(true) - - const cancelReviewerInputFocusFrame = useCallback((): void => { - if (reviewerInputFocusFrameRef.current !== null) { - cancelAnimationFrame(reviewerInputFocusFrameRef.current) - reviewerInputFocusFrameRef.current = null - } - }, []) - - const scheduleReviewerInputFocus = useCallback((): void => { - if (!reviewerPanelMountedRef.current) { - return - } - cancelReviewerInputFocusFrame() - reviewerInputFocusFrameRef.current = requestAnimationFrame(() => { - reviewerInputFocusFrameRef.current = null - reviewerInputRef.current?.focus() - }) - }, [cancelReviewerInputFocusFrame]) - - useEffect(() => { - reviewerPanelMountedRef.current = true - return () => { - reviewerPanelMountedRef.current = false - cancelReviewerInputFocusFrame() - } - }, [cancelReviewerInputFocusFrame]) - - // Why: clear stale optimistic review requests before paint on item switch/refetch (a passive Effect leaves one stale render). - if ( - reviewRequestsSource.itemId !== item.id || - reviewRequestsSource.repoId !== item.repoId || - reviewRequestsSource.reviewRequests !== item.reviewRequests - ) { - setReviewRequestsSource({ - itemId: item.id, - repoId: item.repoId, - reviewRequests: item.reviewRequests - }) - setLocalReviewRequests(item.reviewRequests ?? []) - } - - const reviewerSeedUsers = useMemo(() => { - const byLogin = new Map() - const add = (user: GitHubAssignableUser): void => { - if (!user.login) { - return - } - byLogin.set(user.login.toLowerCase(), user) - } - for (const user of localReviewRequests) { - add(user) - } - for (const review of item.latestReviews ?? []) { - add({ - login: review.login, - name: null, - avatarUrl: review.avatarUrl ?? '' - }) - } - if (item.author) { - add({ login: item.author, name: null, avatarUrl: '' }) - } - return Array.from(byLogin.values()) - }, [item.author, item.latestReviews, localReviewRequests]) - - const reviewRepo = useMemo( - () => resolvePullRequestRepo(item, projectOrigin), - [item, projectOrigin] - ) - const reviewerMetadataBySlug = useRepoAssigneesBySlug( - open && reviewRepo ? reviewRepo.owner : null, - open && reviewRepo ? reviewRepo.repo : null, - reviewerSeedUsers.map((user) => user.login), - sourceSettings, - reviewRepo?.host - ) - const reviewerMetadataByPath = useRepoAssignees( - open && !reviewRepo ? repoPath : null, - open && !reviewRepo ? item.repoId : null, - sourceSettings - ) - const reviewerMetadata = reviewRepo ? reviewerMetadataBySlug : reviewerMetadataByPath - const displayItem = { ...item, reviewRequests: localReviewRequests } - const reviewers = getGitHubPRReviewerRows(displayItem) - const authorLogin = item.author?.toLowerCase() ?? null - const reviewerCandidates = useMemo( - () => - mergeReviewerSuggestions(reviewerMetadata.data, reviewerSeedUsers).filter( - (user) => user.login.toLowerCase() !== authorLogin - ), - [authorLogin, reviewerMetadata.data, reviewerSeedUsers] - ) - const reviewerCandidatesByLogin = useMemo( - () => new Map(reviewerCandidates.map((user) => [user.login.toLowerCase(), user])), - [reviewerCandidates] - ) - const selectedReviewerLogins = useMemo( - () => - new Set( - localReviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) - ), - [localReviewRequests] - ) - const reviewerQueryState = useMemo( - () => getGitHubPRReviewerQueryState(reviewerInput), - [reviewerInput] - ) - const reviewerQuery = reviewerQueryState.query - const filteredReviewerCandidates = useMemo( - () => - filterGitHubPRReviewerCandidates({ - candidates: reviewerCandidates, - queryState: reviewerQueryState - }), - [reviewerCandidates, reviewerQueryState] - ) - const suggestedReviewerRows = useMemo( - () => - reviewerQuery.length === 0 && !reviewerQueryState.isTooLarge - ? reviewerSeedUsers - .filter((user) => !selectedReviewerLogins.has(user.login.toLowerCase())) - .filter((user) => user.login.toLowerCase() !== authorLogin) - .map((user) => reviewerCandidatesByLogin.get(user.login.toLowerCase()) ?? user) - .slice(0, 1) - : [], - [ - authorLogin, - reviewerCandidatesByLogin, - reviewerQuery.length, - reviewerQueryState.isTooLarge, - reviewerSeedUsers, - selectedReviewerLogins - ] - ) - const everyoneElseReviewerRows = useMemo(() => { - const suggestedLogins = new Set(suggestedReviewerRows.map((user) => user.login.toLowerCase())) - return filteredReviewerCandidates.filter( - (user) => !suggestedLogins.has(user.login.toLowerCase()) - ) - }, [filteredReviewerCandidates, suggestedReviewerRows]) - const actionableReviewerRows = useMemo( - () => [...suggestedReviewerRows, ...everyoneElseReviewerRows], - [everyoneElseReviewerRows, suggestedReviewerRows] - ) - - const reviewerCursorResetKey = `${reviewerQuery}\u0000${actionableReviewerRows.length}` - if (activeReviewerCursor.resetKey !== reviewerCursorResetKey) { - setActiveReviewerCursor({ resetKey: reviewerCursorResetKey, index: 0 }) - } - const activeReviewerIndex = - activeReviewerCursor.resetKey === reviewerCursorResetKey ? activeReviewerCursor.index : 0 - const setActiveReviewerIndex = useCallback( - (nextIndex: number | ((current: number) => number)): void => { - setActiveReviewerCursor((current) => { - const currentIndex = current.resetKey === reviewerCursorResetKey ? current.index : 0 - return { - resetKey: reviewerCursorResetKey, - index: typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex - } - }) - }, - [reviewerCursorResetKey] - ) - - const hasReviewerMetadata = - item.reviewDecision !== undefined || - localReviewRequests.length > 0 || - item.reviewRequests !== undefined || - item.latestReviews !== undefined - const canRequestReview = - !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' - - const handleRequestReview = async (requestedLogins?: string[]): Promise => { - if (submitting) { - return - } - const logins = normalizeGitHubReviewerLogins( - requestedLogins ?? parseGitHubReviewerInputLogins(reviewerInput), - selectedReviewerLogins - ) - if (logins.length === 0) { - toast.error(translate('auto.components.PullRequestPage.dace0d1a9f', 'Enter a reviewer')) - return - } - if (localReviewRequests.length + logins.length > 15) { - toast.error( - translate( - 'auto.components.PullRequestPage.8f369a6b6b', - 'You can request up to 15 reviewers' - ) - ) - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.PullRequestPage.1ae11c905c', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.requestPRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.requestPRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer') - ) - return - } - const nextReviewRequests = buildRequestedReviewUsers( - logins, - reviewerCandidates, - localReviewRequests - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - toast.success( - logins.length === 1 - ? translate('auto.components.PullRequestPage.03282ff3b9', 'Reviewer requested') - : translate('auto.components.PullRequestPage.102d3d177f', 'Reviewers requested') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.PullRequestPage.2560588245', 'Failed to request reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const handleRemoveReviewers = async (reviewersToRemove: string[]): Promise => { - if (submitting) { - return - } - const selected = new Set(localReviewRequests.map((reviewer) => reviewer.login.toLowerCase())) - const logins = reviewersToRemove - .map((reviewer) => reviewer.trim().replace(/^@/, '')) - .filter((reviewer) => reviewer.length > 0 && selected.has(reviewer.toLowerCase())) - if (logins.length === 0) { - return - } - const target = getActiveRuntimeTarget(sourceSettings) - if (target.kind !== 'environment' && !repoPath) { - toast.error( - translate( - 'auto.components.PullRequestPage.1ae11c905c', - 'No repo context available for this pull request.' - ) - ) - return - } - setSubmitting(true) - try { - const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId) - const result = - target.kind === 'environment' - ? await callRuntimeRpc<{ ok: boolean; error?: string }>( - target, - 'github.removePRReviewers', - { - repo: runtimeRepo, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.removePRReviewers({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - reviewers: logins, - prRepo: reviewRepo - }) - if (!reviewerPanelMountedRef.current) { - return - } - if (!result.ok) { - toast.error( - result.error ?? - translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer') - ) - return - } - const removed = new Set(logins.map((login) => login.toLowerCase())) - const nextReviewRequests = localReviewRequests.filter( - (reviewer) => !removed.has(reviewer.login.toLowerCase()) - ) - setLocalReviewRequests(nextReviewRequests) - patchWorkItem(item.id, { reviewRequests: nextReviewRequests }, item.repoId, { - sourceContext - }) - onReviewersRequested(nextReviewRequests) - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - setReviewerInput('') - toast.success( - logins.length === 1 - ? translate('auto.components.PullRequestPage.2c1d93da43', 'Reviewer removed') - : translate('auto.components.PullRequestPage.1e6d089420', 'Reviewers removed') - ) - } catch { - if (reviewerPanelMountedRef.current) { - toast.error( - translate('auto.components.PullRequestPage.c798fa0ec7', 'Failed to remove reviewer') - ) - } - } finally { - if (reviewerPanelMountedRef.current) { - setSubmitting(false) - } - } - } - - const requestReviewer = async (reviewer: GitHubAssignableUser): Promise => { - await (selectedReviewerLogins.has(reviewer.login.toLowerCase()) - ? handleRemoveReviewers([reviewer.login]) - : handleRequestReview([reviewer.login])) - scheduleReviewerInputFocus() - } - - const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - scheduleReviewerInputFocus() - return - } - setReviewerInput('') - } - - const renderReviewerPickerRow = ( - reviewer: GitHubAssignableUser, - options: { suggested: boolean; activeIndex: number } - ): React.JSX.Element => { - const selected = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - const active = actionableReviewerRows[activeReviewerIndex]?.login === reviewer.login - return ( - - ) - } - - return ( -
-
- {translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')} - - - - - { - event.preventDefault() - }} - > -
- setReviewerInput(event.target.value)} - disabled={submitting || !canRequestReview} - placeholder={translate( - 'auto.components.PullRequestPage.3bde131f49', - 'Type or choose a user' - )} - aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')} - aria-expanded={open} - aria-haspopup="listbox" - className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs" - onKeyDown={(event) => { - if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => (current + 1) % actionableReviewerRows.length - ) - return - } - if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) { - event.preventDefault() - setActiveReviewerIndex( - (current) => - (current - 1 + actionableReviewerRows.length) % - actionableReviewerRows.length - ) - return - } - if (event.key === 'Enter') { - event.preventDefault() - const activeReviewer = actionableReviewerRows[activeReviewerIndex] - if (activeReviewer) { - void requestReviewer(activeReviewer) - return - } - void handleRequestReview() - return - } - if (event.key === 'Escape') { - event.preventDefault() - handleReviewerPickerOpenChange(false) - } - }} - /> -
-
- {reviewerMetadata.loading ? ( -
- {translate('auto.components.PullRequestPage.57750f4a8c', 'Loading...')} -
- ) : filteredReviewerCandidates.length > 0 ? ( - <> - {suggestedReviewerRows.length > 0 ? ( - <> -
- {translate('auto.components.PullRequestPage.828f045847', 'Suggestions')} -
- {suggestedReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: true, - activeIndex: index - }) - )} - - ) : null} -
- {translate('auto.components.PullRequestPage.2760fa29a4', 'Everyone else')} -
- {everyoneElseReviewerRows.length > 0 ? ( - everyoneElseReviewerRows.map((reviewer, index) => - renderReviewerPickerRow(reviewer, { - suggested: false, - activeIndex: suggestedReviewerRows.length + index - }) - ) - ) : ( -
- {translate( - 'auto.components.PullRequestPage.5ad00c7a0e', - 'No matching reviewers.' - )} -
- )} - - ) : ( -
- {reviewerMetadata.error ?? - (hasReviewerMetadata - ? translate( - 'auto.components.PullRequestPage.5ad00c7a0e', - 'No matching reviewers.' - ) - : translate( - 'auto.components.PullRequestPage.56ec6eafb7', - 'Open the PR details to view current reviewers.' - ))} -
- )} -
-
-
-
- {loading && !hasReviewerMetadata ? ( -
- - {translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')} -
- ) : reviewers.length > 0 ? ( -
- {reviewers.map((reviewer) => { - const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) - return ( -
- -
-
- {reviewer.login} -
- {reviewer.name ? ( -
- {reviewer.name} -
- ) : null} -
- - {reviewer.stateLabel} - - {canRemoveReviewer ? ( - - - - - - {translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')} - - - ) : null} -
- ) - })} -
- ) : ( -
- {translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')} -
- )} -
- ) -} - -// SWR cache: reopening a drawer paints cached data instantly while a background refetch reconciles. See docs/gh-work-item-drawer-cache.md. -const WORK_ITEM_DETAILS_CACHE_MAX = 50 -const WORK_ITEM_DETAILS_FRESH_MS = 30_000 -const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' -type WorkItemDetailsCacheEntry = { - details: GitHubWorkItemDetails | null - fetchedAt: number - pending?: Promise - error?: string -} -const workItemDetailsCache = new Map() - -// Why: useSyncExternalStore snapshot stability relies on every cache write replacing the entry object identity (delete+set). -const workItemDetailsCacheListeners = new Set<() => void>() -function subscribeWorkItemDetailsCache(listener: () => void): () => void { - workItemDetailsCacheListeners.add(listener) - return () => { - workItemDetailsCacheListeners.delete(listener) - } -} -function notifyWorkItemDetailsCache(): void { - for (const listener of workItemDetailsCacheListeners) { - listener() - } -} - -function getWorkItemDetailsCacheKey(args: { - repoPath: string - repoId: string - issueSourcePreference: string | undefined - sourceCacheScope?: string | null - type: 'issue' | 'pr' - number: number -}): string { - // Why: `\0` separator avoids collisions between key fields that may contain `:` or `/`. - const keyParts = args.sourceCacheScope - ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] - : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] - return [...keyParts, args.number].join('\0') -} - -function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { - // Why: re-insert moves the key to MRU; Map insertion order keeps the oldest key first when evicting. - workItemDetailsCache.delete(key) - workItemDetailsCache.set(key, entry) - while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { - const oldest = workItemDetailsCache.keys().next().value - if (oldest === undefined) { - break - } - workItemDetailsCache.delete(oldest) - } - notifyWorkItemDetailsCache() -} - -// Exposed so mutation handlers can drop a stale entry after a local mutation (cross-window invalidation arrives via the `gh:workItemMutated` listener below). -export function invalidateWorkItemDetailsCacheForKey(key: string): void { - // Why: bump generation so an in-flight fetch launched before this invalidation won't write its stale result back. - workItemDetailsCacheGeneration += 1 - const existed = workItemDetailsCache.delete(key) - if (existed) { - notifyWorkItemDetailsCache() - } -} - -// Why: monotonic counter so an in-flight refetch that started before a mutation detects its result is stale and skips the write-back. -let workItemDetailsCacheGeneration = 0 - -// Why: without the exact key (cross-window events carry only repoPath+number+type), drop every entry matching that tuple regardless of source preference. -function invalidateWorkItemDetailsCacheByMatch(args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number -}): void { - const suffix = `\0${args.type}\0${args.number}` - const prefix = `${args.repoId ?? args.repoPath}\0` - let removed = false - for (const key of Array.from(workItemDetailsCache.keys())) { - if (key.startsWith(prefix) && key.endsWith(suffix)) { - workItemDetailsCache.delete(key) - removed = true - } - } - if (removed) { - workItemDetailsCacheGeneration += 1 - notifyWorkItemDetailsCache() - } -} - -function patchCachedPRFileViewedState( - cacheKey: string, - path: string, - viewerViewedState: GitHubPRFileViewedState -): GitHubPRFileViewedState | undefined { - const prev = workItemDetailsCache.get(cacheKey) - const files = prev?.details?.files - if (!prev?.details || !files) { - return undefined - } - let previousState: GitHubPRFileViewedState | undefined - const nextFiles = files.map((file) => { - if (file.path !== path) { - return file - } - previousState = file.viewerViewedState ?? 'UNVIEWED' - return { ...file, viewerViewedState } - }) - if (previousState === undefined || previousState === viewerViewedState) { - return previousState - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, files: nextFiles }, - error: undefined - }) - return previousState -} - -function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, checks }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedPRReviewRequests( - cacheKey: string, - reviewRequests: GitHubAssignableUser[] -): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { - ...prev.details, - item: { ...prev.details.item, reviewRequests } - }, - fetchedAt: Date.now(), - error: undefined - }) -} - -function patchCachedWorkItemBody(cacheKey: string, body: string): void { - const prev = workItemDetailsCache.get(cacheKey) - if (!prev?.details) { - return - } - touchWorkItemDetailsCache(cacheKey, { - ...prev, - details: { ...prev.details, body }, - fetchedAt: Date.now(), - error: undefined - }) -} - -// Install once at module load (all dialogs share the cache); track the unsubscribe so Vite HMR doesn't accumulate listeners across reloads. -let workItemMutatedUnsub: (() => void) | undefined -let workItemDetailsCacheEventUnsub: (() => void) | undefined -if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { - workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => { - invalidateWorkItemDetailsCacheByMatch({ - repoPath: payload.repoPath, - repoId: payload.repoId, - type: payload.type, - number: payload.number - }) - }) - workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => { - invalidateWorkItemDetailsCacheByMatch(payload) - }) -} -if (import.meta !== undefined && import.meta.hot) { - import.meta.hot.dispose(() => { - workItemMutatedUnsub?.() - workItemDetailsCacheEventUnsub?.() - }) -} - -// Why: bounded LRU so a session of opening many PR files can't grow this module map without bound. -const PR_FILE_CONTENT_CACHE_MAX = 64 -type PRFileContentCacheEntry = { - value: Promise | GitHubPRFileContents - byteCount: number -} -const prFileContentCache = new Map() -let prFileContentCacheBytes = 0 - -function touchPRFileContentCache( - key: string, - value: Promise | GitHubPRFileContents -): void { - const retainedByteCount = value instanceof Promise ? 0 : getRetainedPRFileContentsByteCount(value) - if (retainedByteCount === null) { - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - prFileContentCache.delete(key) - return - } - - const existing = prFileContentCache.get(key) - prFileContentCacheBytes -= existing?.byteCount ?? 0 - // Why: re-insert moves the key to MRU; Map insertion order makes the oldest key first when evicting. - prFileContentCache.delete(key) - const byteCount = retainedByteCount - prFileContentCache.set(key, { value, byteCount }) - prFileContentCacheBytes += byteCount - while ( - prFileContentCache.size > PR_FILE_CONTENT_CACHE_MAX || - prFileContentCacheBytes > PR_FILE_CONTENT_CACHE_MAX_BYTES - ) { - const oldest = prFileContentCache.keys().next().value - if (oldest === undefined) { - break - } - const evicted = prFileContentCache.get(oldest) - prFileContentCacheBytes -= evicted?.byteCount ?? 0 - prFileContentCache.delete(oldest) - } -} - -function getPRFileContentCacheKey(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): string { - const repositoryKey = args.repoId ? `repo:${args.repoId}` : `path:${args.repoPath}` - const sourceKey = - args.sourceContext?.provider === 'github' - ? `source:${getTaskSourceCacheScope(args.sourceContext)}` - : 'source:local' - return [ - repositoryKey, - sourceKey, - args.prNumber, - args.prRepo ? githubRepoIdentityKey(args.prRepo) : '', - args.file.path, - args.file.oldPath ?? '', - args.file.status, - args.headSha, - args.baseSha - ].join('\0') -} - -function loadPRFileContents(args: { - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - file: GitHubPRFile - headSha: string - baseSha: string -}): Promise { - const cacheKey = getPRFileContentCacheKey(args) - const cached = prFileContentCache.get(cacheKey) - if (cached) { - touchPRFileContentCache(cacheKey, cached.value) - return Promise.resolve(cached.value) - } - let request: Promise - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - request = ( - runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prFileContents', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prFileContents({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - path: args.file.path, - oldPath: args.file.oldPath, - status: args.file.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - ) - .then((contents) => { - if (prFileContentCache.get(cacheKey)?.value === request) { - touchPRFileContentCache(cacheKey, contents) - } - return contents - }) - .catch((err) => { - const cachedRequest = prFileContentCache.get(cacheKey) - if (cachedRequest?.value === request) { - prFileContentCacheBytes -= cachedRequest.byteCount - prFileContentCache.delete(cacheKey) - } - throw err - }) - touchPRFileContentCache(cacheKey, request) - return request -} - -type CachedPRFilesDiffViewState = { - entrySignature: string - sections: DiffSection[] - sectionHeights: Record - loadedIndices: number[] - scrollTop: number - sideBySide: boolean - fileTreeCollapsed: boolean - activeTreeSectionKey: string | null -} - -const prFilesDiffViewStateCache = new Map() -const prFilesDiffScrollTopCache = new Map() - -function PRFilesCombinedDiffViewer({ - files, - comments, - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - prUrl, - headSha, - baseSha, - pendingViewedPaths, - onCommentAdded, - onViewedChange -}: PRFilesCombinedDiffViewerProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const entriesCacheRef = useRef<{ - signature: string - entries: GitBranchChangeEntry[] - } | null>(null) - const diffEntrySignature = useMemo( - () => - JSON.stringify( - files.map((file) => ({ - path: file.path, - oldPath: file.oldPath ?? null, - status: file.status, - additions: file.additions, - deletions: file.deletions, - isBinary: file.isBinary - })) - ), - [files] - ) - const entries = useMemo(() => { - if (entriesCacheRef.current?.signature === diffEntrySignature) { - return entriesCacheRef.current.entries - } - const nextEntries = getCombinedDiffBranchEntriesInTreeOrder( - 'commit', - files.map(gitHubPRFileToBranchEntry) - ) - entriesCacheRef.current = { - signature: diffEntrySignature, - entries: nextEntries - } - return nextEntries - }, [diffEntrySignature, files]) - const fileByPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files]) - const inlineReviewComments = useMemo( - () => - comments.flatMap((comment): DecoratedDiffComment[] => { - // Why: outdated threads keep originalLine for the sidebar, but rendering it inline can attach the comment to unrelated current code. - if (comment.isOutdated || !comment.path || typeof comment.line !== 'number') { - return [] - } - const createdAtMs = new Date(comment.createdAt).getTime() - return [ - { - id: `github-pr-comment:${comment.id}`, - worktreeId: `github-pr:${repoId}:${prNumber}`, - filePath: comment.path, - source: 'diff', - startLine: comment.startLine, - lineNumber: comment.line, - body: comment.body, - createdAt: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(), - side: 'modified', - author: comment.author, - authorAvatarUrl: comment.authorAvatarUrl, - createdAtLabel: formatRelativeTime(comment.createdAt), - url: comment.url, - canDelete: false, - canEdit: false - } - ] - }), - [comments, prNumber, repoId] - ) - const entrySignature = useMemo( - () => - JSON.stringify({ - repoId, - prNumber, - prRepo: prRepo ? githubRepoIdentityKey(prRepo) : null, - headSha: headSha ?? null, - baseSha: baseSha ?? null, - files: diffEntrySignature - }), - [baseSha, diffEntrySignature, headSha, prNumber, prRepo, repoId] - ) - const viewStateKey = useMemo( - () => [repoId || repoPath, prNumber, prRepo ? githubRepoIdentityKey(prRepo) : ''].join('\0'), - [prNumber, prRepo, repoId, repoPath] - ) - const [sections, setSections] = useState([]) - const [sideBySide, setSideBySide] = useState(false) - const [fileTreeCollapsed, setFileTreeCollapsed] = useState(false) - const [sectionHeights, setSectionHeights] = useState>({}) - const [activeTreeSectionKey, setActiveTreeSectionKey] = useState(null) - const scrollContainerRef = useRef(null) - const pendingRestoreScrollTopRef = useRef(null) - const loadedIndicesRef = useRef>(new Set()) - const loadingIndicesRef = useRef>(new Set()) - const sectionsRef = useRef([]) - const generationRef = useRef(0) - const modifiedEditorsRef = useRef>(new Map()) - const handleSectionSaveRef = useRef<(index: number) => Promise>(async () => {}) - sectionsRef.current = sections - - useEffect(() => { - // Why: bump generation so stale async diff loads from the previous view can't patch the restored sections. - generationRef.current += 1 - const cached = prFilesDiffViewStateCache.get(viewStateKey) - if (cached && cached.entrySignature === entrySignature) { - const restoredSections = cached.sections - loadedIndicesRef.current = new Set( - cached.loadedIndices.filter((index) => !restoredSections[index]?.loading) - ) - loadingIndicesRef.current.clear() - setSections(restoredSections) - setSectionHeights(cached.sectionHeights) - setSideBySide(cached.sideBySide) - setFileTreeCollapsed(cached.fileTreeCollapsed) - setActiveTreeSectionKey(cached.activeTreeSectionKey) - pendingRestoreScrollTopRef.current = - prFilesDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop - return - } - - loadedIndicesRef.current.clear() - loadingIndicesRef.current.clear() - pendingRestoreScrollTopRef.current = prFilesDiffScrollTopCache.get(viewStateKey) ?? null - setSectionHeights({}) - setActiveTreeSectionKey(null) - setSections( - entries.map((entry) => ({ - key: getPRFileSectionKey(entry.path), - path: entry.path, - oldPath: entry.oldPath, - status: entry.status, - added: entry.added, - removed: entry.removed, - originalContent: '', - modifiedContent: '', - collapsed: false, - loading: true, - error: undefined, - dirty: false, - diffResult: null, - largeDiffRenderLimit: null - })) - ) - }, [entries, entrySignature, viewStateKey]) - - const loadSection = useCallback( - (index: number) => { - const section = sectionsRef.current[index] - if (!section || section.collapsed) { - return - } - if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { - return - } - const file = fileByPath.get(section.path) - if (!file) { - return - } - const generation = generationRef.current - loadingIndicesRef.current.add(index) - - const load = async (): Promise<{ - result: GitDiffResult - resultContents?: GitHubPRFileContents - error?: string - }> => { - if (file.isBinary) { - return { - result: { - kind: 'binary', - originalContent: '', - modifiedContent: '', - originalIsBinary: true, - modifiedIsBinary: true - } - } - } - if (!headSha || !baseSha) { - return { - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - }, - error: translate( - 'auto.components.PullRequestPage.74660bd80b', - 'Diff unavailable because the PR commit SHAs are missing.' - ) - } - } - const contents = await loadPRFileContents({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - file, - headSha, - baseSha - }) - return { result: getPRFileDiffResult(contents), resultContents: contents } - } - - load() - .catch((error) => ({ - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } as GitDiffResult, - resultContents: undefined, - error: error instanceof Error ? error.message : 'Failed to load diff.' - })) - .then(({ result, resultContents, error }) => { - loadingIndicesRef.current.delete(index) - if (generationRef.current !== generation) { - return - } - const largeDiffRenderLimit = - !error && result.kind === 'text' && resultContents - ? getPRFileContentsRenderLimit(resultContents) - : null - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - setSections((prev) => - prev.map((current, currentIndex) => - currentIndex === index - ? { - ...current, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit - } - : current - ) - ) - }) - }, - [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const retrySection = useCallback( - (index: number) => { - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - diffResult: null, - originalContent: '', - modifiedContent: '', - loading: true, - error: undefined, - largeDiffRenderLimit: null - } - : section - ) - ) - loadSection(index) - }, - [loadSection] - ) - - const toggleSection = useCallback( - (index: number) => { - const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index ? { ...section, collapsed: !section.collapsed } : section - ) - ) - if (shouldLoadAfterExpand) { - window.requestAnimationFrame(() => loadSection(index)) - } - }, - [loadSection] - ) - - const setAllSectionsCollapsed = useCallback( - (collapsed: boolean) => { - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - if (!collapsed) { - window.requestAnimationFrame(() => { - sectionsRef.current.forEach((_, index) => loadSection(index)) - }) - } - }, - [loadSection] - ) - - const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) - const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) - const viewedSectionKeys = useMemo( - () => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))), - [files] - ) - - const virtualizer = useVirtualizer({ - count: sections.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => { - const section = sections[index] - if (!section) { - return 88 - } - return getDiffSectionEstimatedHeight({ - collapsed: section.collapsed, - measuredContentHeight: sectionHeights[index], - originalContent: section.originalContent, - modifiedContent: section.modifiedContent, - changedLineCount: - section.added === undefined && section.removed === undefined - ? undefined - : (section.added ?? 0) + (section.removed ?? 0), - useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult), - isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true, - lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined - }) - }, - overscan: PR_DIFF_OVERSCAN, - getItemKey: (index) => { - const section = sections[index] - return section - ? `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${entrySignature}` - : `${index}:${entrySignature}` - } - }) - - useLayoutEffect(() => { - virtualizer.measure() - }, [sideBySide, virtualizer]) - - useEffect(() => { - if (sections.length === 0 && entries.length > 0) { - return - } - const preservedScrollTop = - prFilesDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU(prFilesDiffViewStateCache, viewStateKey, { - entrySignature, - sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide, - fileTreeCollapsed, - activeTreeSectionKey - }) - }, [ - activeTreeSectionKey, - entries.length, - entrySignature, - fileTreeCollapsed, - sectionHeights, - sections, - sideBySide, - viewStateKey - ]) - - useLayoutEffect(() => { - const container = scrollContainerRef.current - if (!container) { - return - } - - const updateCachedScrollPosition = (): void => { - const existing = prFilesDiffViewStateCache.get(viewStateKey) - setWithLRU(prFilesDiffScrollTopCache, viewStateKey, container.scrollTop) - if (!existing || existing.entrySignature !== entrySignature) { - return - } - setWithLRU(prFilesDiffViewStateCache, viewStateKey, { - ...existing, - scrollTop: container.scrollTop - }) - } - - container.addEventListener('scroll', updateCachedScrollPosition) - return () => { - updateCachedScrollPosition() - container.removeEventListener('scroll', updateCachedScrollPosition) - } - }, [entrySignature, viewStateKey]) - - useLayoutEffect(() => { - const container = scrollContainerRef.current - const targetScrollTop = pendingRestoreScrollTopRef.current - if (!container || targetScrollTop === null) { - return - } - - let frameId = 0 - let attempts = 0 - const restoreScrollPosition = (): void => { - const liveContainer = scrollContainerRef.current - const liveTarget = pendingRestoreScrollTopRef.current - if (!liveContainer || liveTarget === null) { - return - } - - const maxScrollTop = Math.max(0, liveContainer.scrollHeight - liveContainer.clientHeight) - const nextScrollTop = Math.min(liveTarget, maxScrollTop) - liveContainer.scrollTop = nextScrollTop - setWithLRU(prFilesDiffScrollTopCache, viewStateKey, nextScrollTop) - - if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) { - pendingRestoreScrollTopRef.current = null - return - } - - attempts += 1 - if (attempts < 30) { - frameId = window.requestAnimationFrame(restoreScrollPosition) - } - } - - restoreScrollPosition() - return () => window.cancelAnimationFrame(frameId) - }, [sectionHeights, sections, viewStateKey]) - - const handleTreeNavigate = useCallback( - (entry: CombinedDiffFileTreeEntry) => { - const navigatedIndex = handleCombinedDiffFileTreeNavigation({ - mode: 'commit', - entry, - sections: sectionsRef.current, - sectionIndexByKey, - toggleSection, - scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' }) - }) - if (navigatedIndex !== null) { - setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null) - } - }, - [sectionIndexByKey, toggleSection, virtualizer] - ) - - const openFilesOnGitHub = useCallback(() => { - void window.api.shell.openUrl(`${prUrl.replace(/\/$/, '')}/files`) - }, [prUrl]) - - const handleAddLineComment = useCallback( - async ( - section: DiffSection, - { - lineNumber, - startLine, - body - }: { - lineNumber: number - startLine?: number - body: string - } - ) => { - if (!headSha) { - toast.error( - translate( - 'auto.components.PullRequestPage.d8c3ba91c4', - 'Unable to comment without the PR head SHA.' - ) - ) - return false - } - const result = await addPRReviewCommentForRepo({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - commitId: headSha, - path: section.path, - line: lineNumber, - startLine, - body - }) - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.PullRequestPage.19628e058d', 'Failed to add review comment.') - ) - return false - } - onCommentAdded(result.comment) - toast.success( - translate('auto.components.PullRequestPage.eff839f438', 'Review comment added.') - ) - return true - }, - [headSha, onCommentAdded, prNumber, prRepo, repoId, repoPath, sourceContext] - ) - - const renderViewedCheckbox = useCallback( - (section: DiffSection) => { - const file = fileByPath.get(section.path) - if (!file) { - return null - } - const viewed = isPRFileViewed(file) - const pending = pendingViewedPaths.has(file.path) - return ( - { - if (!pending) { - void onViewedChange(file.path, !viewed) - } - }} - /> - ) - }, - [fileByPath, onViewedChange, pendingViewedPaths] - ) - - return ( -
-
-
- {fileTreeCollapsed && ( - - - - - - {translate('auto.components.PullRequestPage.319cf2d54b', 'Show file tree')} - - - )} - - {files.filter(isPRFileViewed).length} / {files.length}{' '} - {translate('auto.components.PullRequestPage.89e80af1c7', 'files viewed')} - -
-
- - -
-
-
- -
-
- {virtualizer.getVirtualItems().map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } - return ( -
- - fileByPath.get(section.path)?.reviewCommentLineNumbers - } - setSectionHeights={setSectionHeights} - setSections={setSections} - modifiedEditorsRef={modifiedEditorsRef} - handleSectionSaveRef={handleSectionSaveRef} - /> -
- ) - })} -
-
-
-
- ) -} - -function ConversationTab({ - item, - repoPath, - repoId, - sourceContext, - body, - comments, - files, - headSha, - baseSha, - loading, - detailsLoaded, - checks, - participants: detailsParticipants, - localState, - onStateChange, - projectOrigin, - onMutated, - onChecksUpdated, - onBodyUpdated, - onCommentAdded, - onReviewersRequested -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - body: string - comments: PRComment[] - files: GitHubPRFile[] - headSha: string | undefined - baseSha: string | undefined - loading: boolean - detailsLoaded: boolean - checks: GitHubWorkItemDetails['checks'] - participants: GitHubAssignableUser[] - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - projectOrigin: PullRequestPageProjectOrigin | undefined - onMutated: () => void - onChecksUpdated: (checks: PRCheckDetail[]) => void - onBodyUpdated: (body: string) => void - onCommentAdded: (comment: PRComment) => void - onReviewersRequested: (reviewRequests: GitHubAssignableUser[]) => void -}): React.JSX.Element { - const authorLabel = item.author ?? 'unknown' - const [replyingTo, setReplyingTo] = useState(null) - const [commentFilter, setCommentFilter] = useState('all') - const [bodyDraft, setBodyDraft] = useState(body) - const [bodyEditing, setBodyEditing] = useState(false) - const [bodySaving, setBodySaving] = useState(false) - const bodyTextareaRef = useRef(null) - const bodyTextareaFocusFrameRef = useRef(null) - const canUseRepoMutationContext = canUseGitHubRepoContext(repoPath, sourceContext) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const repoAssignees = useRepoAssignees(repoPath, item.repoId, sourceSettings) - const botAuthorOverrides = usePRBotAuthorOverrides() - const commentCounts = useMemo( - () => getPRCommentAudienceCounts(comments, botAuthorOverrides), - [botAuthorOverrides, comments] - ) - const visibleComments = useMemo( - () => filterPRCommentsByAudience(comments, commentFilter, botAuthorOverrides), - [botAuthorOverrides, commentFilter, comments] - ) - const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) - const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments) - const mentionOptions = useMemo( - () => - buildMentionOptions({ - item, - comments, - participants: detailsParticipants, - assignableUsers: repoAssignees.data - }), - [comments, detailsParticipants, item, repoAssignees.data] - ) - - const cancelBodyTextareaFocusFrame = useCallback((): void => { - if (bodyTextareaFocusFrameRef.current !== null) { - cancelAnimationFrame(bodyTextareaFocusFrameRef.current) - bodyTextareaFocusFrameRef.current = null - } - }, []) - - if (resolvedReplyingTo !== replyingTo) { - // Why: clear before paint when filters/refetches hide the reply target, so a stale composer doesn't flash for the wrong comment set. - setReplyingTo(resolvedReplyingTo) - } - - const resolvedBodyDraft = resolveGitHubBodyDraft(bodyDraft, body, bodyEditing) - if (shouldSyncGitHubBodyDraft(bodyDraft, body, bodyEditing)) { - // Why: reconcile before paint so a background body refresh while the editor is closed doesn't show a stale draft on reopen. - setBodyDraft(resolvedBodyDraft) - } - - useEffect(() => { - if (!bodyEditing) { - cancelBodyTextareaFocusFrame() - return cancelBodyTextareaFocusFrame - } - cancelBodyTextareaFocusFrame() - bodyTextareaFocusFrameRef.current = requestAnimationFrame(() => { - bodyTextareaFocusFrameRef.current = null - bodyTextareaRef.current?.focus() - }) - return cancelBodyTextareaFocusFrame - }, [bodyEditing, cancelBodyTextareaFocusFrame]) - - const bodySlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) - const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) - const markdownGitHubRepo = useMemo( - () => - projectOrigin - ? { owner: projectOrigin.owner, repo: projectOrigin.repo, host: projectOrigin.host } - : bodySlug, - [bodySlug, projectOrigin] - ) - const canEditBody = - item.type === 'pr' - ? Boolean(projectOrigin || bodySlug) - : Boolean(projectOrigin || canUseRepoMutationContext) - const bodyChanged = resolvedBodyDraft !== body - - const handleSaveBody = useCallback(async (): Promise => { - if (bodySaving || !bodyChanged) { - setBodyEditing(false) - return - } - setBodySaving(true) - try { - await runWorkItemBodyUpdate({ - item, - repoPath, - sourceContext, - projectOrigin, - body: resolvedBodyDraft, - parsedSlug: bodySlug - }) - onBodyUpdated(resolvedBodyDraft) - setBodyEditing(false) - toast.success(translate('auto.components.PullRequestPage.9b4190dc98', 'Description updated.')) - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.d94810f652', 'Failed to update description.') - ) - } finally { - setBodySaving(false) - } - }, [ - bodyChanged, - resolvedBodyDraft, - bodySaving, - bodySlug, - item, - onBodyUpdated, - projectOrigin, - repoPath, - sourceContext - ]) - - const handleReply = useCallback( - async (comment: PRComment, replyBody: string): Promise => { - if (!canUseRepoMutationContext) { - toast.error( - translate( - 'auto.components.PullRequestPage.6885c619e7', - 'Unable to reply without a repository path.' - ) - ) - return false - } - // Why: nest under review threads (path/threadId/discussion_r); never post a - // separate top-level conversation comment for those. - const isReviewThreadReply = item.type === 'pr' && canPostPRReviewThreadReply(comment) - const result = isReviewThreadReply - ? await addPRReviewCommentReplyForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - prNumber: item.number, - prRepo, - commentId: comment.id, - body: replyBody, - threadId: comment.threadId, - path: comment.path, - line: comment.line - }) - : await addIssueCommentForRepo({ - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - number: item.number, - // Why: a GitHub App login carries a [bot] suffix that never resolves as a mention. - body: buildPRCommentConversationReplyBody(comment.author, replyBody), - type: item.type, - prRepo - }) - - if (!result.ok) { - toast.error( - result.error || - translate('auto.components.PullRequestPage.5821aab360', 'Failed to post reply.') - ) - return false - } - onCommentAdded( - isReviewThreadReply ? attachPRReviewReplyParent(result.comment, comment) : result.comment - ) - setReplyingTo(null) - toast.success(translate('auto.components.PullRequestPage.11505c7a71', 'Reply posted.')) - return true - }, - [ - canUseRepoMutationContext, - item.number, - item.repoId, - item.type, - onCommentAdded, - prRepo, - repoPath, - sourceContext - ] - ) - - const rightPanel = - item.type === 'pr' ? ( -
- - - - -
- ) : null - - const renderCommentCard = (comment: PRComment, isReply = false): React.JSX.Element => ( -
-
- {comment.authorAvatarUrl ? ( - {comment.author} - ) : ( -
- )} - - {comment.author} - - - · {formatRelativeTime(comment.createdAt)} - - {comment.path && ( - - {comment.path.split('/').pop()} - {comment.line - ? translate('auto.components.PullRequestPage.34b9f7c264', ':L{{value0}}', { - value0: comment.line - }) - : ''} - - )} - {comment.isResolved && ( - - {translate('auto.components.PullRequestPage.76b2a0ac5b', 'resolved')} - - )} -
- - - - - - {translate('auto.components.PullRequestPage.d6c6679de7', 'Reply to comment')} - - - {comment.url && ( - - - - - - {translate('auto.components.PullRequestPage.0ac19bb52e', 'Open comment on GitHub')} - - - )} -
-
-
- - - - {resolvedReplyingTo === comment.id && ( - setReplyingTo(null)} - onSubmit={(replyBody) => handleReply(comment, replyBody)} - /> - )} -
-
- ) - - const renderCommentGroup = (group: PRCommentGroup): React.JSX.Element => { - const cards = - group.kind === 'thread' - ? [ - renderCommentCard(group.root), - ...group.replies.map((reply) => renderCommentCard(reply, true)) - ] - : [renderCommentCard(group.comment)] - - if (!isResolvedPRCommentGroup(group)) { - return ( -
- {cards} -
- ) - } - - const root = getPRCommentGroupRoot(group) - const count = getPRCommentGroupCount(group) - return ( - - - - - {translate('auto.components.PullRequestPage.f4fe47c2bb', 'Resolved')}{' '} - {group.kind === 'thread' - ? translate('auto.components.PullRequestPage.345b68254c', 'thread') - : translate('auto.components.PullRequestPage.e01e34f5fa', 'comment')}{' '} - {translate('auto.components.PullRequestPage.3c891789f6', 'by')} {root.author} - {count > 1 ? ` (${count})` : ''} - - - - {cards} - - - - ) - } - - return ( -
-
-
-
- {authorLabel} - - {translate('auto.components.PullRequestPage.169a93b29a', 'updated')}{' '} - {formatRelativeTime(item.updatedAt)} - - {canEditBody && !loading && detailsLoaded ? ( - bodyEditing ? ( -
- - -
- ) : ( - - - - - - {translate('auto.components.PullRequestPage.da9aaa8bcf', 'Edit description')} - - - ) - ) : null} -
-
- {loading && !detailsLoaded ? ( -
- -
- ) : bodyEditing ? ( - { - if (event.key === 'Escape') { - event.preventDefault() - setBodyDraft(body) - setBodyEditing(false) - return - } - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - void handleSaveBody() - } - }} - placeholder={translate('auto.components.PullRequestPage.778683ec84', 'Description')} - rows={12} - mentionOptions={mentionOptions} - wrapperClassName="flex min-h-64 w-full items-stretch" - className="scrollbar-sleek block min-h-64 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> - ) : body.trim() ? ( - - ) : ( - - {translate( - 'auto.components.PullRequestPage.c8ea6c7c4c', - 'No description provided.' - )} - - )} -
-
- - {detailsLoaded ? ( - <> -
- - - {translate('auto.components.PullRequestPage.3463d10a63', 'Comments')} - - {comments.length > 0 && ( - - {comments.length} - - )} -
- - {item.type === 'pr' && comments.length > 0 && ( -
- {getPrCommentAudienceFilters().map((filter) => { - const isActive = commentFilter === filter.value - return ( - - ) - })} -
- )} - - {comments.length === 0 ? ( -
- {translate('auto.components.PullRequestPage.d2d589556c', 'No comments yet.')} -
- ) : visibleComments.length === 0 ? ( -
- {getPRCommentAudienceEmptyLabel(commentFilter)} -
- ) : ( -
- {visibleCommentGroups.map(renderCommentGroup)} -
- )} - - ) : null} - - {detailsLoaded && canUseRepoMutationContext && ( - - )} -
- - {rightPanel} -
- ) -} - -function PRActionsPanel({ - item, - repoPath, - repoId, - sourceContext, - projectOrigin, - localState, - onStateChange, - onMutated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: PullRequestPageProjectOrigin | undefined - localState: GitHubWorkItem['state'] - onStateChange: (state: GitHubWorkItem['state']) => void - onMutated: () => void -}): React.JSX.Element { - const [statePending, setStatePending] = useState(false) - const [mergePending, setMergePending] = useState(false) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const confirm = useConfirmationDialog() - const actionItem = { ...item, state: localState } - const mergePresentation = presentGitHubPRMergeState(actionItem) - const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = useAppStore( - useShallow((s) => - getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) - ) - ) - const mergeTarget = getActiveRuntimeTarget(sourceSettings) - const prRepo = resolvePullRequestRepo(item, projectOrigin) - const canMutateWithRepoContext = - !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' - const canMutateState = localState !== 'merged' && canMutateWithRepoContext - const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' - const canMergeWithRepoContext = !!repoPath || mergeTarget.kind === 'environment' - const mergeDisabled = - !canMergeWithRepoContext || mergePending || !mergePresentation.directMergeAvailable - - const patchProjectRowIfNeeded = useCallback( - (state: GitHubWorkItem['state']) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) - }, - [patchProjectRowContent, projectOrigin] - ) - - const applyStatePatch = useCallback( - (state: GitHubWorkItem['state']) => { - onStateChange(state) - patchWorkItem(item.id, { state }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(state) - }, - [item.id, item.repoId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, sourceContext] - ) - - const handleStateChange = async (): Promise => { - if (!canMutateState || statePending) { - return - } - const label = nextState === 'closed' ? 'Close' : 'Reopen' - const confirmed = await confirm({ - title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', { - value0: label, - value1: item.number - }), - description: - nextState === 'closed' - ? translate( - 'auto.components.PullRequestPage.5a65651096', - 'This will close the pull request on GitHub.' - ) - : translate( - 'auto.components.PullRequestPage.3d77438c92', - 'This will reopen the pull request on GitHub.' - ), - confirmLabel: label, - confirmVariant: nextState === 'closed' ? 'destructive' : 'default' - }) - if (!confirmed) { - return - } - const previousState = localState - setStatePending(true) - applyStatePatch(nextState) - try { - await runPullRequestStateUpdate({ - repoPath, - repoId, - sourceContext, - projectOrigin, - number: item.number, - prRepo, - updates: { state: nextState } - }) - toast.success( - nextState === 'closed' - ? translate('auto.components.PullRequestPage.7aa3b5f706', 'Pull request closed') - : translate('auto.components.PullRequestPage.710e47aa06', 'Pull request reopened') - ) - onMutated() - } catch (err) { - applyStatePatch(previousState) - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.b8c6cbb8c4', 'Failed to {{value0}} PR', { - value0: label.toLowerCase() - }) - ) - } finally { - setStatePending(false) - } - } - - const handleMerge = async (method: GitHubPRMergeMethod): Promise => { - if (mergeDisabled) { - return - } - const label = GITHUB_PR_MERGE_METHOD_LABELS[method] - const confirmed = await confirm({ - title: translate('auto.components.PullRequestPage.eec3706a6a', '{{value0}} PR #{{value1}}?', { - value0: label, - value1: item.number - }), - description: translate( - 'auto.components.PullRequestPage.a63b3c159c', - 'This will update the pull request on GitHub.' - ), - confirmLabel: label - }) - if (!confirmed) { - return - } - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.mergePR', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - method, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.mergePR({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - method, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - applyStatePatch('merged') - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - toast.success(translate('auto.components.PullRequestPage.c57873d721', 'Pull request merged')) - onMutated() - } catch { - toast.error( - translate('auto.components.PullRequestPage.aae645d36d', 'Failed to merge pull request') - ) - } finally { - setMergePending(false) - } - } - - const handleAutoMerge = async (): Promise => { - if (!canMergeWithRepoContext || !mergePresentation.autoMergeAction) { - return - } - const enabled = mergePresentation.autoMergeAction.kind === 'enable' - setMergePending(true) - try { - const result = - mergeTarget.kind === 'environment' - ? await callRuntimeRpc>>( - mergeTarget, - 'github.setPRAutoMerge', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRAutoMerge({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - enabled, - method: enabled ? mergeMethods.defaultMethod : undefined, - prRepo - }) - if (!result.ok) { - toast.error(result.error) - return - } - if (mergeTarget.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: repoPath ?? '', - repoId: item.repoId, - sourceContext, - type: 'pr', - number: item.number - }, - { local: false } - ) - } - toast.success( - enabled - ? translate('auto.components.PullRequestPage.5edbe7eefa', 'Auto-merge enabled') - : translate('auto.components.PullRequestPage.0f5821b035', 'Auto-merge disabled') - ) - onMutated() - } catch { - toast.error( - enabled - ? translate('auto.components.PullRequestPage.d31f4b508c', 'Failed to enable auto-merge') - : translate('auto.components.PullRequestPage.973ef2fac9', 'Failed to disable auto-merge') - ) - } finally { - setMergePending(false) - } - } - - return ( - - ) -} - -function CommentReplyForm({ - className, - placeholder, - mentionOptions, - onCancel, - onSubmit -}: { - className?: string - placeholder: string - mentionOptions: MentionOption[] - onCancel: () => void - onSubmit: (body: string) => Promise -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const textareaRef = useRef(null) - const mountedRef = useMountedRef() - - useEffect(() => { - textareaRef.current?.focus() - }, []) - - const submit = useCallback(async () => { - const bodyState = getCommentBodySubmitState(body) - if (bodyState.status === 'empty' || submitting) { - return - } - if (bodyState.status === 'too-large-leading-whitespace') { - toast.error( - translate( - 'auto.components.PullRequestPage.commentTooLarge', - 'Comment is too large to submit safely.' - ) - ) - return - } - setSubmitting(true) - try { - const ok = await onSubmit(bodyState.body) - if (!mountedRef.current) { - return - } - if (ok) { - setBody('') - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, mountedRef, onSubmit, submitting]) - const canSubmitReply = hasBoundedCommentBodyText(body) - - return ( -
- { - if (e.key === 'Escape') { - e.preventDefault() - onCancel() - return - } - if (isScreenSubmitShortcut(e)) { - e.preventDefault() - void submit() - } - }} - placeholder={placeholder} - rows={3} - mentionOptions={mentionOptions} - className="scrollbar-sleek min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> -
- - -
-
- ) -} - -function ChecksTab({ - item, - repoPath, - repoId, - sourceContext, - headSha, - checks, - loading, - variant = 'compact', - onChecksUpdated -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - headSha: string | undefined - checks: GitHubWorkItemDetails['checks'] - loading: boolean - variant?: 'compact' | 'page' - onChecksUpdated: (checks: PRCheckDetail[]) => void -}): React.JSX.Element { - const targetRepoId = repoId ?? item.repoId - const settings = useAppStore((s) => s.settings) - const updateSettings = useAppStore((s) => s.updateSettings) - const updateRepo = useAppStore((s) => s.updateRepo) - const repo = useAppStore((s) => - targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null - ) - const [fixingChecks, setFixingChecks] = useState(false) - const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState(null) - const mountedRef = useMountedRef() - const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) - const nextCheckDetailsRequestIdRef = useRef(0) - const checkDetailsContextKey = [ - sourceContext ? getTaskSourceCacheScope(sourceContext) : 'local', - repoId ?? item.repoId ?? '', - repoPath ?? '', - prRepo ? githubRepoIdentityKey(prRepo) : '', - item.id, - item.number, - headSha ?? '' - ].join('\0') - const [checksState, setChecksState] = useState(() => - createGitHubChecksTabState(checks, checkDetailsContextKey) - ) - const resolvedChecksState = resolveGitHubChecksTabState( - checksState, - checks, - checkDetailsContextKey - ) - const committedChecksContextOwnerRef = useRef(resolvedChecksState.contextOwner) - const nextChecksRefreshRequestIdRef = useRef(0) - const activeChecksRefreshRequestIdRef = useRef(null) - const [refreshingOwner, setRefreshingOwner] = useState<{ - contextOwner: object - requestId: number - } | null>(null) - const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner - const [rerunningOwner, setRerunningOwner] = useState(null) - const rerunning = rerunningOwner === resolvedChecksState.contextOwner - useLayoutEffect(() => { - committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner - }, [resolvedChecksState.contextOwner]) - if (resolvedChecksState !== checksState) { - // Why: reconcile before paint when a parent check refresh replaces the source list, so stale rows/details never show. - setChecksState(resolvedChecksState) - } - const { localChecks, expandedCheckKey, detailsByCheckKey } = resolvedChecksState - const list = useMemo(() => localChecks ?? checks ?? [], [checks, localChecks]) - const fixChecksRecipe = useMemo( - () => - resolveSourceControlActionRecipe({ - settings, - repo, - actionId: 'fixChecks' - }), - [repo, settings] - ) - const fixChecksLaunchPlatform = useMemo( - () => - resolveSourceControlLaunchPlatform({ - connectionId: repo?.connectionId ?? null, - worktreePath: repo?.path ?? null, - projectRuntime: repo?.connectionId - ? undefined - : getLocalRepoProjectExecutionRuntimeContext( - useAppStore.getState(), - repo?.id, - CLIENT_PLATFORM - ) - }), - [repo?.connectionId, repo?.id, repo?.path] - ) - const saveFixChecksActionDefault = useCallback( - async ( - target: SourceControlAiWriteTarget, - actionId: SourceControlLaunchActionId, - recipe: SourceControlActionRecipe - ): Promise => { - const state = useAppStore.getState() - const latestSettings = state.settings - if (!latestSettings) { - throw new Error('Settings are not loaded.') - } - const latestRepo = - target.type === 'repo' - ? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null) - : null - const result = saveSourceControlActionRecipe({ - target, - settings: latestSettings, - repo: latestRepo, - actionId, - recipe - }) - if ('sourceControlAi' in result) { - await updateSettings({ sourceControlAi: result.sourceControlAi }) - return - } - await updateRepo(result.target.repoId, result.update) - }, - [updateRepo, updateSettings] - ) - const handleStartFixChecksFromDialog = useCallback( - async ({ - agent, - commandInput, - agentArgs - }: { - agent: Parameters[0]['agentOverride'] - commandInput: string - agentArgs: string - }): Promise => { - if (!targetRepoId) { - return false - } - return await launchWorkItemDirect({ - item: { ...item, repoId: targetRepoId, pasteContent: commandInput }, - repoId: targetRepoId, - launchSource: 'task_page', - telemetrySource: 'sidebar', - promptDelivery: 'submit-after-ready', - agentOverride: agent, - agentArgs, - openModalFallback: () => { - toast.error( - translate( - 'auto.components.PullRequestPage.c4c02ea23e', - 'Unable to create a fix workspace automatically.' - ) - ) - } - }) - }, - [item, targetRepoId] - ) - const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) - const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = sortChecksBySeverity(list) - const failedChecks = getBrokenChecks(list) - const counts = getCheckCounts(list) - const summaryLabel = getChecksSummaryLabel(list) - // Why: keying the green tick off `list.length` painted an all-neutral PR green above the words - // "0 of N checks passing"; nothing passed, so it reads unresolved like the checks pill does. - const SummaryIcon = - counts.failing > 0 - ? CHECK_ICON.failure - : counts.needsAction > 0 - ? CHECK_ICON.action_required - : counts.pending > 0 - ? CHECK_ICON.pending - : counts.passing > 0 - ? CHECK_ICON.success - : CircleDashed - const summaryColor = - counts.failing > 0 - ? CHECK_COLOR.failure - : counts.needsAction > 0 - ? CHECK_COLOR.action_required - : counts.pending > 0 - ? CHECK_COLOR.pending - : counts.passing > 0 - ? CHECK_COLOR.success - : 'text-muted-foreground' - const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) - - const handleRefresh = useCallback( - async (expectedContextOwner?: object): Promise => { - if (!canUseChecksRepoContext) { - toast.error( - translate( - 'auto.components.PullRequestPage.c057f2fcb0', - 'Unable to refresh checks without a repository path.' - ) - ) - return null - } - const refreshContextOwner = expectedContextOwner ?? committedChecksContextOwnerRef.current - if (committedChecksContextOwnerRef.current !== refreshContextOwner) { - return null - } - const refreshRequestId = ++nextChecksRefreshRequestIdRef.current - activeChecksRefreshRequestIdRef.current = refreshRequestId - setRefreshingOwner({ contextOwner: refreshContextOwner, requestId: refreshRequestId }) - try { - const nextChecks = (await (runtimeHost - ? callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - prRepo, - noCache: true - }, - { timeoutMs: 30_000 } - ) - : window.api.gh.prChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - prRepo, - noCache: true - }))) as PRCheckDetail[] - if ( - !mountedRef.current || - committedChecksContextOwnerRef.current !== refreshContextOwner || - activeChecksRefreshRequestIdRef.current !== refreshRequestId - ) { - return null - } - setChecksState((current) => - current.contextOwner === refreshContextOwner - ? updateGitHubChecksTabLocalChecks(resetGitHubChecksTabForSource(current), nextChecks) - : current - ) - onChecksUpdated(nextChecks) - return nextChecks - } catch (err) { - if ( - mountedRef.current && - committedChecksContextOwnerRef.current === refreshContextOwner && - activeChecksRefreshRequestIdRef.current === refreshRequestId - ) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.246b2c6456', 'Failed to refresh checks') - ) - } - return null - } finally { - if (activeChecksRefreshRequestIdRef.current === refreshRequestId) { - activeChecksRefreshRequestIdRef.current = null - } - if (mountedRef.current) { - setRefreshingOwner((current) => - current?.requestId === refreshRequestId ? null : current - ) - } - } - }, - [ - canUseChecksRepoContext, - headSha, - item.number, - item.repoId, - mountedRef, - onChecksUpdated, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleRerun = useCallback( - async (failedOnly: boolean): Promise => { - if (!canUseChecksRepoContext || rerunning) { - return - } - const rerunContextOwner = committedChecksContextOwnerRef.current - setRerunningOwner(rerunContextOwner) - try { - const result = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.rerunPRChecks', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - prNumber: item.number, - headSha, - failedOnly, - prRepo - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.rerunPRChecks({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - prNumber: item.number, - headSha, - failedOnly, - prRepo - }) - if (!mountedRef.current || committedChecksContextOwnerRef.current !== rerunContextOwner) { - return - } - if (!result.ok) { - toast.error(result.error) - return - } - toast.success( - result.count === 1 - ? translate('auto.components.PullRequestPage.5963a6a852', 'Check rerun requested') - : translate('auto.components.PullRequestPage.18f2af42ac', 'Check reruns requested') - ) - await handleRefresh(rerunContextOwner) - } catch (err) { - if (mountedRef.current && committedChecksContextOwnerRef.current === rerunContextOwner) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.PullRequestPage.788a782bb0', 'Failed to rerun checks') - ) - } - } finally { - if (mountedRef.current) { - setRerunningOwner((current) => (current === rerunContextOwner ? null : current)) - } - } - }, - [ - canUseChecksRepoContext, - handleRefresh, - headSha, - item.number, - item.repoId, - mountedRef, - prRepo, - runtimeHost, - rerunning, - repoId, - repoPath, - sourceContext - ] - ) - - const handleFixBrokenChecks = useCallback(async (): Promise => { - if (!targetRepoId || fixingChecks) { - return - } - if (failedChecks.length === 0) { - toast.message( - translate('auto.components.PullRequestPage.51c65c0265', 'No broken checks to fix.') - ) - return - } - - const basePrompt = buildFixBrokenChecksPrompt({ - reviewKind: 'PR', - reviewNumber: item.number, - reviewTitle: item.title, - reviewUrl: item.url, - checks: list - }) - setFixingChecks(true) - try { - const started = await startFixChecksAgent({ - item, - repoId: targetRepoId, - basePrompt, - launchSource: 'task_page', - telemetrySource: 'sidebar', - openModalFallback: () => { - setFixChecksComposerPrompt(basePrompt) - } - }) - if (started) { - toast.success( - translate( - 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' - ) - ) - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.error('Failed to start fix checks agent', err) - toast.error( - translate( - 'auto.components.PullRequestPage.98583589c6', - 'Failed to start an AI agent for the broken checks: {{value0}}', - { value0: message } - ) - ) - } finally { - setFixingChecks(false) - } - }, [failedChecks.length, fixingChecks, item, list, targetRepoId]) - - const requestCheckDetails = useCallback( - (check: PRCheckDetail, key: string): void => { - if (!canUseChecksRepoContext || (!check.checkRunId && !check.workflowRunId && !check.url)) { - return - } - const requestId = ++nextCheckDetailsRequestIdRef.current - const commit = (next: Omit): void => { - if (!mountedRef.current) { - return - } - setChecksState((current) => settleGitHubChecksTabDetails(current, key, requestId, next)) - } - setChecksState((current) => beginGitHubChecksTabDetails(current, key, requestId)) - const detailsRequest = withGitHubCheckDetailsTimeout((signal) => - runtimeHost - ? callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.prCheckDetails', - { - repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId), - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }, - { timeoutMs: 30_000, signal } - ) - : window.api.gh.prCheckDetails({ - repoPath: repoPath ?? '', - repoId: repoId ?? undefined, - sourceContext, - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo - }) - ) - void detailsRequest - .then((details) => { - commit({ - loading: false, - details, - error: details - ? null - : translate( - 'auto.components.PullRequestPage.6b1d5ee3e4', - 'No inline details are available for this check.' - ) - }) - }) - .catch((err) => { - commit({ - loading: false, - details: null, - error: - err instanceof Error - ? err.message - : translate( - 'auto.components.PullRequestPage.e04c027d98', - 'Failed to load check details.' - ) - }) - }) - }, - [ - canUseChecksRepoContext, - item.repoId, - mountedRef, - runtimeHost, - prRepo, - repoId, - repoPath, - sourceContext - ] - ) - - const handleToggleCheckDetails = useCallback( - (check: PRCheckDetail): void => { - const key = getCheckDetailsKey(check) - setChecksState((current) => toggleGitHubChecksTabExpandedKey(current, key)) - if (detailsByCheckKey[key]) { - return - } - requestCheckDetails(check, key) - }, - [detailsByCheckKey, requestCheckDetails] - ) - - const refreshAction = ( - - - - - - {translate('auto.components.PullRequestPage.5d0f42766d', 'Refresh checks')} - - - ) - const fixBrokenChecksAction = - failedChecks.length > 0 || fixingChecks ? ( - - - - - - {translate( - 'auto.components.PullRequestPage.0fa8b8faec', - 'Start the default AI agent on these checks' - )} - - - ) : null - const rerunAction = - list.length > 0 || rerunning ? ( - - - - - - void handleRerun(true)} - > - - {translate('auto.components.PullRequestPage.68605516dd', 'Rerun failed checks')} - - void handleRerun(false)}> - - {translate('auto.components.PullRequestPage.54cddd1858', 'Rerun all checks')} - - - - ) : null - const secondaryActions = - variant === 'compact' && !fixBrokenChecksAction ? null : fixBrokenChecksAction || - rerunAction ? ( -
- {fixBrokenChecksAction} - {variant === 'page' ? rerunAction : null} -
- ) : null - const actions = ( -
- {refreshAction} - {fixBrokenChecksAction} - {rerunAction} -
- ) - const compactHeader = ( -
-
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
-
- {translate('auto.components.PullRequestPage.94d95cf1f7', 'Checks')} -
- {list.length > 0 && ( -
- {summaryLabel} -
- )} -
-
-
- {refreshAction} - {list.length > 0 && ( -
- {rerunAction} -
- )} -
-
- {secondaryActions ? ( -
{secondaryActions}
- ) : null} -
- ) - - const renderCheckRow = (check: PRCheckDetail): React.JSX.Element => { - const conclusion = getCheckConclusion(check) - const Icon = CHECK_ICON[conclusion] ?? CircleDashed - const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' - const statusLabel = getCheckStatusLabel(check) - const key = getCheckDetailsKey(check) - const expanded = expandedCheckKey === key - const detailsState = detailsByCheckKey[key] - return ( -
- - {expanded && renderCheckDetails(check, detailsState)} -
- ) - } - - const renderCheckDetails = ( - check: PRCheckDetail, - state: CheckDetailsLoadState | undefined - ): React.JSX.Element => { - const details = state?.details - const openUrl = details?.detailsUrl ?? details?.url ?? check.url - const startedAt = formatCheckTimestamp(details?.startedAt) - const completedAt = formatCheckTimestamp(details?.completedAt) - const detailsStatusCheck: PRCheckDetail = { - ...check, - status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status, - conclusion: - (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion - } - const hasOutput = Boolean(details?.title || details?.summary || details?.text) - const hasAnnotations = (details?.annotations.length ?? 0) > 0 - const hasJobs = (details?.jobs.length ?? 0) > 0 - - return ( -
- {state?.loading && !state.error ? ( -
- - {translate('auto.components.PullRequestPage.d8e82b7f15', 'Loading check details…')} -
- ) : ( -
-
- - {translate('auto.components.PullRequestPage.662bc2998d', 'Status:')}{' '} - {details ? getCheckStatusLabel(detailsStatusCheck) : getCheckStatusLabel(check)} - - {startedAt && ( - - {translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt} - - )} - {completedAt && ( - - {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')}{' '} - {completedAt} - - )} - {check.checkRunId && ( - - {translate('auto.components.PullRequestPage.f01bf79a79', 'check #')} - {check.checkRunId} - - )} -
- - {state?.error && ( -
-
- {state.error} -
- -
- )} - - {hasOutput && ( -
- {details?.title && ( -
- {details.title} -
- )} - {details?.summary && ( - - )} - {details?.text && ( - - )} -
- )} - - {hasAnnotations && ( -
-
- {translate('auto.components.PullRequestPage.8432d17901', 'Annotations')} -
-
- {details!.annotations.map((annotation, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {annotation.path ?? - translate('auto.components.PullRequestPage.35a0573f41', 'Annotation')} - {annotation.startLine ? `:${annotation.startLine}` : ''} - - {annotation.annotationLevel && ( - - {annotation.annotationLevel} - - )} -
- {annotation.title && ( -
- {annotation.title} -
- )} -
- {annotation.message} -
- {annotation.rawDetails && ( -
-                          {annotation.rawDetails}
-                        
- )} -
- ))} -
-
- )} - - {hasJobs && ( -
-
- {translate('auto.components.PullRequestPage.7720c9c3f5', 'Jobs')} -
-
- {details!.jobs.map((job, index) => ( -
0 && 'border-t border-border/30' - )} - > -
- - {job.name} - - - {job.conclusion ?? - job.status ?? - translate('auto.components.PullRequestPage.77d9388fb0', 'unknown')} - -
- {job.steps.length > 0 && ( -
- {job.steps.map((step) => ( -
- {step.name} - {step.conclusion ?? step.status} -
- ))} -
- )} -
- ))} -
-
- )} - - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && ( -
- {getCheckConclusion(check) === 'action_required' - ? translate( - 'auto.components.PullRequestPage.checkActionRequiredHint', - 'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.' - ) - : translate( - 'auto.components.PullRequestPage.1550675e5f', - 'No inline output is available for this check.' - )} -
- )} - - {openUrl && ( -
- -
- )} -
- )} -
- ) - } - - const fixChecksAgentDialog = ( - { - if (!open) { - setFixChecksComposerPrompt(null) - } - }} - actionId="fixChecks" - title={translate('auto.components.PullRequestPage.a053bdd082', 'Fix Broken Checks With AI')} - description={translate( - 'auto.components.PullRequestPage.ddfd42f460', - 'Review the prompt before starting an agent.' - )} - baseCommandInput={fixChecksComposerPrompt ?? ''} - connectionId={repo?.connectionId ?? null} - repoId={targetRepoId} - promptDelivery="submit-after-ready" - launchPlatform={fixChecksLaunchPlatform} - launchSource="task_page" - savedAgentId={readSourceControlLaunchRecipeAgentId(fixChecksRecipe)} - savedCommandInputTemplate={fixChecksRecipe.commandInputTemplate ?? null} - savedAgentArgs={fixChecksRecipe.agentArgs ?? null} - onSaveAgentDefault={saveFixChecksActionDefault} - onLaunched={() => { - toast.success( - translate( - 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' - ) - ) - }} - onStart={handleStartFixChecksFromDialog} - /> - ) - - if (loading && list.length === 0) { - return ( - <> - {variant === 'compact' ? compactHeader : null} -
- -
- - ) - } - if (list.length === 0) { - if (variant === 'page') { - return ( -
-
- -
- - {translate('auto.components.PullRequestPage.45877f5089', 'No checks found')} - - - {translate( - 'auto.components.PullRequestPage.3912daf310', - 'This pull request has no reported checks yet.' - )} - -
- {actions} -
-
- ) - } - return ( - <> - {compactHeader} -
- -
- {translate('auto.components.PullRequestPage.a18d01cda3', 'No checks reported yet')} -
-
- - ) - } - if (variant === 'page') { - const countChips = getCheckCountChips(counts) - return ( - <> -
-
- 0 && counts.failing === 0 && 'animate-spin' - )} - /> -
- - {summaryLabel} - - {countChips.length > 1 && ( - - {countChips.map((chip, i) => ( - - {i > 0 && ·} - {chip.label} - - ))} - - )} -
- {actions} -
-
- {sorted.map((check, index) => ( -
0 && 'border-t border-border/40')} - > - {renderCheckRow(check)} -
- ))} -
-
- {fixChecksAgentDialog} - - ) - } - return ( - <> - {compactHeader} -
- {sorted.map(renderCheckRow)} -
- {fixChecksAgentDialog} - - ) -} - -function MentionTextarea({ - value, - onValueChange, - onKeyDown, - placeholder, - rows, - className, - wrapperClassName, - mentionOptions, - textareaRef -}: { - value: string - onValueChange: (value: string) => void - onKeyDown?: (event: React.KeyboardEvent) => void - placeholder: string - rows: number - className?: string - wrapperClassName?: string - mentionOptions: MentionOption[] - textareaRef: React.RefObject -}): React.JSX.Element { - const [mentionQuery, setMentionQuery] = useState(null) - const [activeIndex, setActiveIndex] = useState(0) - const suggestions = useMemo( - () => (mentionQuery ? filterGitHubMentionOptions(mentionOptions, mentionQuery.query) : []), - [mentionOptions, mentionQuery] - ) - const showSuggestions = mentionQuery !== null && suggestions.length > 0 - - const syncMentionQuery = useCallback((textarea: HTMLTextAreaElement): void => { - const nextQuery = findMentionQuery(textarea.value, textarea.selectionStart) - setMentionQuery(nextQuery) - setActiveIndex(0) - }, []) - - const insertMention = useCallback( - (option: MentionOption): void => { - const textarea = textareaRef.current - const caret = textarea?.selectionStart ?? value.length - const query = textarea ? findMentionQuery(value, caret) : mentionQuery - if (!query) { - return - } - const suffix = value[caret] && !/\s/.test(value[caret]) ? ' ' : '' - const inserted = `@${option.login}${suffix}` - const nextValue = `${value.slice(0, query.atIndex)}${inserted}${value.slice(caret)}` - const nextCaret = query.atIndex + inserted.length - onValueChange(nextValue) - setMentionQuery(null) - requestAnimationFrame(() => { - textarea?.focus() - textarea?.setSelectionRange(nextCaret, nextCaret) - }) - }, - [mentionQuery, onValueChange, textareaRef, value] - ) - - return ( -
- {showSuggestions && ( -
- {suggestions.map((option, index) => ( - - ))} -
- )} -