From bc1feb5a4f2f2c81add5cf1d95826aa4e77e9962 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:13:40 -0700
Subject: [PATCH 01/42] Add search by name, project, and prompt for automations
(#12561)
* Add search by name, project, and prompt for automations
Split the monolithic automations page into focused modules: extract dialog logic, list panel rendering, search functionality, and utility helpers into separate files. Introduce deferred search matching to keep the input responsive, with proper bounds checking to reject oversized pastes. The page stays unfiltered when search is inactive or too large, preserving the original list view in those cases.
* fix(i18n): add missing automation search localization keys
Sync en.json keys used by AutomationListSearchField and the no-matches empty state so static analysis localization catalog check passes.
* Localize remaining automation strings and optimize search
- Add 12 i18n keys for automation labels, counts, and usage display
- Extract AutomationPaneTab and SelectedExternalRunPage types to shared automation-page-state module
- Optimize search fingerprint by truncating prompts to indexed prefix for bounded performance per tick
- Improve escape-key handling in search field to clear input before blurring
- Remove deprecated getAutomationListSearchQuery function
---
.../automations/AutomationDeleteDialogs.tsx | 186 +++
.../AutomationListExternalRows.tsx | 223 +++
.../automations/AutomationListLocalRows.tsx | 245 ++++
.../automations/AutomationListSearchField.tsx | 106 ++
.../automations/AutomationRunHistory.tsx | 2 +-
.../automations/AutomationsDetailPane.tsx | 339 +++++
.../automations/AutomationsListPanel.tsx | 206 +++
.../automations/AutomationsPage.tsx | 1233 +++--------------
.../ExternalAutomationManagers.tsx | 31 +-
.../ExternalAutomationRunTable.tsx | 45 +-
.../automations/automation-draft-model.ts | 52 +
.../automations/automation-host-client.ts | 14 +
.../automation-list-search.test.ts | 186 +++
.../automations/automation-list-search.ts | 244 ++++
.../automations/automation-page-state.ts | 15 +
.../automations/automation-run-content.ts | 17 +
.../automations/automation-run-view-state.ts | 10 +
.../automations/automation-source-context.ts | 56 +
.../external-automation-display.ts | 71 +
.../external-automation-list-entries.ts | 46 +
.../automations/use-automation-list-search.ts | 265 ++++
src/renderer/src/i18n/locales/en.json | 22 +-
22 files changed, 2499 insertions(+), 1115 deletions(-)
create mode 100644 src/renderer/src/components/automations/AutomationDeleteDialogs.tsx
create mode 100644 src/renderer/src/components/automations/AutomationListExternalRows.tsx
create mode 100644 src/renderer/src/components/automations/AutomationListLocalRows.tsx
create mode 100644 src/renderer/src/components/automations/AutomationListSearchField.tsx
create mode 100644 src/renderer/src/components/automations/AutomationsDetailPane.tsx
create mode 100644 src/renderer/src/components/automations/AutomationsListPanel.tsx
create mode 100644 src/renderer/src/components/automations/automation-draft-model.ts
create mode 100644 src/renderer/src/components/automations/automation-list-search.test.ts
create mode 100644 src/renderer/src/components/automations/automation-list-search.ts
create mode 100644 src/renderer/src/components/automations/automation-page-state.ts
create mode 100644 src/renderer/src/components/automations/automation-run-content.ts
create mode 100644 src/renderer/src/components/automations/automation-source-context.ts
create mode 100644 src/renderer/src/components/automations/external-automation-display.ts
create mode 100644 src/renderer/src/components/automations/external-automation-list-entries.ts
create mode 100644 src/renderer/src/components/automations/use-automation-list-search.ts
diff --git a/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx
new file mode 100644
index 00000000000..9921ad0b614
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx
@@ -0,0 +1,186 @@
+import React from 'react'
+import { Check, Trash2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import type {
+ Automation,
+ ExternalAutomationJob,
+ ExternalAutomationManager
+} from '../../../../shared/automations-types'
+import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
+import { getExternalProviderLabel } from './external-automation-display'
+import { translate } from '@/i18n/i18n'
+
+export function AutomationDeleteDialog({
+ deleteTarget,
+ dontAskDeleteAgain,
+ confirmButtonRef,
+ onOpenChange,
+ onDontAskAgainToggle,
+ onCancel,
+ onConfirm
+}: {
+ deleteTarget: Automation | null
+ dontAskDeleteAgain: boolean
+ confirmButtonRef: React.RefObject
+ onOpenChange: (open: boolean) => void
+ onDontAskAgainToggle: () => void
+ onCancel: () => void
+ onConfirm: () => void
+}): React.JSX.Element {
+ return (
+
+ {
+ event.preventDefault()
+ confirmButtonRef.current?.focus()
+ }}
+ >
+
+
+ {translate(
+ 'auto.components.automations.AutomationsPage.080dcb5fbb',
+ 'Delete Automation'
+ )}
+
+
+ {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '}
+ {deleteTarget?.name} {' '}
+ {translate(
+ 'auto.components.automations.AutomationsPage.b264564427',
+ 'and its run history. Workspaces created by previous runs are not deleted.'
+ )}
+
+
+ {deleteTarget ? (
+
+
{deleteTarget.name}
+
+ {deleteTarget.workspaceMode === 'new_per_run'
+ ? translate(
+ 'auto.components.automations.AutomationsPage.cd8397cc32',
+ 'New workspace each run'
+ )
+ : translate(
+ 'auto.components.automations.AutomationsPage.36f71740a7',
+ 'Selected workspace'
+ )}
+
+
+ ) : null}
+
+
+ {dontAskDeleteAgain ? : null}
+
+ {translate('auto.components.automations.AutomationsPage.1e2e41392f', "Don't ask again")}
+
+
+
+ {translate('auto.components.automations.AutomationsPage.73f630b49d', 'Cancel')}
+
+
+
+ {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
+
+
+
+
+ )
+}
+
+export function ExternalAutomationDeleteDialog({
+ externalDeleteTarget,
+ confirmButtonRef,
+ onOpenChange,
+ onCancel,
+ onConfirm
+}: {
+ externalDeleteTarget: {
+ manager: ExternalAutomationManager
+ job: ExternalAutomationJob
+ } | null
+ confirmButtonRef: React.RefObject
+ onOpenChange: (open: boolean) => void
+ onCancel: () => void
+ onConfirm: () => void
+}): React.JSX.Element {
+ return (
+
+ {
+ event.preventDefault()
+ confirmButtonRef.current?.focus()
+ }}
+ >
+
+
+ {translate(
+ 'auto.components.automations.AutomationsPage.9adfab2596',
+ 'Delete External Automation'
+ )}
+
+
+ {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '}
+
+ {externalDeleteTarget?.job.name}
+ {' '}
+ {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '}
+ {externalDeleteTarget
+ ? getExternalProviderLabel(externalDeleteTarget.manager)
+ : translate(
+ 'auto.components.automations.AutomationsPage.8500baacb4',
+ 'external source'
+ )}{' '}
+ {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}{' '}
+ {externalDeleteTarget?.manager.targetLabel}.
+
+
+ {externalDeleteTarget ? (
+
+
+ {externalDeleteTarget.job.name}
+
+
+ {
+ getExternalAutomationScheduleDisplay(
+ externalDeleteTarget.manager,
+ externalDeleteTarget.job
+ ).label
+ }
+
+
+ ) : null}
+
+
+ {translate('auto.components.automations.AutomationsPage.73f630b49d', 'Cancel')}
+
+
+
+ {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx
new file mode 100644
index 00000000000..b5269b65593
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx
@@ -0,0 +1,223 @@
+import React from 'react'
+import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger
+} from '@/components/ui/context-menu'
+import { cn } from '@/lib/utils'
+import type {
+ ExternalAutomationAction,
+ ExternalAutomationJob,
+ ExternalAutomationManager
+} from '../../../../shared/automations-types'
+import type { SshConnectionState } from '../../../../shared/ssh-types'
+import type { ExternalAutomationListEntry } from './external-automation-list-entries'
+import {
+ formatExternalDate,
+ getExternalProviderLabel,
+ getExternalTargetKindLabel
+} from './external-automation-display'
+import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
+import {
+ getExternalAutomationActionDisabledMessage,
+ getExternalAutomationSourceAvailability
+} from './external-automation-source-availability'
+import { translate } from '@/i18n/i18n'
+
+export function AutomationListExternalRows({
+ entries,
+ selectedExternalKey,
+ relativeNow,
+ sshConnectionStates,
+ externalActionKey,
+ onSelect,
+ onRequestAction,
+ onEdit
+}: {
+ entries: readonly ExternalAutomationListEntry[]
+ selectedExternalKey: string | null | undefined
+ relativeNow: number
+ sshConnectionStates: ReadonlyMap>
+ externalActionKey: string | null
+ onSelect: (entryKey: string) => void
+ onRequestAction: (
+ manager: ExternalAutomationManager,
+ job: ExternalAutomationJob,
+ action: ExternalAutomationAction
+ ) => void
+ onEdit: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void
+}): React.JSX.Element {
+ return (
+ <>
+ {entries.map((entry) => {
+ const providerLabel = getExternalProviderLabel(entry.manager)
+ const targetKindLabel = getExternalTargetKindLabel(entry.manager)
+ if (entry.kind === 'source') {
+ const sshStatus =
+ entry.manager.target.type === 'ssh'
+ ? sshConnectionStates.get(entry.manager.target.connectionId)?.status
+ : undefined
+ const sourceAvailability = getExternalAutomationSourceAvailability({
+ manager: entry.manager,
+ providerLabel,
+ targetKindLabel,
+ sshStatus
+ })
+ return (
+ onSelect(entry.key)}
+ className={cn(
+ 'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
+ selectedExternalKey === entry.key
+ ? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
+ : 'border-transparent hover:bg-muted/50'
+ )}
+ >
+
+
+
+ {entry.manager.targetLabel}
+
+
+
+ {providerLabel}{' '}
+ {translate('auto.components.automations.AutomationsPage.82eb6cb933', 'source')}
+
+ /
+ {targetKindLabel}
+
+
+ {sourceAvailability.summary}
+
+
+
+
+ {sourceAvailability.statusLabel}
+
+
+ )
+ }
+ const nextRunLabel = entry.job.enabled
+ ? formatExternalDate(entry.job.nextRunAt, relativeNow)
+ : translate('auto.components.automations.AutomationsPage.paused', 'Paused')
+ const entrySshStatus =
+ entry.manager.target.type === 'ssh'
+ ? sshConnectionStates.get(entry.manager.target.connectionId)?.status
+ : undefined
+ const disabledMessage = getExternalAutomationActionDisabledMessage({
+ manager: entry.manager,
+ providerLabel,
+ targetKindLabel,
+ sshStatus: entrySshStatus,
+ actionInProgress: externalActionKey !== null
+ })
+ const actionDisabled = disabledMessage !== null
+ const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job)
+ return (
+
+
+ onSelect(entry.key)}
+ className={cn(
+ 'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
+ selectedExternalKey === entry.key
+ ? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
+ : 'border-transparent hover:bg-muted/50'
+ )}
+ >
+
+
+
+ {entry.job.name}
+
+
+ {scheduleDisplay.label}
+
+
+
+ {providerLabel} / {entry.manager.targetLabel}
+
+ ·
+
+ {entry.manager.provider === 'hermes'
+ ? translate(
+ 'auto.components.automations.AutomationsPage.runCount',
+ '{{count}} runs',
+ { count: entry.job.runCount }
+ )
+ : entry.manager.canManage
+ ? translate(
+ 'auto.components.automations.AutomationsPage.aecdc3681f',
+ 'Manageable'
+ )
+ : translate(
+ 'auto.components.automations.AutomationsPage.e059042585',
+ 'Read-only'
+ )}
+
+
+
+
+
+ {nextRunLabel}
+
+
+
+
+ onRequestAction(entry.manager, entry.job, 'run')}
+ >
+
+
+ {disabledMessage ??
+ translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
+
+
+ {entry.manager.provider === 'hermes' ? (
+ onEdit(entry.manager, entry.job)}
+ >
+
+ {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
+
+ ) : null}
+
+ onRequestAction(entry.manager, entry.job, entry.job.enabled ? 'pause' : 'resume')
+ }
+ >
+ {entry.job.enabled ? : }
+ {entry.job.enabled
+ ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
+ : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
+
+
+ onRequestAction(entry.manager, entry.job, 'delete')}
+ >
+
+ {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
+
+
+
+ )
+ })}
+ >
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx
new file mode 100644
index 00000000000..1a5a4994aac
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx
@@ -0,0 +1,245 @@
+import React from 'react'
+import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger
+} from '@/components/ui/context-menu'
+import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
+import { cn } from '@/lib/utils'
+import type { Automation, AutomationRun } from '../../../../shared/automations-types'
+import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
+import { formatAutomationSchedule } from '../../../../shared/automation-schedules'
+import type { SshConnectionState } from '../../../../shared/ssh-types'
+import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
+import type { RuntimeStatus } from '../../../../shared/runtime-types'
+import type { TaskSourceHostAvailability } from '../task-source-context-summary'
+import type { AutomationHostTarget } from './automation-host-client'
+import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
+import {
+ formatAutomationCost,
+ formatAutomationTokens,
+ summarizeAutomationRunUsage
+} from './automation-usage-model'
+import { getAutomationTargetAvailability } from './automation-target-availability'
+import { getAgentLabel } from './automation-draft-model'
+import { translate } from '@/i18n/i18n'
+
+export function AutomationListLocalRows({
+ automations,
+ selectedId,
+ isSelectedLocal,
+ runs,
+ relativeNow,
+ repoMap,
+ worktreeMap,
+ projectHostSetups,
+ sshConnectionStates,
+ runtimeStatusByEnvironmentId,
+ automationHostTarget,
+ automationSourceHostAvailabilityById,
+ onSelect,
+ onRunNow,
+ onEdit,
+ onToggle,
+ onDelete
+}: {
+ automations: readonly Automation[]
+ selectedId: string | null | undefined
+ isSelectedLocal: boolean
+ runs: readonly AutomationRun[]
+ relativeNow: number
+ repoMap: ReadonlyMap
+ worktreeMap: ReadonlyMap
+ projectHostSetups: readonly ProjectHostSetup[]
+ sshConnectionStates: ReadonlyMap>
+ runtimeStatusByEnvironmentId: ReadonlyMap<
+ string,
+ { status: RuntimeStatus | null; checkedAt: number }
+ >
+ automationHostTarget: AutomationHostTarget | null
+ automationSourceHostAvailabilityById: ReadonlyMap
+ onSelect: (automationId: string) => void
+ onRunNow: (automation: Automation) => void
+ onEdit: (automation: Automation) => void
+ onToggle: (automation: Automation) => void
+ onDelete: (automation: Automation) => void
+}): React.JSX.Element {
+ // Why: one pass over runs instead of a full scan per rendered automation —
+ // this list re-renders on the relativeNow timer.
+ const runsByAutomationId = React.useMemo(() => {
+ const grouped = new Map()
+ for (const run of runs) {
+ const existing = grouped.get(run.automationId)
+ if (existing) {
+ existing.push(run)
+ } else {
+ grouped.set(run.automationId, [run])
+ }
+ }
+ return grouped
+ }, [runs])
+ return (
+ <>
+ {automations.map((automation) => {
+ const automationRepo = repoMap.get(getAutomationRunRepoId(automation))
+ const automationWorktree = automation.workspaceId
+ ? worktreeMap.get(automation.workspaceId)
+ : null
+ const automationRunAvailability = getAutomationTargetAvailability({
+ automation,
+ repo: automationRepo,
+ workspace: automationWorktree,
+ projectHostSetups,
+ sshConnectionStates,
+ runtimeStatusByEnvironmentId,
+ automationHostTarget,
+ sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id)
+ })
+ const baseRefLabel =
+ automation.baseBranch ??
+ automationRepo?.worktreeBaseRef ??
+ translate(
+ 'auto.components.automations.AutomationsPage.projectDefaultBaseRef',
+ 'project default'
+ )
+ const workspaceLabel =
+ automation.workspaceMode === 'new_per_run'
+ ? translate(
+ 'auto.components.automations.AutomationsPage.createFromBaseRef',
+ 'Create from {{baseRef}}',
+ { baseRef: baseRefLabel }
+ )
+ : (automationWorktree?.displayName ??
+ translate(
+ 'auto.components.automations.AutomationsPage.missingWorkspace',
+ 'Missing workspace'
+ ))
+ const usageSummary = summarizeAutomationRunUsage(
+ runsByAutomationId.get(automation.id) ?? []
+ )
+ const usageText =
+ usageSummary.knownRuns > 0
+ ? translate(
+ 'auto.components.automations.AutomationsPage.runUsageSummary',
+ '{{cost}} est. · {{tokens}} tokens',
+ {
+ cost: formatAutomationCost(usageSummary.estimatedCostUsd),
+ tokens: formatAutomationTokens(usageSummary.totalTokens)
+ }
+ )
+ : usageSummary.unavailableRuns > 0
+ ? translate(
+ 'auto.components.automations.AutomationsPage.usageUnavailable',
+ 'Usage unavailable'
+ )
+ : translate(
+ 'auto.components.automations.AutomationsPage.noRunUsageYet',
+ 'No run usage yet'
+ )
+ const nextRunLabel = automation.enabled
+ ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
+ : translate('auto.components.automations.AutomationsPage.paused', 'Paused')
+ const scheduleLabel = formatAutomationSchedule(automation.rrule)
+ return (
+
+
+ onSelect(automation.id)}
+ className={cn(
+ 'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
+ isSelectedLocal && selectedId === automation.id
+ ? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
+ : 'border-transparent hover:bg-muted/50'
+ )}
+ >
+
+
+
+ {automation.name}
+
+
+ {scheduleLabel}
+
+
+ {automationRepo ? (
+
+ ) : (
+
+ {translate(
+ 'auto.components.automations.AutomationsPage.13118faadf',
+ 'Unknown project'
+ )}
+
+ )}
+ /
+ {workspaceLabel}
+ ·
+ {getAgentLabel(automation.agentId)}
+
+
+ {usageText}
+
+
+
+
+ {nextRunLabel}
+
+
+
+
+ {
+ if (!automationRunAvailability.canRunNow) {
+ event.preventDefault()
+ return
+ }
+ onRunNow(automation)
+ }}
+ >
+
+
+ {automationRunAvailability.canRunNow
+ ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
+ : automationRunAvailability.message}
+
+
+ onEdit(automation)}>
+
+ {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
+
+ onToggle(automation)}>
+ {automation.enabled ? (
+
+ ) : (
+
+ )}
+ {automation.enabled
+ ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
+ : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
+
+
+ onDelete(automation)}>
+
+ {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
+
+
+
+ )
+ })}
+ >
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx
new file mode 100644
index 00000000000..3a2bceb7c48
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx
@@ -0,0 +1,106 @@
+import React, { useRef } from 'react'
+import { Search, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { translate } from '@/i18n/i18n'
+import { cn } from '@/lib/utils'
+
+type AutomationListSearchFieldProps = {
+ query: string
+ isTooLarge: boolean
+ onQueryChange: (query: string) => void
+ onClear: () => void
+ className?: string
+}
+
+export function AutomationListSearchField({
+ query,
+ isTooLarge,
+ onQueryChange,
+ onClear,
+ className
+}: AutomationListSearchFieldProps): React.JSX.Element {
+ const inputRef = useRef(null)
+ const hasText = query !== ''
+ const tooLargeMessage = isTooLarge
+ ? translate(
+ 'auto.components.automations.AutomationListSearchField.tooLong',
+ 'Search text is too long — list is unfiltered'
+ )
+ : null
+
+ return (
+
+
+
onQueryChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key !== 'Escape' || event.nativeEvent.isComposing) {
+ return
+ }
+ if (!hasText) {
+ return
+ }
+ event.preventDefault()
+ onClear()
+ }}
+ />
+ {hasText ? (
+
+ {isTooLarge ? (
+
+ {translate(
+ 'auto.components.automations.AutomationListSearchField.tooLongShort',
+ 'Too long'
+ )}
+
+ ) : null}
+ event.preventDefault()}
+ onClick={() => {
+ onClear()
+ inputRef.current?.focus()
+ }}
+ >
+
+
+
+ ) : null}
+ {isTooLarge ? (
+
+ {tooLargeMessage}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx
index be8a76dcd5e..777eaff3fa2 100644
--- a/src/renderer/src/components/automations/AutomationRunHistory.tsx
+++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx
@@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n'
type AutomationRunHistoryProps = {
runs: AutomationRun[]
automationId: string
- worktreeMap: Map
+ worktreeMap: ReadonlyMap
onOpenRun: (run: AutomationRun) => void
}
diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx
new file mode 100644
index 00000000000..2d290f26ac6
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx
@@ -0,0 +1,339 @@
+import React from 'react'
+import { Eye, RefreshCw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { cn } from '@/lib/utils'
+import type {
+ Automation,
+ ExternalAutomationAction,
+ ExternalAutomationJob,
+ ExternalAutomationManager,
+ ExternalAutomationRun,
+ AutomationRun
+} from '../../../../shared/automations-types'
+import type { Worktree } from '../../../../shared/types'
+import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
+import { AutomationDetail } from './AutomationDetail'
+import { HermesCronOutputView } from './HermesCronOutputView'
+import { AutomationRunPageFrame } from './AutomationRunPageFrame'
+import { AutomationRunHistory } from './AutomationRunHistory'
+import { ExternalAutomationManagers } from './ExternalAutomationManagers'
+import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
+import type { ExternalAutomationListEntry } from './external-automation-list-entries'
+import {
+ formatExternalDate,
+ getExternalProviderLabel,
+ getExternalRunContent,
+ getExternalRunStatusLabel,
+ getExternalRunStatusVariant
+} from './external-automation-display'
+import {
+ formatAutomationDateTimeWithRelative,
+ getAutomationRunStatusLabel,
+ getAutomationRunStatusVariant
+} from './automation-page-parts'
+import { getAutomationRunContent } from './automation-run-content'
+import type { AutomationTargetAvailability } from './automation-target-availability'
+import type { AutomationRunViewState } from './automation-run-view-state'
+import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
+import type { ExternalAutomationSourceAvailability } from './external-automation-source-availability'
+import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
+import { translate } from '@/i18n/i18n'
+
+type AutomationsDetailPaneProps = {
+ selected: Automation | null
+ selectedExternal: ExternalAutomationListEntry | null
+ selectedExternalRunPage: SelectedExternalRunPage | null
+ selectedAutomationRunPage: AutomationRun | null
+ selectedRuns: AutomationRun[]
+ activePaneTab: AutomationPaneTab
+ relativeNow: number
+ externalActionKey: string | null
+ selectedRepoDisplayName: string
+ selectedRepoDefaultBaseRef: string | null
+ selectedWorkspaceName: string
+ hostLabelById: ReadonlyMap
+ selectedRunNowAvailability: AutomationTargetAvailability | null
+ selectedExternalSourceAvailability: ExternalAutomationSourceAvailability | null
+ selectedExternalSshSource: {
+ manager: ExternalAutomationManager
+ } | null
+ selectedExternalSshConnected: boolean
+ selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null
+ selectedAutomationRunPageViewState: AutomationRunViewState | null
+ canRerunSelectedAutomationRunPage: boolean
+ isSelectedAutomationRunPageRerunPending: boolean
+ worktreeMap: ReadonlyMap
+ fetchExternalAutomationRuns: FetchExternalAutomationRuns
+ onActivePaneTabChange: (tab: AutomationPaneTab) => void
+ onClearExternalRunPage: () => void
+ onClearAutomationRunPage: () => void
+ requestExternalAction: (
+ manager: ExternalAutomationManager,
+ job: ExternalAutomationJob,
+ action: ExternalAutomationAction
+ ) => void
+ openExternalRunPage: (
+ manager: ExternalAutomationManager,
+ job: ExternalAutomationJob,
+ run: ExternalAutomationRun
+ ) => void
+ openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void
+ connectExternalAutomationSource: (manager: ExternalAutomationManager) => void
+ runNow: (automation: Automation) => void
+ openEditDialog: (automation: Automation) => void
+ toggleAutomation: (automation: Automation) => void
+ requestDeleteAutomation: (automation: Automation) => void
+ rerunAutomationRun: (automation: Automation, run: AutomationRun) => void
+ openRunWorkspace: (run: AutomationRun) => void
+ openAutomationRunPage: (run: AutomationRun) => void
+}
+
+export function AutomationsDetailPane({
+ selected,
+ selectedExternal,
+ selectedExternalRunPage,
+ selectedAutomationRunPage,
+ selectedRuns,
+ activePaneTab,
+ relativeNow,
+ externalActionKey,
+ selectedRepoDisplayName,
+ selectedRepoDefaultBaseRef,
+ selectedWorkspaceName,
+ hostLabelById,
+ selectedRunNowAvailability,
+ selectedExternalSourceAvailability,
+ selectedExternalSshSource,
+ selectedExternalSshConnected,
+ selectedAutomationRunPageWorkspaceDisplay,
+ selectedAutomationRunPageViewState,
+ canRerunSelectedAutomationRunPage,
+ isSelectedAutomationRunPageRerunPending,
+ worktreeMap,
+ fetchExternalAutomationRuns,
+ onActivePaneTabChange,
+ onClearExternalRunPage,
+ onClearAutomationRunPage,
+ requestExternalAction,
+ openExternalRunPage,
+ openEditExternalDialog,
+ connectExternalAutomationSource,
+ runNow,
+ openEditDialog,
+ toggleAutomation,
+ requestDeleteAutomation,
+ rerunAutomationRun,
+ openRunWorkspace,
+ openAutomationRunPage
+}: AutomationsDetailPaneProps): React.JSX.Element {
+ return (
+
+ {selectedExternal ? (
+
+ {selectedExternalRunPage ? (
+
+
+
+ ) : selectedExternal.kind === 'job' ? (
+
+ ) : (
+
+
+
+
+ {selectedExternal.manager.targetLabel}
+
+
+ {selectedExternalSourceAvailability?.summary}
+
+
+ {selectedExternalSshSource ? (
+
+ void connectExternalAutomationSource(selectedExternalSshSource.manager)
+ }
+ >
+ {selectedExternalSourceAvailability?.isConnecting ? (
+
+ ) : null}
+ {selectedExternalSourceAvailability?.isConnecting
+ ? translate(
+ 'auto.components.automations.AutomationsPage.f93ed7a6f8',
+ 'Connecting...'
+ )
+ : selectedExternalSshConnected
+ ? translate(
+ 'auto.components.automations.AutomationsPage.53f06f0ad5',
+ 'Retry source'
+ )
+ : translate(
+ 'auto.components.automations.AutomationsPage.7934ee0d81',
+ 'Connect SSH'
+ )}
+
+ ) : null}
+
+
+ {selectedExternalSourceAvailability?.detail}
+
+
+ )}
+
+ ) : (
+ onActivePaneTabChange(value as AutomationPaneTab)}
+ className="min-h-0 flex-1 gap-0"
+ >
+
+
+
+ {translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')}
+
+
+ {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '}
+ {selectedRuns.length}
+
+
+
+
+
+ void runNow(automation)}
+ onEdit={(automation) => void openEditDialog(automation)}
+ onToggle={(automation) => void toggleAutomation(automation)}
+ onDelete={requestDeleteAutomation}
+ />
+
+
+
+ {selectedAutomationRunPage ? (
+
+ {canRerunSelectedAutomationRunPage && selected ? (
+ void rerunAutomationRun(selected, selectedAutomationRunPage)}
+ >
+
+ {translate(
+ 'auto.components.automations.AutomationsPage.295698292f',
+ 'Rerun'
+ )}
+
+ ) : null}
+ {selectedAutomationRunPageViewState ? (
+ openRunWorkspace(selectedAutomationRunPage)}
+ >
+
+ {selectedAutomationRunPageViewState.actionLabel}
+
+ ) : null}
+ >
+ }
+ onBack={onClearAutomationRunPage}
+ >
+
+
+ ) : selected ? (
+
+ ) : (
+
+ {translate(
+ 'auto.components.automations.AutomationsPage.c3a28c9793',
+ 'Select an automation to view runs.'
+ )}
+
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx
new file mode 100644
index 00000000000..31d9cc9d6d9
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx
@@ -0,0 +1,206 @@
+import React from 'react'
+import { Plus } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import type {
+ Automation,
+ AutomationRun,
+ ExternalAutomationAction,
+ ExternalAutomationJob,
+ ExternalAutomationManager
+} from '../../../../shared/automations-types'
+import type { SshConnectionState } from '../../../../shared/ssh-types'
+import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
+import type { RuntimeStatus } from '../../../../shared/runtime-types'
+import type { TaskSourceHostAvailability } from '../task-source-context-summary'
+import type { AutomationHostTarget } from './automation-host-client'
+import { clampAutomationListSearchQueryInput } from './automation-list-search'
+import type { AutomationPaneTab } from './automation-page-state'
+import { AutomationListSearchField } from './AutomationListSearchField'
+import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
+import type { ExternalAutomationListEntry } from './external-automation-list-entries'
+import { AutomationListLocalRows } from './AutomationListLocalRows'
+import { AutomationListExternalRows } from './AutomationListExternalRows'
+import { translate } from '@/i18n/i18n'
+
+type AutomationsListPanelProps = {
+ hasListItems: boolean
+ hasFilteredListItems: boolean
+ isListSearchActive: boolean
+ listSearchQuery: string
+ isListSearchQueryTooLarge: boolean
+ onListSearchQueryChange: (query: string) => void
+ filteredAutomations: readonly Automation[]
+ filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
+ selected: Automation | null
+ selectedExternal: ExternalAutomationListEntry | null
+ runs: readonly AutomationRun[]
+ relativeNow: number
+ repoMap: ReadonlyMap
+ worktreeMap: ReadonlyMap
+ projectHostSetups: readonly ProjectHostSetup[]
+ sshConnectionStates: ReadonlyMap>
+ runtimeStatusByEnvironmentId: ReadonlyMap<
+ string,
+ { status: RuntimeStatus | null; checkedAt: number }
+ >
+ automationHostTarget: AutomationHostTarget | null
+ automationSourceHostAvailabilityById: ReadonlyMap
+ externalActionKey: string | null
+ selectAutomationId: (automationId: string | null) => void
+ selectExternalKey: (externalKey: string | null) => void
+ setActivePaneTab: (tab: AutomationPaneTab) => void
+ runNow: (automation: Automation) => void
+ openEditDialog: (automation: Automation) => void
+ toggleAutomation: (automation: Automation) => void
+ requestDeleteAutomation: (automation: Automation) => void
+ requestExternalAction: (
+ manager: ExternalAutomationManager,
+ job: ExternalAutomationJob,
+ action: ExternalAutomationAction
+ ) => void
+ openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void
+ openCreateDialog: (template?: AutomationTemplate) => void
+}
+
+export function AutomationsListPanel({
+ hasListItems,
+ hasFilteredListItems,
+ isListSearchActive,
+ listSearchQuery,
+ isListSearchQueryTooLarge,
+ onListSearchQueryChange,
+ filteredAutomations,
+ filteredExternalAutomationEntries,
+ selected,
+ selectedExternal,
+ runs,
+ relativeNow,
+ repoMap,
+ worktreeMap,
+ projectHostSetups,
+ sshConnectionStates,
+ runtimeStatusByEnvironmentId,
+ automationHostTarget,
+ automationSourceHostAvailabilityById,
+ externalActionKey,
+ selectAutomationId,
+ selectExternalKey,
+ setActivePaneTab,
+ runNow,
+ openEditDialog,
+ toggleAutomation,
+ requestDeleteAutomation,
+ requestExternalAction,
+ openEditExternalDialog,
+ openCreateDialog
+}: AutomationsListPanelProps): React.JSX.Element {
+ return (
+
+ {hasListItems ? (
+
+
+ onListSearchQueryChange(clampAutomationListSearchQueryInput(query))
+ }
+ onClear={() => onListSearchQueryChange('')}
+ />
+
+ ) : null}
+
+ {hasFilteredListItems ? (
+
+
+ {translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')}
+
+
+ {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')}
+
+
+ ) : null}
+
{
+ selectExternalKey(null)
+ selectAutomationId(automationId)
+ }}
+ onRunNow={runNow}
+ onEdit={openEditDialog}
+ onToggle={toggleAutomation}
+ onDelete={requestDeleteAutomation}
+ />
+ {
+ selectExternalKey(entryKey)
+ setActivePaneTab('overview')
+ }}
+ onRequestAction={requestExternalAction}
+ onEdit={openEditExternalDialog}
+ />
+ {hasListItems && isListSearchActive && !hasFilteredListItems ? (
+
+ {translate(
+ 'auto.components.automations.AutomationsPage.noSearchMatches',
+ 'No automations match your search.'
+ )}
+
+ ) : null}
+ {!hasListItems ? (
+
+
+ {translate(
+ 'auto.components.automations.AutomationsPage.d207ab4c25',
+ 'Start from a template'
+ )}
+
+ {getAutomationTemplates().map((template) => (
+
openCreateDialog(template)}
+ className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ >
+
+ {template.category}
+
+ {template.label}
+
+ {template.description}
+
+
+ ))}
+
openCreateDialog()}
+ >
+
+ {translate('auto.components.automations.AutomationsPage.25060635c6', 'Add new')}
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx
index d55ee012dae..97f15d844a6 100644
--- a/src/renderer/src/components/automations/AutomationsPage.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.tsx
@@ -1,46 +1,16 @@
/* eslint-disable max-lines -- Why: this page owns the automations list/detail
- * orchestration while the form and detail presentation live in sibling files. */
+ * orchestration while the form, list, and detail presentation live in sibling files. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import {
- CalendarClock,
- Check,
- Clock,
- Eye,
- Pause,
- Pencil,
- Play,
- Plus,
- RefreshCw,
- Trash2,
- X
-} from 'lucide-react'
+import { CalendarClock, Plus, RefreshCw, X } from 'lucide-react'
import { toast } from 'sonner'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
-import type { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger
-} from '@/components/ui/context-menu'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from '@/components/ui/dialog'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
import { cn } from '@/lib/utils'
-import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { useRepoMap, useWorktreeMap } from '@/store/selectors'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
@@ -50,7 +20,6 @@ import type {
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun,
- AutomationPrecheck,
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
@@ -61,34 +30,20 @@ import {
parseExecutionHostId
} from '../../../../shared/execution-host'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
-import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { PreflightStatus } from '../../../../preload/api-types'
-import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
-import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types'
+import type { OrcaHooks, Repo } from '../../../../shared/types'
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id'
import {
- buildAutomationCronSchedule,
buildAutomationRrule,
- formatAutomationSchedule,
isValidAutomationCronSchedule,
isValidAutomationSchedule,
tryParseAutomationRrule
} from '../../../../shared/automation-schedules'
-import {
- formatAutomationDateTimeWithRelative,
- getAutomationRunStatusLabel,
- getAutomationRunStatusVariant
-} from './automation-page-parts'
-import {
- formatAutomationCost,
- formatAutomationTokens,
- summarizeAutomationRunUsage
-} from './automation-usage-model'
import {
canRerunAutomationRun,
- getAutomationRerunPendingRemainingMs,
- getAutomationRunViewState
+ getAutomationRunViewState,
+ waitForAutomationRerunPendingVisibility
} from './automation-run-view-state'
import {
automationRunMatchesPaneKey,
@@ -98,22 +53,17 @@ import {
resolveAutomationRunOpenTarget
} from './automation-run-open-target'
import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
-import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
-import { AutomationDetail } from './AutomationDetail'
-import { HermesCronOutputView } from './HermesCronOutputView'
import {
AutomationEditorDialog,
type AutomationCreateTarget,
type AutomationDraft
} from './AutomationEditorDialog'
-import { AutomationRunPageFrame } from './AutomationRunPageFrame'
-import { AutomationRunHistory } from './AutomationRunHistory'
import {
getAutomationSetupDecisionDraftValue,
getVisibleAutomationSetupDecision,
resolveAutomationSetupDecisionForSave
} from './automation-setup-decision'
-import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
+import type { AutomationTemplate } from './automation-templates'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { buildAutomationRunContextForRepo } from './automation-run-context'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
@@ -125,14 +75,14 @@ import {
} from '../task-source-provider-availability'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import {
- getExternalAutomationActionDisabledMessage,
getExternalAutomationSourceAvailability,
isSshConnectionBusy
} from './external-automation-source-availability'
import {
createAutomationForTarget,
deleteAutomationForTarget,
- type AutomationHostTarget,
+ getAutomationHostTargetFromKey,
+ getAutomationHostTargetKey,
getAutomationListTarget,
getAutomationOwnerTarget,
getAutomationTargetFromHostId,
@@ -141,223 +91,38 @@ import {
runAutomationNowForTarget,
updateAutomationForTarget
} from './automation-host-client'
-import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
-import { ExternalAutomationManagers } from './ExternalAutomationManagers'
import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable'
+import {
+ AUTOMATION_DEFAULT_TIME,
+ buildDraftPrecheck,
+ buildHermesCronSchedule,
+ formatTimeInput,
+ getDefaultWorktree,
+ parseDraftTime
+} from './automation-draft-model'
+import {
+ getRepoBackedAutomationSourceContext,
+ getRuntimeSourceHostAvailability,
+ type RepoBackedAutomationSourceContext
+} from './automation-source-context'
+import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
+import {
+ getExternalAutomationKey,
+ getExternalAutomationSourceKey,
+ getExternalProviderLabel,
+ getExternalTargetKindLabel,
+ isMissingExternalRunsApiError
+} from './external-automation-display'
+import { buildExternalAutomationListEntries } from './external-automation-list-entries'
+import { useAutomationListSearch } from './use-automation-list-search'
+import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs'
+import { AutomationsListPanel } from './AutomationsListPanel'
+import { AutomationsDetailPane } from './AutomationsDetailPane'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { translate } from '@/i18n/i18n'
const AGENTS = getAgentCatalog().map((agent) => agent.id)
-const DEFAULT_TIME = '09:00'
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
-type AutomationPaneTab = 'overview' | 'runs'
-type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' }
-
-type ExternalAutomationListEntry =
- | {
- kind: 'job'
- key: string
- manager: ExternalAutomationManager
- job: ExternalAutomationJob
- }
- | {
- kind: 'source'
- key: string
- manager: ExternalAutomationManager
- }
-
-type SelectedExternalRunPage = {
- manager: ExternalAutomationManager
- job: ExternalAutomationJob
- run: ExternalAutomationRun
-}
-
-function getAutomationHostTargetKey(target: AutomationHostTarget): string {
- return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local'
-}
-
-function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null {
- if (!key) {
- return null
- }
- if (key.startsWith('environment:')) {
- return { kind: 'environment', environmentId: key.slice('environment:'.length) }
- }
- return { kind: 'local' }
-}
-
-function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null {
- return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null
-}
-
-function getRepoBackedAutomationSourceContext(
- automation: Automation
-): RepoBackedAutomationSourceContext | null {
- const context = automation.sourceContext
- return context?.provider === 'github' || context?.provider === 'gitlab'
- ? (context as RepoBackedAutomationSourceContext)
- : null
-}
-
-function getRuntimeSourceHostAvailability(
- context: TaskSourceContext,
- runtimeStatusByEnvironmentId: ReadonlyMap<
- string,
- { status: RuntimeStatus | null; checkedAt: number }
- >
-): TaskSourceHostAvailability | null {
- const parsed = parseExecutionHostId(context.hostId)
- if (parsed?.kind !== 'runtime') {
- return null
- }
- const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId)
- if (!entry) {
- return { hostId: context.hostId, reason: 'checking-task-source-capability' }
- }
- if (!entry.status) {
- return { hostId: context.hostId, health: 'disconnected' }
- }
- if (entry.status.graphStatus !== 'ready') {
- return { hostId: context.hostId, health: 'connecting' }
- }
- const capabilities = entry.status.capabilities
- if (!capabilities) {
- return { hostId: context.hostId, reason: 'checking-task-source-capability' }
- }
- if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) {
- return { hostId: context.hostId, reason: 'missing-task-source-capability' }
- }
- return null
-}
-
-function formatTimeInput(hour: number, minute: number): string {
- return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
-}
-
-function parseDraftTime(time: string): { hour: number; minute: number } {
- const [rawHour, rawMinute] = time.split(':').map((part) => Number(part))
- return {
- hour: Number.isFinite(rawHour) ? rawHour : 9,
- minute: Number.isFinite(rawMinute) ? rawMinute : 0
- }
-}
-
-function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null {
- const command = draft.precheckCommand.trim()
- if (!command) {
- return null
- }
- const rawTimeout = Number(draft.precheckTimeoutSeconds)
- return {
- command,
- timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60
- }
-}
-
-function buildHermesCronSchedule(draft: AutomationDraft): string {
- if (draft.preset === 'custom') {
- return draft.customSchedule.trim()
- }
- const { hour, minute } = parseDraftTime(draft.time)
- return buildAutomationCronSchedule({
- preset: draft.preset,
- hour,
- minute,
- dayOfWeek: Number(draft.dayOfWeek)
- })
-}
-
-function getAgentLabel(agentId: string): string {
- return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId
-}
-
-function getExternalAutomationKey(
- manager: ExternalAutomationManager,
- job: ExternalAutomationJob
-): string {
- return `${manager.id}:${job.id}`
-}
-
-function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string {
- return `${manager.id}:source`
-}
-
-function formatExternalDate(value: string | null, now: number): string {
- if (!value) {
- return 'Never'
- }
- const parsed = Date.parse(value)
- if (!Number.isFinite(parsed)) {
- return value
- }
- return formatAutomationDateTimeWithRelative(parsed, now)
-}
-
-function getExternalProviderLabel(manager: ExternalAutomationManager): string {
- return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'
-}
-
-function getExternalTargetKindLabel(manager: ExternalAutomationManager): string {
- return manager.target.type === 'ssh' ? 'SSH host' : 'Local'
-}
-
-function getExternalRunStatusLabel(run: ExternalAutomationRun): string {
- switch (run.status) {
- case 'completed':
- return 'Completed'
- case 'failed':
- return 'Failed'
- case 'unknown':
- return 'Unknown'
- }
-}
-
-function getExternalRunStatusVariant(
- run: ExternalAutomationRun
-): React.ComponentProps['variant'] {
- switch (run.status) {
- case 'completed':
- return 'secondary'
- case 'failed':
- return 'destructive'
- case 'unknown':
- return 'outline'
- }
-}
-
-function getExternalRunContent(run: ExternalAutomationRun): string {
- return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.'
-}
-
-function getAutomationRunContent(run: AutomationRun): string {
- const savedOutput = run.outputSnapshot?.content.trim()
- if (savedOutput) {
- return run.outputSnapshot?.content ?? savedOutput
- }
- if (run.precheckResult) {
- const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()]
- .filter(Boolean)
- .join('\n\n')
- if (output) {
- return output
- }
- }
- return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.'
-}
-
-function isMissingExternalRunsApiError(error: unknown): boolean {
- const message = error instanceof Error ? error.message : String(error)
- return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message)
-}
-
-async function waitForAutomationRerunPendingVisibility(pendingStartedAt: number): Promise {
- const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt })
- if (remainingMs <= 0) {
- return
- }
- await new Promise((resolve) => window.setTimeout(resolve, remainingMs))
-}
-
export default function AutomationsPage(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
@@ -415,6 +180,7 @@ export default function AutomationsPage(): React.JSX.Element {
)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
+ const [listSearchQuery, setListSearchQuery] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createTarget, setCreateTarget] = useState('orca')
const [editingAutomationId, setEditingAutomationId] = useState(null)
@@ -463,6 +229,9 @@ export default function AutomationsPage(): React.JSX.Element {
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
const editRequestRef = useRef(0)
const deleteConfirmButtonRef = useRef(null)
+ // Why: both dialogs stay mounted, so a shared ref would let one dialog's
+ // unmount clear the focus target the other still needs.
+ const externalDeleteConfirmButtonRef = useRef(null)
const completionInFlightRef = useRef>(new Set())
const rerunRunIdsInFlightRef = useRef>(new Set())
const workspaceNameCacheRef = useRef>(new Map())
@@ -488,40 +257,35 @@ export default function AutomationsPage(): React.JSX.Element {
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: 'weekdays',
- time: DEFAULT_TIME,
+ time: AUTOMATION_DEFAULT_TIME,
dayOfWeek: '1',
customSchedule: '',
missedRunGraceMinutes: '720',
scheduleWarning: null
})
- const externalAutomationEntries = useMemo(
- () =>
- externalManagers.flatMap((manager): ExternalAutomationListEntry[] => {
- if (manager.jobs.length === 0) {
- if (
- manager.provider === 'hermes' &&
- (manager.status === 'unavailable' || manager.error)
- ) {
- return [
- {
- kind: 'source' as const,
- key: getExternalAutomationSourceKey(manager),
- manager
- }
- ]
- }
- return []
- }
- return manager.jobs.map((job) => ({
- kind: 'job' as const,
- key: getExternalAutomationKey(manager, job),
- manager,
- job
- }))
- }),
+ const externalAutomationEntries = useMemo(
+ () => buildExternalAutomationListEntries(externalManagers),
[externalManagers]
)
+ const {
+ isListSearchQueryTooLarge,
+ isListSearchActive,
+ filteredAutomations,
+ filteredExternalAutomationEntries,
+ hasListItems,
+ hasFilteredListItems
+ } = useAutomationListSearch({
+ listSearchQuery,
+ automations,
+ externalAutomationEntries,
+ repoMap,
+ selectedId,
+ selectedExternalKey,
+ selectAutomationId,
+ selectExternalKey
+ })
+
const selectedExternal =
externalAutomationEntries.find((entry) => entry.key === selectedExternalKey) ??
(automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null)
@@ -769,7 +533,10 @@ export default function AutomationsPage(): React.JSX.Element {
: null
const canRerunSelectedAutomationRunPage =
selectedAutomationRunPage !== null &&
- canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage })
+ canRerunAutomationRun({
+ automation: selected,
+ run: selectedAutomationRunPage
+ })
const isSelectedAutomationRunPageRerunPending =
selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id)
const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey
@@ -1298,7 +1065,7 @@ export default function AutomationsPage(): React.JSX.Element {
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: 'weekdays',
- time: DEFAULT_TIME,
+ time: AUTOMATION_DEFAULT_TIME,
dayOfWeek: '1',
customSchedule: '',
missedRunGraceMinutes: '720',
@@ -1356,7 +1123,7 @@ export default function AutomationsPage(): React.JSX.Element {
precheckCommand: latest.precheck?.command ?? '',
precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60),
preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'),
- time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : DEFAULT_TIME,
+ time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME,
dayOfWeek: String(schedule?.dayOfWeek ?? 1),
customSchedule: hasCustomSchedule ? latest.rrule : '',
missedRunGraceMinutes: String(latest.missedRunGraceMinutes),
@@ -1404,7 +1171,7 @@ export default function AutomationsPage(): React.JSX.Element {
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: hasCustomSchedule ? 'custom' : 'weekdays',
- time: DEFAULT_TIME,
+ time: AUTOMATION_DEFAULT_TIME,
dayOfWeek: '1',
customSchedule: hasCustomSchedule ? rawSchedule : '',
missedRunGraceMinutes: '720',
@@ -2009,7 +1776,9 @@ export default function AutomationsPage(): React.JSX.Element {
)
return
}
- const state = await window.api.ssh.connect({ targetId: manager.target.connectionId })
+ const state = await window.api.ssh.connect({
+ targetId: manager.target.connectionId
+ })
if (!state || state.status !== 'connected') {
toast.error(
state?.error ??
@@ -2107,6 +1876,12 @@ export default function AutomationsPage(): React.JSX.Element {
return
}
+ // Why: fields that clear their own value on Escape consume this press;
+ // blurring here would drop focus and let the next Escape close the page.
+ if (target.dataset.escapeClearsValue === 'true') {
+ return
+ }
+
// Why: match Tasks page behavior: Esc first exits field focus, then exits
// the page once focus is back on page chrome.
if (
@@ -2232,8 +2007,10 @@ export default function AutomationsPage(): React.JSX.Element {
onSave={() => void saveAutomation()}
/>
- {
if (open) {
return
@@ -2241,766 +2018,116 @@ export default function AutomationsPage(): React.JSX.Element {
setDeleteTarget(null)
setDontAskDeleteAgain(false)
}}
- >
- {
- event.preventDefault()
- deleteConfirmButtonRef.current?.focus()
- }}
- >
-
-
- {translate(
- 'auto.components.automations.AutomationsPage.080dcb5fbb',
- 'Delete Automation'
- )}
-
-
- {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '}
- {deleteTarget?.name} {' '}
- {translate(
- 'auto.components.automations.AutomationsPage.b264564427',
- 'and its run history. Workspaces created by previous runs are not deleted.'
- )}
-
-
- {deleteTarget ? (
-
-
{deleteTarget.name}
-
- {deleteTarget.workspaceMode === 'new_per_run'
- ? translate(
- 'auto.components.automations.AutomationsPage.cd8397cc32',
- 'New workspace each run'
- )
- : translate(
- 'auto.components.automations.AutomationsPage.36f71740a7',
- 'Selected workspace'
- )}
-
-
- ) : null}
- setDontAskDeleteAgain((prev) => !prev)}
- className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- >
-
- {dontAskDeleteAgain ? : null}
-
- {translate('auto.components.automations.AutomationsPage.1e2e41392f', "Don't ask again")}
-
-
- {
- setDeleteTarget(null)
- setDontAskDeleteAgain(false)
- }}
- >
- {translate('auto.components.automations.AutomationsPage.73f630b49d', 'Cancel')}
-
- void confirmDeleteAutomation()}
- >
-
- {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
-
-
-
-
+ onDontAskAgainToggle={() => setDontAskDeleteAgain((prev) => !prev)}
+ onCancel={() => {
+ setDeleteTarget(null)
+ setDontAskDeleteAgain(false)
+ }}
+ onConfirm={() => void confirmDeleteAutomation()}
+ />
- {
if (!open) {
setExternalDeleteTarget(null)
}
}}
- >
- {
- event.preventDefault()
- deleteConfirmButtonRef.current?.focus()
- }}
- >
-
-
- {translate(
- 'auto.components.automations.AutomationsPage.9adfab2596',
- 'Delete External Automation'
- )}
-
-
- {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '}
-
- {externalDeleteTarget?.job.name}
- {' '}
- {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '}
- {externalDeleteTarget
- ? getExternalProviderLabel(externalDeleteTarget.manager)
- : translate(
- 'auto.components.automations.AutomationsPage.8500baacb4',
- 'external source'
- )}{' '}
- {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}
- {externalDeleteTarget?.manager.targetLabel}.
-
-
- {externalDeleteTarget ? (
-
-
- {externalDeleteTarget.job.name}
-
-
- {
- getExternalAutomationScheduleDisplay(
- externalDeleteTarget.manager,
- externalDeleteTarget.job
- ).label
- }
-
-
- ) : null}
-
- setExternalDeleteTarget(null)}>
- {translate('auto.components.automations.AutomationsPage.73f630b49d', 'Cancel')}
-
- void confirmDeleteExternalAutomation()}
- >
-
- {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
-
-
-
-
+ onCancel={() => setExternalDeleteTarget(null)}
+ onConfirm={() => void confirmDeleteExternalAutomation()}
+ />
-
-
- {automations.length + externalAutomationEntries.length > 0 ? (
-
-
- {translate(
- 'auto.components.automations.AutomationsPage.761a35834d',
- 'Automation'
- )}
-
-
- {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')}
-
-
- ) : null}
- {automations.map((automation) => {
- const automationRepo = repoMap.get(getAutomationRunRepoId(automation))
- const automationWorktree = automation.workspaceId
- ? worktreeMap.get(automation.workspaceId)
- : null
- const automationRunAvailability = getAutomationTargetAvailability({
- automation,
- repo: automationRepo,
- workspace: automationWorktree,
- projectHostSetups,
- sshConnectionStates,
- runtimeStatusByEnvironmentId,
- automationHostTarget,
- sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id)
- })
- const workspaceLabel =
- automation.workspaceMode === 'new_per_run'
- ? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}`
- : (automationWorktree?.displayName ?? 'Missing workspace')
- const usageSummary = summarizeAutomationRunUsage(
- runs.filter((run) => run.automationId === automation.id)
- )
- const usageText =
- usageSummary.knownRuns > 0
- ? `${formatAutomationCost(
- usageSummary.estimatedCostUsd
- )} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens`
- : usageSummary.unavailableRuns > 0
- ? 'Usage unavailable'
- : 'No run usage yet'
- const nextRunLabel = automation.enabled
- ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
- : 'Paused'
- const scheduleLabel = formatAutomationSchedule(automation.rrule)
- return (
-
-
- {
- selectExternalKey(null)
- selectAutomationId(automation.id)
- }}
- className={cn(
- 'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
- selectedExternal === null && selected?.id === automation.id
- ? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
- : 'border-transparent hover:bg-muted/50'
- )}
- >
-
-
-
- {automation.name}
-
-
- {scheduleLabel}
-
-
- {automationRepo ? (
-
- ) : (
-
- {translate(
- 'auto.components.automations.AutomationsPage.13118faadf',
- 'Unknown project'
- )}
-
- )}
- /
- {workspaceLabel}
- ·
- {getAgentLabel(automation.agentId)}
-
-
- {usageText}
-
-
-
-
- {nextRunLabel}
-
-
-
-
- {
- if (!automationRunAvailability.canRunNow) {
- event.preventDefault()
- return
- }
- void runNow(automation)
- }}
- >
-
-
- {automationRunAvailability.canRunNow
- ? translate(
- 'auto.components.automations.AutomationsPage.2faecab10b',
- 'Run Now'
- )
- : automationRunAvailability.message}
-
-
- void openEditDialog(automation)}>
-
- {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
-
- void toggleAutomation(automation)}>
- {automation.enabled ? (
-
- ) : (
-
- )}
- {automation.enabled
- ? translate(
- 'auto.components.automations.AutomationsPage.b457436d6a',
- 'Pause'
- )
- : translate(
- 'auto.components.automations.AutomationsPage.376631ef2b',
- 'Resume'
- )}
-
-
- requestDeleteAutomation(automation)}
- >
-
- {translate(
- 'auto.components.automations.AutomationsPage.15e0bfb13b',
- 'Delete'
- )}
-
-
-
- )
- })}
- {externalAutomationEntries.map((entry) => {
- const providerLabel = getExternalProviderLabel(entry.manager)
- const targetKindLabel = getExternalTargetKindLabel(entry.manager)
- if (entry.kind === 'source') {
- const sshStatus =
- entry.manager.target.type === 'ssh'
- ? sshConnectionStates.get(entry.manager.target.connectionId)?.status
- : undefined
- const sourceAvailability = getExternalAutomationSourceAvailability({
- manager: entry.manager,
- providerLabel,
- targetKindLabel,
- sshStatus
- })
- return (
-
{
- selectExternalKey(entry.key)
- setActivePaneTab('overview')
- }}
- className={cn(
- 'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
- selectedExternal?.key === entry.key
- ? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
- : 'border-transparent hover:bg-muted/50'
- )}
- >
-
-
-
- {entry.manager.targetLabel}
-
-
-
- {providerLabel}{' '}
- {translate(
- 'auto.components.automations.AutomationsPage.82eb6cb933',
- 'source'
- )}
-
- /
- {targetKindLabel}
-
-
- {sourceAvailability.summary}
-
-
-
-
- {sourceAvailability.statusLabel}
-
-
+
void runNow(automation)}
+ openEditDialog={(automation) => void openEditDialog(automation)}
+ toggleAutomation={(automation) => void toggleAutomation(automation)}
+ requestDeleteAutomation={requestDeleteAutomation}
+ requestExternalAction={requestExternalAction}
+ openEditExternalDialog={openEditExternalDialog}
+ openCreateDialog={openCreateDialog}
+ />
+
+
-
- {
- selectExternalKey(entry.key)
- setActivePaneTab('overview')
- }}
- className={cn(
- 'mb-1 grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors',
- selectedExternal?.key === entry.key
- ? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
- : 'border-transparent hover:bg-muted/50'
- )}
- >
-
-
-
- {entry.job.name}
-
-
- {scheduleDisplay.label}
-
-
-
- {providerLabel} / {entry.manager.targetLabel}
-
- ·
-
- {entry.manager.provider === 'hermes'
- ? `${entry.job.runCount} ${entry.job.runCount === 1 ? 'run' : 'runs'}`
- : entry.manager.canManage
- ? translate(
- 'auto.components.automations.AutomationsPage.aecdc3681f',
- 'Manageable'
- )
- : translate(
- 'auto.components.automations.AutomationsPage.e059042585',
- 'Read-only'
- )}
-
-
-
-
-
- {nextRunLabel}
-
-
-
-
- requestExternalAction(entry.manager, entry.job, 'run')}
- >
-
-
- {disabledMessage ??
- translate(
- 'auto.components.automations.AutomationsPage.2faecab10b',
- 'Run Now'
- )}
-
-
- {entry.manager.provider === 'hermes' ? (
- openEditExternalDialog(entry.manager, entry.job)}
- >
-
- {translate(
- 'auto.components.automations.AutomationsPage.f4612e3f78',
- 'Edit'
- )}
-
- ) : null}
-
- requestExternalAction(
- entry.manager,
- entry.job,
- entry.job.enabled ? 'pause' : 'resume'
- )
- }
- >
- {entry.job.enabled ? (
-
- ) : (
-
- )}
- {entry.job.enabled
- ? translate(
- 'auto.components.automations.AutomationsPage.b457436d6a',
- 'Pause'
- )
- : translate(
- 'auto.components.automations.AutomationsPage.376631ef2b',
- 'Resume'
- )}
-
-
- requestExternalAction(entry.manager, entry.job, 'delete')}
- >
-
- {translate(
- 'auto.components.automations.AutomationsPage.15e0bfb13b',
- 'Delete'
- )}
-
-
-
- )
- })}
- {automations.length === 0 && externalAutomationEntries.length === 0 ? (
-
-
- {translate(
- 'auto.components.automations.AutomationsPage.d207ab4c25',
- 'Start from a template'
- )}
-
- {getAutomationTemplates().map((template) => (
-
openCreateDialog(template)}
- className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
- >
-
- {template.category}
-
- {template.label}
-
- {template.description}
-
-
- ))}
-
openCreateDialog()}
- >
-
- {translate('auto.components.automations.AutomationsPage.25060635c6', 'Add new')}
-
-
- ) : null}
-
-
-
-
- {selectedExternal ? (
-
- {selectedExternalRunPage ? (
-
setSelectedExternalRunPage(null)}
- >
-
-
- ) : selectedExternal.kind === 'job' ? (
-
- ) : (
-
-
-
-
- {selectedExternal.manager.targetLabel}
-
-
- {selectedExternalSourceAvailability?.summary}
-
-
- {selectedExternalSshSource ? (
-
- void connectExternalAutomationSource(selectedExternalSshSource.manager)
- }
- >
- {selectedExternalSourceAvailability?.isConnecting ? (
-
- ) : null}
- {selectedExternalSourceAvailability?.isConnecting
- ? translate(
- 'auto.components.automations.AutomationsPage.f93ed7a6f8',
- 'Connecting...'
- )
- : selectedExternalSshConnected
- ? translate(
- 'auto.components.automations.AutomationsPage.53f06f0ad5',
- 'Retry source'
- )
- : translate(
- 'auto.components.automations.AutomationsPage.7934ee0d81',
- 'Connect SSH'
- )}
-
- ) : null}
-
-
- {selectedExternalSourceAvailability?.detail}
-
-
- )}
-
- ) : (
- setActivePaneTab(value as AutomationPaneTab)}
- className="min-h-0 flex-1 gap-0"
- >
-
-
-
- {translate(
- 'auto.components.automations.AutomationsPage.bb1b2cd31e',
- 'Overview'
- )}
-
-
- {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '}
- {selectedRuns.length}
-
-
-
-
-
- void runNow(automation)}
- onEdit={(automation) => void openEditDialog(automation)}
- onToggle={(automation) => void toggleAutomation(automation)}
- onDelete={requestDeleteAutomation}
- />
-
-
-
- {selectedAutomationRunPage ? (
-
- {canRerunSelectedAutomationRunPage && selected ? (
-
- void rerunAutomationRun(selected, selectedAutomationRunPage)
- }
- >
-
- {translate(
- 'auto.components.automations.AutomationsPage.295698292f',
- 'Rerun'
- )}
-
- ) : null}
- {selectedAutomationRunPageViewState ? (
- openRunWorkspace(selectedAutomationRunPage)}
- >
-
- {selectedAutomationRunPageViewState.actionLabel}
-
- ) : null}
- >
- }
- onBack={() => setSelectedAutomationRunPageId(null)}
- >
-
-
- ) : selected ? (
-
- ) : (
-
- {translate(
- 'auto.components.automations.AutomationsPage.c3a28c9793',
- 'Select an automation to view runs.'
- )}
-
- )}
-
-
- )}
-
+ : (selectedWorktree?.displayName ??
+ translate(
+ 'auto.components.automations.AutomationsPage.missingWorkspace',
+ 'Missing workspace'
+ ))
+ }
+ hostLabelById={hostLabelById}
+ selectedRunNowAvailability={selectedRunNowAvailability}
+ selectedExternalSourceAvailability={selectedExternalSourceAvailability}
+ selectedExternalSshSource={
+ selectedExternalSshSource ? { manager: selectedExternalSshSource.manager } : null
+ }
+ selectedExternalSshConnected={selectedExternalSshConnected}
+ selectedAutomationRunPageWorkspaceDisplay={selectedAutomationRunPageWorkspaceDisplay}
+ selectedAutomationRunPageViewState={selectedAutomationRunPageViewState}
+ canRerunSelectedAutomationRunPage={canRerunSelectedAutomationRunPage}
+ isSelectedAutomationRunPageRerunPending={isSelectedAutomationRunPageRerunPending}
+ worktreeMap={worktreeMap}
+ fetchExternalAutomationRuns={fetchExternalAutomationRuns}
+ onActivePaneTabChange={setActivePaneTab}
+ onClearExternalRunPage={() => setSelectedExternalRunPage(null)}
+ onClearAutomationRunPage={() => setSelectedAutomationRunPageId(null)}
+ requestExternalAction={requestExternalAction}
+ openExternalRunPage={openExternalRunPage}
+ openEditExternalDialog={openEditExternalDialog}
+ connectExternalAutomationSource={(manager) =>
+ void connectExternalAutomationSource(manager)
+ }
+ runNow={(automation) => void runNow(automation)}
+ openEditDialog={(automation) => void openEditDialog(automation)}
+ toggleAutomation={(automation) => void toggleAutomation(automation)}
+ requestDeleteAutomation={requestDeleteAutomation}
+ rerunAutomationRun={(automation, run) => void rerunAutomationRun(automation, run)}
+ openRunWorkspace={openRunWorkspace}
+ openAutomationRunPage={openAutomationRunPage}
+ />
)
diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx
index bd6e1b67549..f64684f8c29 100644
--- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx
+++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx
@@ -10,7 +10,11 @@ import type {
ExternalAutomationManager,
ExternalAutomationRun
} from '../../../../shared/automations-types'
-import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
+import {
+ formatExternalDate,
+ getExternalProviderLabel,
+ getExternalTargetKindLabel
+} from './external-automation-display'
import {
ExternalAutomationRunTable,
type FetchExternalAutomationRuns
@@ -37,17 +41,6 @@ type ExternalAutomationManagersProps = {
onEdit?: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void
}
-function formatExternalDate(value: string | null, now: number): string {
- if (!value) {
- return 'Never'
- }
- const parsed = Date.parse(value)
- if (!Number.isFinite(parsed)) {
- return value
- }
- return formatAutomationDateTimeWithRelative(parsed, now)
-}
-
function actionKey(
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
@@ -56,14 +49,6 @@ function actionKey(
return `${manager.id}:${job.id}:${action}`
}
-function getProviderLabel(manager: ExternalAutomationManager): string {
- return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'
-}
-
-function getTargetKindLabel(manager: ExternalAutomationManager): string {
- return manager.target.type === 'ssh' ? 'SSH host' : 'Local'
-}
-
function ExternalActionButton({
label,
disabled,
@@ -140,7 +125,7 @@ export function ExternalAutomationManagers({
{manager.targetLabel}
- {getProviderLabel(manager)} / {getTargetKindLabel(manager)} ·{' '}
+ {getExternalProviderLabel(manager)} / {getExternalTargetKindLabel(manager)} ·{' '}
{manager.status === 'available'
? manager.canManage
? translate(
@@ -236,8 +221,8 @@ export function ExternalAutomationManagers({
'auto.components.automations.ExternalAutomationManagers.20fd7a3a15',
'next'
)}{' '}
- {formatExternalDate(job.nextRunAt, now)} · {getProviderLabel(manager)} /{' '}
- {manager.targetLabel}
+ {formatExternalDate(job.nextRunAt, now)} ·{' '}
+ {getExternalProviderLabel(manager)} / {manager.targetLabel}
{manager.provider === 'hermes' ? (
diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx
index f6e5f693371..2c322737cc8 100644
--- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx
+++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx
@@ -10,7 +10,11 @@ import type {
ExternalAutomationManager,
ExternalAutomationRun
} from '../../../../shared/automations-types'
-import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
+import {
+ formatExternalDate,
+ getExternalRunStatusLabel,
+ getExternalRunStatusVariant
+} from './external-automation-display'
import {
createExternalAutomationRunTableState,
resolveExternalAutomationFetchedRuns,
@@ -41,41 +45,6 @@ type ExternalAutomationRunTableProps = {
onOpenRun?: (run: ExternalAutomationRun) => void
}
-function formatExternalDate(value: string | null, now: number): string {
- if (!value) {
- return 'Never'
- }
- const parsed = Date.parse(value)
- if (!Number.isFinite(parsed)) {
- return value
- }
- return formatAutomationDateTimeWithRelative(parsed, now)
-}
-
-function getRunStatusLabel(run: ExternalAutomationRun): string {
- switch (run.status) {
- case 'completed':
- return 'Completed'
- case 'failed':
- return 'Failed'
- case 'unknown':
- return 'Unknown'
- }
-}
-
-function getRunStatusVariant(
- run: ExternalAutomationRun
-): React.ComponentProps['variant'] {
- switch (run.status) {
- case 'completed':
- return 'secondary'
- case 'failed':
- return 'destructive'
- case 'unknown':
- return 'outline'
- }
-}
-
function getRunSummary(run: ExternalAutomationRun): string {
return run.error ?? run.outputPreview ?? 'No output preview'
}
@@ -263,7 +232,9 @@ export function ExternalAutomationRunTable({
{getRunSummary(run)}
- {getRunStatusLabel(run)}
+
+ {getExternalRunStatusLabel(run)}
+
))}
diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts
new file mode 100644
index 00000000000..747bc615782
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-draft-model.ts
@@ -0,0 +1,52 @@
+import { getAgentCatalog } from '@/lib/agent-catalog'
+import type { AutomationPrecheck } from '../../../../shared/automations-types'
+import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules'
+import type { Worktree } from '../../../../shared/types'
+import type { AutomationDraft } from './AutomationEditorDialog'
+
+export const AUTOMATION_DEFAULT_TIME = '09:00'
+
+export function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null {
+ return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null
+}
+
+export function formatTimeInput(hour: number, minute: number): string {
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
+}
+
+export function parseDraftTime(time: string): { hour: number; minute: number } {
+ const [rawHour, rawMinute] = time.split(':').map((part) => Number(part))
+ return {
+ hour: Number.isFinite(rawHour) ? rawHour : 9,
+ minute: Number.isFinite(rawMinute) ? rawMinute : 0
+ }
+}
+
+export function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null {
+ const command = draft.precheckCommand.trim()
+ if (!command) {
+ return null
+ }
+ const rawTimeout = Number(draft.precheckTimeoutSeconds)
+ return {
+ command,
+ timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60
+ }
+}
+
+export function buildHermesCronSchedule(draft: AutomationDraft): string {
+ if (draft.preset === 'custom') {
+ return draft.customSchedule.trim()
+ }
+ const { hour, minute } = parseDraftTime(draft.time)
+ return buildAutomationCronSchedule({
+ preset: draft.preset,
+ hour,
+ minute,
+ dayOfWeek: Number(draft.dayOfWeek)
+ })
+}
+
+export function getAgentLabel(agentId: string): string {
+ return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId
+}
diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts
index b3569b4b7b2..36e1ff0560a 100644
--- a/src/renderer/src/components/automations/automation-host-client.ts
+++ b/src/renderer/src/components/automations/automation-host-client.ts
@@ -26,6 +26,20 @@ export type AutomationHostTarget =
| { kind: 'local' }
| { kind: 'environment'; environmentId: string }
+export function getAutomationHostTargetKey(target: AutomationHostTarget): string {
+ return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local'
+}
+
+export function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null {
+ if (!key) {
+ return null
+ }
+ if (key.startsWith('environment:')) {
+ return { kind: 'environment', environmentId: key.slice('environment:'.length) }
+ }
+ return { kind: 'local' }
+}
+
export function getAutomationTargetFromHostId(
hostId: string | null | undefined
): AutomationHostTarget {
diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts
new file mode 100644
index 00000000000..47f59faaba6
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-list-search.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from 'vitest'
+import {
+ AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS,
+ AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES,
+ AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT,
+ automationListSearchFieldsMatch,
+ automationListSearchIndexMatches,
+ buildAutomationListSearchFingerprint,
+ buildAutomationListSearchIndex,
+ buildAutomationProjectSearchText,
+ clampAutomationListSearchQueryInput,
+ filterByActiveAutomationListSearchQuery,
+ filterByAutomationListSearch,
+ filterByAutomationListSearchIndex,
+ getActiveAutomationListSearchQuery,
+ isAutomationListSearchQueryTooLarge,
+ normalizeAutomationListSearchField,
+ resolveAutomationListSearchQuery,
+ truncateAutomationListSearchField
+} from './automation-list-search'
+
+describe('automation-list-search', () => {
+ it('normalizes query casing and whitespace', () => {
+ expect(getActiveAutomationListSearchQuery(' Auto PR ')).toBe('auto pr')
+ expect(resolveAutomationListSearchQuery(' Auto PR ')).toEqual({
+ status: 'active',
+ query: 'auto pr'
+ })
+ })
+
+ it('rejects oversized queries without searching', () => {
+ const oversized = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1)
+ expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true)
+ expect(getActiveAutomationListSearchQuery(oversized)).toBeNull()
+ expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' })
+ expect(
+ automationListSearchFieldsMatch(
+ { name: 'Auto PR', project: 'orca', prompt: 'nudge' },
+ oversized
+ )
+ ).toBe(false)
+
+ const items = [
+ { id: '1', name: 'Auto PR', project: 'orca', prompt: 'nudge' },
+ { id: '2', name: 'Nightly', project: 'mobile', prompt: 'ship' }
+ ]
+ // Why: oversized paste must leave the list unfiltered, not blank it.
+ expect(filterByAutomationListSearch(items, oversized, (item) => item)).toBe(items)
+ })
+
+ it('rejects queries over the byte limit but under the code-unit limit', () => {
+ // 3 UTF-8 bytes per character, so half the cap in code units is over it in bytes.
+ const multiByte = '한'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES / 2)
+ expect(multiByte.length).toBeLessThanOrEqual(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES)
+ expect(isAutomationListSearchQueryTooLarge(multiByte)).toBe(true)
+ expect(resolveAutomationListSearchQuery(multiByte)).toEqual({ status: 'too_large' })
+ })
+
+ it('treats whitespace-only queries as inactive (no search work)', () => {
+ expect(getActiveAutomationListSearchQuery(' \t ')).toBeNull()
+ expect(resolveAutomationListSearchQuery(' ')).toEqual({ status: 'inactive' })
+ const items = [{ name: 'A', project: 'p1', prompt: 'one' }]
+ expect(filterByAutomationListSearch(items, ' ', (item) => item)).toBe(items)
+ })
+
+ it('clamps stored query input so multi-MB pastes are discarded', () => {
+ const hugePaste = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES * 100)
+ const clamped = clampAutomationListSearchQueryInput(hugePaste)
+ expect(clamped.length).toBe(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1)
+ expect(isAutomationListSearchQueryTooLarge(clamped)).toBe(true)
+ expect(clampAutomationListSearchQueryInput('auto pr')).toBe('auto pr')
+ })
+
+ it('caps indexed field length so huge prompts stay bounded', () => {
+ const prompt = `${'x'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS)}unique-tail-token`
+ const index = buildAutomationListSearchIndex({
+ name: 'Nightly',
+ project: 'mobile',
+ prompt
+ })
+ expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS)
+ expect(automationListSearchIndexMatches(index, 'unique-tail-token')).toBe(false)
+ expect(automationListSearchIndexMatches(index, 'xxxx')).toBe(true)
+ })
+
+ it('null-safely normalizes missing fields', () => {
+ expect(normalizeAutomationListSearchField(null, 10)).toBe('')
+ expect(normalizeAutomationListSearchField(undefined, 10)).toBe('')
+ expect(
+ buildAutomationListSearchIndex({
+ name: 'Job',
+ project: 'host',
+ prompt: null as unknown as string
+ }).prompt
+ ).toBe('')
+ })
+
+ it('does not split surrogate pairs when truncating', () => {
+ const emoji = '😀'
+ const value = `${'a'.repeat(7)}${emoji}`
+ expect(truncateAutomationListSearchField(value, 8)).toBe('a'.repeat(7))
+ expect(truncateAutomationListSearchField(value, 9)).toBe(value)
+ })
+
+ it('indexes unknown project fallback for missing repos', () => {
+ expect(buildAutomationProjectSearchText({})).toBe(AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT)
+ expect(buildAutomationProjectSearchText({ displayName: ' ', path: null })).toBe(
+ AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT
+ )
+ expect(buildAutomationProjectSearchText({ displayName: 'orca', path: '/tmp/orca' })).toBe(
+ 'orca /tmp/orca'
+ )
+ const index = buildAutomationListSearchIndex({
+ name: 'Orphan',
+ project: buildAutomationProjectSearchText({}),
+ prompt: 'hi'
+ })
+ expect(automationListSearchIndexMatches(index, 'unknown')).toBe(true)
+ })
+
+ it('matches name, project, or prompt', () => {
+ const fields = {
+ name: 'Auto PR assignment',
+ project: 'orca / main',
+ prompt: 'Assign reviewers for open PRs'
+ }
+ expect(automationListSearchFieldsMatch(fields, 'assignment')).toBe(true)
+ expect(automationListSearchFieldsMatch(fields, 'ORCA')).toBe(true)
+ expect(automationListSearchFieldsMatch(fields, 'reviewers')).toBe(true)
+ expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false)
+ })
+
+ it('filters by active query without re-resolving bounds', () => {
+ const items = [
+ { id: '1', name: 'Auto Issue assignment', project: 'orca', prompt: 'triage issues' },
+ { id: '2', name: 'Nightly deploy', project: 'mobile', prompt: 'ship apk' },
+ { id: '3', name: 'PR nudge', project: 'orca', prompt: 'remind reviewers' }
+ ]
+ const indexes = items.map((item) =>
+ buildAutomationListSearchIndex({
+ name: item.name,
+ project: item.project,
+ prompt: item.prompt
+ })
+ )
+ expect(
+ filterByActiveAutomationListSearchQuery(items, indexes, 'apk').map((item) => item.id)
+ ).toEqual(['2'])
+ expect(
+ filterByAutomationListSearchIndex(items, indexes, 'orca').map((item) => item.id)
+ ).toEqual(['1', '3'])
+ expect(filterByAutomationListSearchIndex(items, indexes, ' ')).toBe(items)
+ expect(
+ filterByAutomationListSearchIndex(
+ items,
+ indexes,
+ 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1)
+ )
+ ).toBe(items)
+ // Why: a desynchronized index must leave the list unfiltered, not blank it.
+ expect(
+ filterByActiveAutomationListSearchQuery(items, indexes.slice(0, 1), 'apk').map(
+ (item) => item.id
+ )
+ ).toEqual(['1', '2', '3'])
+ })
+
+ it('builds a stable fingerprint from search sources only', () => {
+ const sources = [
+ { name: 'A', project: 'p1', prompt: 'one' },
+ { name: 'B', project: 'p2', prompt: 'two' }
+ ]
+ expect(buildAutomationListSearchFingerprint(sources)).toBe(
+ buildAutomationListSearchFingerprint([
+ { name: 'A', project: 'p1', prompt: 'one' },
+ { name: 'B', project: 'p2', prompt: 'two' }
+ ])
+ )
+ expect(buildAutomationListSearchFingerprint(sources)).not.toBe(
+ buildAutomationListSearchFingerprint([
+ { name: 'A', project: 'p1', prompt: 'changed' },
+ { name: 'B', project: 'p2', prompt: 'two' }
+ ])
+ )
+ })
+})
diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts
new file mode 100644
index 00000000000..b27da8fe221
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-list-search.ts
@@ -0,0 +1,244 @@
+import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
+
+/** Pasted queries above this are rejected so filtering never runs on unbounded input. */
+export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024
+
+// Why: prompts can be multi-MB agent instructions. Index only a fixed prefix so
+// lowercasing/includes stay O(bound) per automation rather than O(prompt size).
+export const AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS = 512
+export const AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS = 1_024
+export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 8 * 1024
+
+/** Indexed when a local automation has no resolved project so "unknown" still matches. */
+export const AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT = 'unknown project'
+
+export type AutomationListSearchFields = {
+ name: string
+ project: string
+ prompt: string
+}
+
+/** Lowercased, length-capped fields ready for substring match. */
+export type AutomationListSearchIndex = {
+ name: string
+ project: string
+ prompt: string
+}
+
+export type AutomationListSearchQueryResolution =
+ | { status: 'inactive' }
+ | { status: 'too_large' }
+ | { status: 'active'; query: string }
+
+export function isAutomationListSearchQueryTooLarge(
+ rawQuery: string,
+ maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES
+): boolean {
+ return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)
+}
+
+/**
+ * Caps the controlled input value so a multi-MB paste cannot pin renderer
+ * memory. Keeping maxBytes+1 code units is enough for the over-limit check
+ * (`length > maxBytes`) while discarding the rest of the paste.
+ */
+export function clampAutomationListSearchQueryInput(
+ rawQuery: string,
+ maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES
+): string {
+ const maxStoredCodeUnits = maxBytes + 1
+ if (rawQuery.length <= maxStoredCodeUnits) {
+ return rawQuery
+ }
+ return rawQuery.slice(0, maxStoredCodeUnits)
+}
+
+export function resolveAutomationListSearchQuery(
+ rawQuery: string,
+ maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES
+): AutomationListSearchQueryResolution {
+ // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan.
+ if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) {
+ return { status: 'too_large' }
+ }
+ const query = rawQuery.trim().toLowerCase()
+ if (!query) {
+ return { status: 'inactive' }
+ }
+ return { status: 'active', query }
+}
+
+/** Active lowercase query, or null when search must not run. */
+export function getActiveAutomationListSearchQuery(
+ rawQuery: string,
+ maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES
+): string | null {
+ const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes)
+ return resolved.status === 'active' ? resolved.query : null
+}
+
+/** Avoid splitting a surrogate pair at the cap boundary. */
+export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string {
+ if (value.length <= maxCodeUnits) {
+ return value
+ }
+ let end = maxCodeUnits
+ const last = value.charCodeAt(end - 1)
+ // High surrogate at the cut would leave an orphan low surrogate.
+ if (last >= 0xd800 && last <= 0xdbff) {
+ end -= 1
+ }
+ return value.slice(0, end)
+}
+
+export function normalizeAutomationListSearchField(
+ value: string | null | undefined,
+ maxCodeUnits: number
+): string {
+ if (value == null || value === '') {
+ return ''
+ }
+ return truncateAutomationListSearchField(value, maxCodeUnits).toLowerCase()
+}
+
+export function buildAutomationListSearchIndex(
+ fields: AutomationListSearchFields
+): AutomationListSearchIndex {
+ return {
+ name: normalizeAutomationListSearchField(
+ fields.name,
+ AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS
+ ),
+ project: normalizeAutomationListSearchField(
+ fields.project,
+ AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS
+ ),
+ prompt: normalizeAutomationListSearchField(
+ fields.prompt,
+ AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS
+ )
+ }
+}
+
+export function buildAutomationProjectSearchText(parts: {
+ displayName?: string | null
+ path?: string | null
+}): string {
+ const joined = [parts.displayName, parts.path]
+ .map((part) => part?.trim() ?? '')
+ .filter(Boolean)
+ .join(' ')
+ return joined || AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT
+}
+
+export function automationListSearchIndexMatches(
+ index: AutomationListSearchIndex,
+ activeQuery: string
+): boolean {
+ // Why: check short fields first so huge-prompt includes rarely run.
+ return (
+ index.name.includes(activeQuery) ||
+ index.project.includes(activeQuery) ||
+ index.prompt.includes(activeQuery)
+ )
+}
+
+export function automationListSearchFieldsMatch(
+ fields: AutomationListSearchFields,
+ rawQuery: string
+): boolean {
+ const resolved = resolveAutomationListSearchQuery(rawQuery)
+ if (resolved.status === 'too_large') {
+ return false
+ }
+ if (resolved.status === 'inactive') {
+ return true
+ }
+ return automationListSearchIndexMatches(buildAutomationListSearchIndex(fields), resolved.query)
+}
+
+/**
+ * Filters with an already-resolved active query. Callers must pass null/skip
+ * when search is inactive or too large so this never runs "for free".
+ */
+export function filterByActiveAutomationListSearchQuery
(
+ items: readonly T[],
+ indexes: readonly AutomationListSearchIndex[],
+ activeQuery: string
+): T[] {
+ if (indexes.length !== items.length) {
+ return [...items]
+ }
+ const matches: T[] = []
+ for (let i = 0; i < items.length; i += 1) {
+ const item = items[i]
+ const index = indexes[i]
+ if (item !== undefined && index && automationListSearchIndexMatches(index, activeQuery)) {
+ matches.push(item)
+ }
+ }
+ return matches
+}
+
+/**
+ * Filters items by a prebuilt index. Empty and oversized queries leave the
+ * original array reference untouched so the list stays unfiltered and search
+ * work is skipped entirely.
+ */
+export function filterByAutomationListSearchIndex(
+ items: readonly T[],
+ indexes: readonly AutomationListSearchIndex[],
+ rawQuery: string
+): readonly T[] {
+ const activeQuery = getActiveAutomationListSearchQuery(rawQuery)
+ if (activeQuery === null) {
+ return items
+ }
+ return filterByActiveAutomationListSearchQuery(items, indexes, activeQuery)
+}
+
+/** Builds indexes then filters. Prefer prebuilt indexes when filtering often. */
+export function filterByAutomationListSearch(
+ items: readonly T[],
+ rawQuery: string,
+ getFields: (item: T) => AutomationListSearchFields
+): readonly T[] {
+ const activeQuery = getActiveAutomationListSearchQuery(rawQuery)
+ if (activeQuery === null) {
+ return items
+ }
+ const matches: T[] = []
+ for (const item of items) {
+ if (
+ automationListSearchIndexMatches(buildAutomationListSearchIndex(getFields(item)), activeQuery)
+ ) {
+ matches.push(item)
+ }
+ }
+ return matches
+}
+
+/**
+ * Content fingerprint for search-relevant fields only. Used so list refresh
+ * ticks that replace arrays with equivalent search content do not rebuild
+ * indexes or re-run filtering.
+ */
+export function buildAutomationListSearchFingerprint(
+ sources: readonly AutomationListSearchFields[]
+): string {
+ if (sources.length === 0) {
+ return ''
+ }
+ let fingerprint = ''
+ for (let i = 0; i < sources.length; i += 1) {
+ if (i > 0) {
+ fingerprint += '\u0000'
+ }
+ const source = sources[i]
+ if (!source) {
+ continue
+ }
+ fingerprint += `${source.name}\u0001${source.project}\u0001${source.prompt}`
+ }
+ return fingerprint
+}
diff --git a/src/renderer/src/components/automations/automation-page-state.ts b/src/renderer/src/components/automations/automation-page-state.ts
new file mode 100644
index 00000000000..b4eb2a31cc0
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-page-state.ts
@@ -0,0 +1,15 @@
+import type {
+ ExternalAutomationJob,
+ ExternalAutomationManager,
+ ExternalAutomationRun
+} from '../../../../shared/automations-types'
+
+/** Detail-pane tab shared by the page, its list panel, and the detail pane. */
+export type AutomationPaneTab = 'overview' | 'runs'
+
+/** External run opened as a full page inside the detail pane. */
+export type SelectedExternalRunPage = {
+ manager: ExternalAutomationManager
+ job: ExternalAutomationJob
+ run: ExternalAutomationRun
+}
diff --git a/src/renderer/src/components/automations/automation-run-content.ts b/src/renderer/src/components/automations/automation-run-content.ts
new file mode 100644
index 00000000000..5ea2eddabb4
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-run-content.ts
@@ -0,0 +1,17 @@
+import type { AutomationRun } from '../../../../shared/automations-types'
+
+export function getAutomationRunContent(run: AutomationRun): string {
+ const savedOutput = run.outputSnapshot?.content.trim()
+ if (savedOutput) {
+ return run.outputSnapshot?.content ?? savedOutput
+ }
+ if (run.precheckResult) {
+ const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()]
+ .filter(Boolean)
+ .join('\n\n')
+ if (output) {
+ return output
+ }
+ }
+ return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.'
+}
diff --git a/src/renderer/src/components/automations/automation-run-view-state.ts b/src/renderer/src/components/automations/automation-run-view-state.ts
index dd300871ac7..56f55dec088 100644
--- a/src/renderer/src/components/automations/automation-run-view-state.ts
+++ b/src/renderer/src/components/automations/automation-run-view-state.ts
@@ -21,6 +21,16 @@ export function getAutomationRerunPendingRemainingMs({
return Math.max(0, pendingStartedAt + AUTOMATION_RERUN_PENDING_MIN_VISIBLE_MS - now)
}
+export async function waitForAutomationRerunPendingVisibility(
+ pendingStartedAt: number
+): Promise {
+ const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt })
+ if (remainingMs <= 0) {
+ return
+ }
+ await new Promise((resolve) => window.setTimeout(resolve, remainingMs))
+}
+
export function canRerunAutomationRun({
automation,
run
diff --git a/src/renderer/src/components/automations/automation-source-context.ts b/src/renderer/src/components/automations/automation-source-context.ts
new file mode 100644
index 00000000000..e8a05ebabc5
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-source-context.ts
@@ -0,0 +1,56 @@
+import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
+import { parseExecutionHostId } from '../../../../shared/execution-host'
+import type { Automation } from '../../../../shared/automations-types'
+import type { RuntimeStatus } from '../../../../shared/runtime-types'
+import type { TaskSourceContext } from '../../../../shared/task-source-context'
+import type { TaskSourceHostAvailability } from '../task-source-context-summary'
+
+export type RepoBackedAutomationSourceContext = TaskSourceContext & {
+ provider: 'github' | 'gitlab'
+}
+
+export function getRepoBackedAutomationSourceContext(
+ automation: Automation
+): RepoBackedAutomationSourceContext | null {
+ const context = automation.sourceContext
+ return context?.provider === 'github' || context?.provider === 'gitlab'
+ ? (context as RepoBackedAutomationSourceContext)
+ : null
+}
+
+export function getRuntimeSourceHostAvailability(
+ context: TaskSourceContext,
+ runtimeStatusByEnvironmentId: ReadonlyMap<
+ string,
+ { status: RuntimeStatus | null; checkedAt: number }
+ >
+): TaskSourceHostAvailability | null {
+ const parsed = parseExecutionHostId(context.hostId)
+ if (parsed?.kind !== 'runtime') {
+ return null
+ }
+ const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId)
+ if (!entry) {
+ return {
+ hostId: context.hostId,
+ reason: 'checking-task-source-capability'
+ }
+ }
+ if (!entry.status) {
+ return { hostId: context.hostId, health: 'disconnected' }
+ }
+ if (entry.status.graphStatus !== 'ready') {
+ return { hostId: context.hostId, health: 'connecting' }
+ }
+ const capabilities = entry.status.capabilities
+ if (!capabilities) {
+ return {
+ hostId: context.hostId,
+ reason: 'checking-task-source-capability'
+ }
+ }
+ if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) {
+ return { hostId: context.hostId, reason: 'missing-task-source-capability' }
+ }
+ return null
+}
diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts
new file mode 100644
index 00000000000..ee8e9d19d68
--- /dev/null
+++ b/src/renderer/src/components/automations/external-automation-display.ts
@@ -0,0 +1,71 @@
+import React from 'react'
+import type { Badge } from '@/components/ui/badge'
+import type {
+ ExternalAutomationJob,
+ ExternalAutomationManager,
+ ExternalAutomationRun
+} from '../../../../shared/automations-types'
+import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
+
+export function getExternalAutomationKey(
+ manager: ExternalAutomationManager,
+ job: ExternalAutomationJob
+): string {
+ return `${manager.id}:${job.id}`
+}
+
+export function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string {
+ return `${manager.id}:source`
+}
+
+export function formatExternalDate(value: string | null, now: number): string {
+ if (!value) {
+ return 'Never'
+ }
+ const parsed = Date.parse(value)
+ if (!Number.isFinite(parsed)) {
+ return value
+ }
+ return formatAutomationDateTimeWithRelative(parsed, now)
+}
+
+export function getExternalProviderLabel(manager: ExternalAutomationManager): string {
+ return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'
+}
+
+export function getExternalTargetKindLabel(manager: ExternalAutomationManager): string {
+ return manager.target.type === 'ssh' ? 'SSH host' : 'Local'
+}
+
+export function getExternalRunStatusLabel(run: ExternalAutomationRun): string {
+ switch (run.status) {
+ case 'completed':
+ return 'Completed'
+ case 'failed':
+ return 'Failed'
+ case 'unknown':
+ return 'Unknown'
+ }
+}
+
+export function getExternalRunStatusVariant(
+ run: ExternalAutomationRun
+): React.ComponentProps['variant'] {
+ switch (run.status) {
+ case 'completed':
+ return 'secondary'
+ case 'failed':
+ return 'destructive'
+ case 'unknown':
+ return 'outline'
+ }
+}
+
+export function getExternalRunContent(run: ExternalAutomationRun): string {
+ return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.'
+}
+
+export function isMissingExternalRunsApiError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error)
+ return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message)
+}
diff --git a/src/renderer/src/components/automations/external-automation-list-entries.ts b/src/renderer/src/components/automations/external-automation-list-entries.ts
new file mode 100644
index 00000000000..01b3a84720a
--- /dev/null
+++ b/src/renderer/src/components/automations/external-automation-list-entries.ts
@@ -0,0 +1,46 @@
+import type {
+ ExternalAutomationJob,
+ ExternalAutomationManager
+} from '../../../../shared/automations-types'
+import {
+ getExternalAutomationKey,
+ getExternalAutomationSourceKey
+} from './external-automation-display'
+
+export type ExternalAutomationListEntry =
+ | {
+ kind: 'job'
+ key: string
+ manager: ExternalAutomationManager
+ job: ExternalAutomationJob
+ }
+ | {
+ kind: 'source'
+ key: string
+ manager: ExternalAutomationManager
+ }
+
+export function buildExternalAutomationListEntries(
+ managers: readonly ExternalAutomationManager[]
+): ExternalAutomationListEntry[] {
+ return managers.flatMap((manager): ExternalAutomationListEntry[] => {
+ if (manager.jobs.length === 0) {
+ if (manager.provider === 'hermes' && (manager.status === 'unavailable' || manager.error)) {
+ return [
+ {
+ kind: 'source' as const,
+ key: getExternalAutomationSourceKey(manager),
+ manager
+ }
+ ]
+ }
+ return []
+ }
+ return manager.jobs.map((job) => ({
+ kind: 'job' as const,
+ key: getExternalAutomationKey(manager, job),
+ manager,
+ job
+ }))
+ })
+}
diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts
new file mode 100644
index 00000000000..ce16d98c06a
--- /dev/null
+++ b/src/renderer/src/components/automations/use-automation-list-search.ts
@@ -0,0 +1,265 @@
+import { useDeferredValue, useEffect, useMemo } from 'react'
+import type { Automation } from '../../../../shared/automations-types'
+import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
+import type { Repo } from '../../../../shared/types'
+import {
+ automationListSearchIndexMatches,
+ buildAutomationListSearchIndex,
+ buildAutomationProjectSearchText,
+ resolveAutomationListSearchQuery,
+ truncateAutomationListSearchField,
+ AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS,
+ type AutomationListSearchFields,
+ type AutomationListSearchIndex
+} from './automation-list-search'
+import type { ExternalAutomationListEntry } from './external-automation-list-entries'
+import { getExternalProviderLabel } from './external-automation-display'
+
+export function useAutomationListSearch({
+ listSearchQuery,
+ automations,
+ externalAutomationEntries,
+ repoMap,
+ selectedId,
+ selectedExternalKey,
+ selectAutomationId,
+ selectExternalKey
+}: {
+ listSearchQuery: string
+ automations: readonly Automation[]
+ externalAutomationEntries: readonly ExternalAutomationListEntry[]
+ repoMap: ReadonlyMap
+ selectedId: string | null
+ selectedExternalKey: string | null
+ selectAutomationId: (automationId: string | null) => void
+ selectExternalKey: (externalKey: string | null) => void
+}): {
+ isListSearchQueryTooLarge: boolean
+ isListSearchActive: boolean
+ filteredAutomations: readonly Automation[]
+ filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
+ hasListItems: boolean
+ hasFilteredListItems: boolean
+} {
+ // Why: keep the input snappy; matching is deferred so caret never waits on
+ // index scans. Only the normalized active query can fire a search.
+ const deferredListSearchQuery = useDeferredValue(listSearchQuery)
+ const liveListSearchResolution = useMemo(
+ () => resolveAutomationListSearchQuery(listSearchQuery),
+ [listSearchQuery]
+ )
+ const deferredListSearchResolution = useMemo(
+ () => resolveAutomationListSearchQuery(deferredListSearchQuery),
+ [deferredListSearchQuery]
+ )
+ // Why: field feedback tracks the live value so a huge paste is labeled
+ // immediately; list filtering stays on the deferred resolution.
+ const isListSearchQueryTooLarge = liveListSearchResolution.status === 'too_large'
+ // Why: null means search must not run (empty, whitespace, or too large).
+ const activeListSearchQuery =
+ deferredListSearchResolution.status === 'active' ? deferredListSearchResolution.query : null
+ const isListSearchActive = activeListSearchQuery !== null
+
+ // Why: fingerprint includes id + search fields so refresh ticks that only
+ // change nextRunAt / usage do not rebuild indexes or re-run matching. Prompts
+ // are truncated to the indexed prefix so each tick stays O(bound) per row.
+ const automationSearchFingerprint = useMemo(
+ () =>
+ automations
+ .map((automation) => {
+ const repo = repoMap.get(getAutomationRunRepoId(automation))
+ const project = buildAutomationProjectSearchText({
+ displayName: repo?.displayName,
+ path: repo?.path
+ })
+ const prompt = truncateAutomationListSearchField(
+ automation.prompt,
+ AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS
+ )
+ return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${prompt}`
+ })
+ .join('\u0000'),
+ [automations, repoMap]
+ )
+ const automationSearchRows = useMemo((): {
+ id: string
+ index: AutomationListSearchIndex
+ }[] => {
+ return automations.map((automation) => {
+ const repo = repoMap.get(getAutomationRunRepoId(automation))
+ return {
+ id: automation.id,
+ index: buildAutomationListSearchIndex({
+ name: automation.name,
+ project: buildAutomationProjectSearchText({
+ displayName: repo?.displayName,
+ path: repo?.path
+ }),
+ prompt: automation.prompt
+ })
+ }
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate
+ }, [automationSearchFingerprint])
+
+ const externalAutomationSearchFingerprint = useMemo(
+ () =>
+ externalAutomationEntries
+ .map((entry) => {
+ if (entry.kind === 'source') {
+ return `${entry.key}\u0001${entry.manager.targetLabel}\u0001${getExternalProviderLabel(entry.manager)}\u0001`
+ }
+ const prompt = truncateAutomationListSearchField(
+ entry.job.prompt ?? entry.job.promptPreview ?? '',
+ AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS
+ )
+ return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${prompt}`
+ })
+ .join('\u0000'),
+ [externalAutomationEntries]
+ )
+ const externalAutomationSearchRows = useMemo((): {
+ key: string
+ index: AutomationListSearchIndex
+ }[] => {
+ return externalAutomationEntries.map((entry) => {
+ const fields: AutomationListSearchFields =
+ entry.kind === 'source'
+ ? {
+ name: entry.manager.targetLabel,
+ project: `${getExternalProviderLabel(entry.manager)} ${entry.manager.targetLabel}`,
+ prompt: ''
+ }
+ : {
+ name: entry.job.name,
+ project: [
+ getExternalProviderLabel(entry.manager),
+ entry.manager.targetLabel,
+ entry.job.workdir
+ ]
+ .filter(Boolean)
+ .join(' '),
+ prompt: entry.job.prompt ?? entry.job.promptPreview ?? ''
+ }
+ return { key: entry.key, index: buildAutomationListSearchIndex(fields) }
+ })
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate
+ }, [externalAutomationSearchFingerprint])
+
+ // Why: matching runs only when the normalized query or search content changes —
+ // never on relativeNow / nextRunAt / usage refresh alone.
+ const filteredAutomationIds = useMemo((): readonly string[] | null => {
+ if (activeListSearchQuery === null) {
+ return null
+ }
+ const ids: string[] = []
+ for (const row of automationSearchRows) {
+ if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) {
+ ids.push(row.id)
+ }
+ }
+ return ids
+ }, [activeListSearchQuery, automationSearchRows])
+
+ const filteredExternalAutomationKeys = useMemo((): readonly string[] | null => {
+ if (activeListSearchQuery === null) {
+ return null
+ }
+ const keys: string[] = []
+ for (const row of externalAutomationSearchRows) {
+ if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) {
+ keys.push(row.key)
+ }
+ }
+ return keys
+ }, [activeListSearchQuery, externalAutomationSearchRows])
+
+ const filteredAutomations = useMemo((): readonly Automation[] => {
+ if (filteredAutomationIds === null) {
+ return automations
+ }
+ if (filteredAutomationIds.length === 0) {
+ return []
+ }
+ const byId = new Map(automations.map((automation) => [automation.id, automation]))
+ const next: Automation[] = []
+ for (const id of filteredAutomationIds) {
+ const automation = byId.get(id)
+ if (automation) {
+ next.push(automation)
+ }
+ }
+ return next
+ }, [automations, filteredAutomationIds])
+
+ const filteredExternalAutomationEntries = useMemo((): readonly ExternalAutomationListEntry[] => {
+ if (filteredExternalAutomationKeys === null) {
+ return externalAutomationEntries
+ }
+ if (filteredExternalAutomationKeys.length === 0) {
+ return []
+ }
+ const byKey = new Map(externalAutomationEntries.map((entry) => [entry.key, entry]))
+ const next: ExternalAutomationListEntry[] = []
+ for (const key of filteredExternalAutomationKeys) {
+ const entry = byKey.get(key)
+ if (entry) {
+ next.push(entry)
+ }
+ }
+ return next
+ }, [externalAutomationEntries, filteredExternalAutomationKeys])
+
+ const hasListItems = automations.length + externalAutomationEntries.length > 0
+ const hasFilteredListItems =
+ filteredAutomations.length + filteredExternalAutomationEntries.length > 0
+
+ // Why: when search hides the current row, move selection to the first visible
+ // match so list highlight and detail stay aligned. No matches → keep detail.
+ useEffect(() => {
+ if (activeListSearchQuery === null) {
+ return
+ }
+ const localVisible =
+ selectedExternalKey === null &&
+ selectedId != null &&
+ filteredAutomations.some((automation) => automation.id === selectedId)
+ const externalVisible =
+ selectedExternalKey != null &&
+ filteredExternalAutomationEntries.some((entry) => entry.key === selectedExternalKey)
+ if (localVisible || externalVisible) {
+ return
+ }
+ const firstLocal = filteredAutomations[0]
+ if (firstLocal) {
+ if (selectedExternalKey !== null) {
+ selectExternalKey(null)
+ }
+ if (selectedId !== firstLocal.id) {
+ selectAutomationId(firstLocal.id)
+ }
+ return
+ }
+ const firstExternal = filteredExternalAutomationEntries[0]
+ if (firstExternal && selectedExternalKey !== firstExternal.key) {
+ selectExternalKey(firstExternal.key)
+ }
+ }, [
+ activeListSearchQuery,
+ filteredAutomations,
+ filteredExternalAutomationEntries,
+ selectAutomationId,
+ selectExternalKey,
+ selectedExternalKey,
+ selectedId
+ ])
+
+ return {
+ isListSearchQueryTooLarge,
+ isListSearchActive,
+ filteredAutomations,
+ filteredExternalAutomationEntries,
+ hasListItems,
+ hasFilteredListItems
+ }
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index b3ce98a4edd..b7f0deb0871 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -13943,7 +13943,20 @@
"a21f6c33ad": "Automation source refreshed.",
"53f06f0ad5": "Retry source",
"pendingAutomationMissing": "Automation no longer available.",
- "pendingAutomationRunMissing": "Run history no longer available."
+ "pendingAutomationRunMissing": "Run history no longer available.",
+ "noSearchMatches": "No automations match your search.",
+ "paused": "Paused",
+ "runCount": "{{count}} runs",
+ "runCount_one": "{{count}} run",
+ "runCount_other": "{{count}} runs",
+ "projectDefaultBaseRef": "project default",
+ "createFromBaseRef": "Create from {{baseRef}}",
+ "missingWorkspace": "Missing workspace",
+ "runUsageSummary": "{{cost}} est. · {{tokens}} tokens",
+ "usageUnavailable": "Usage unavailable",
+ "noRunUsageYet": "No run usage yet",
+ "noWorkspace": "No workspace",
+ "latestSavedOutput": "Latest saved output"
},
"CreateFromPicker": {
"f061f49e3f": "Search repo branches...",
@@ -14056,6 +14069,13 @@
"5a7863909c": "Run setup for each new workspace",
"18f000ad4e": "Advanced",
"874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs."
+ },
+ "AutomationListSearchField": {
+ "tooLong": "Search text is too long — list is unfiltered",
+ "label": "Search automations",
+ "placeholder": "Search by name, project, or prompt",
+ "tooLongShort": "Too long",
+ "clear": "Clear search"
}
},
"agent": {
From e4aadccefffd99189e08683eec082f7d11b931f3 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:36:07 -0700
Subject: [PATCH 02/42] fix(mobile): keep repeated-prefix native chat replies
streaming (STA-3333) (#12501)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mobile): keep repeated-prefix chat replies streaming
Text alone can't tell "the transcript caught up with this stream" from
"a new reply repeats the previous turn's prefix", so the old suppress-on-
prefix rule swallowed genuine repeated replies. A stateful gate remembers
which transcript tail predates the current stream segment and hides the
bubble only when that tail moved during the segment, scoped to the active
host/workspace/tab/session so a swapped chat can't inherit a baseline.
Refs STA-3333.
* fix(mobile): keep the streaming gate alive across chat/terminal toggles
The gate lived in MobileNativeChatView, but MobileNativeChatOverlay returns
null whenever the user peeks at the terminal — that unmounts the view and
throws the baseline away, so the repeated-prefix reply was swallowed again on
the way back. Move the gate (and the fold memo it reads) up to the overlay,
which stays mounted across those toggles.
While hidden the transcript is empty and the throttled stream reports no text,
which the gate would have read as "idle" and re-anchored on. Pass the agent's
working state so a textless tick inside a live segment holds the baseline
instead. The scope key is now keyed off the tab rather than the view-gated
chat resolution, so it survives the toggle too; streamIdentity keeps its exact
previous value because the delayed-send guards compare against it.
Also drops a dead disjunct in the caught-up test: a null baseline is already
unequal to every real tail id.
* test(mobile): model the real re-show ordering in the streaming-gate tests
The overlay regression test replayed the transcript before the stream text on
the way back from the terminal view. That ordering is backwards: the session
withholds `messages` until a fresh read settles (an RPC round trip) while the
throttled stream text returns in ~50ms — and with the transcript already back,
a gate that got discarded on the toggle still passes. Replay the real order,
which pins the gate's lifetime as intended.
Swaps the hidden-gap duplicate case for the in-view one (a tool frame clears
the assistant text mid-turn), which is where the hold actually earns its keep;
the hidden-gap direction stays covered at the gate level.
* fix(mobile): stop the streaming gate adopting a reply as its own history
A textless status tick was re-anchoring the gate's pre-stream baseline, so
two paths still rendered wrong:
- The reply's transcript push beats its throttled status text whenever the
pane stays `working` past the turn (a live subagent or background task).
The tick in between adopted the just-landed reply as history, and the
status text that followed rendered it a second time — a duplicate bubble,
and a regression against main's suppress-on-prefix rule.
- Peeking at the terminal between turns empties the transcript. That empty
tail was adopted as the baseline, so the next repeated-prefix reply was
swallowed again — the bug this PR exists to fix.
Only a tick that carries a real tail and sits outside a live turn anchors
now, with an exception for a gate that has never anchored: mounted mid-turn,
the first real tail it sees is the best history it will ever get.
Also drop `buildMobileNativeChatData`, a test-only builder this PR had wired
the new gate into; its green test asserted the exact suppression this PR
removes. Its fold/pending/image coverage moves to the builder the view calls.
* test(mobile): pin the textless anchor's text reset
Mutation testing found the `prevText` reset on an anchoring textless tick
unpinned: keeping the previous turn's text there reads the next turn's
opener as a new segment, re-anchors onto the reply that just landed, and
renders it a second time — the same duplicate-bubble class already fixed
twice on this branch.
---
.../session/MobileNativeChatOverlay.test.ts | 193 ++++++++++++++
.../src/session/MobileNativeChatOverlay.tsx | 19 +-
.../src/session/MobileNativeChatView.test.ts | 60 ++++-
mobile/src/session/MobileNativeChatView.tsx | 18 +-
.../mobile-native-chat-render-data.test.ts | 94 +++----
.../session/mobile-native-chat-render-data.ts | 51 +---
.../mobile-native-chat-streaming-gate.test.ts | 238 ++++++++++++++++++
.../mobile-native-chat-streaming-gate.ts | 93 +++++++
.../use-mobile-native-chat-controller.test.ts | 102 +++++++-
.../use-mobile-native-chat-controller.ts | 16 +-
...use-mobile-native-chat-streaming-bubble.ts | 31 +++
11 files changed, 787 insertions(+), 128 deletions(-)
create mode 100644 mobile/src/session/MobileNativeChatOverlay.test.ts
create mode 100644 mobile/src/session/mobile-native-chat-streaming-gate.test.ts
create mode 100644 mobile/src/session/mobile-native-chat-streaming-gate.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-streaming-bubble.ts
diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts
new file mode 100644
index 00000000000..2680064c94b
--- /dev/null
+++ b/mobile/src/session/MobileNativeChatOverlay.test.ts
@@ -0,0 +1,193 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
+import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
+
+vi.mock('react-native', () => ({
+ StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
+ View: 'View'
+}))
+
+vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' }))
+
+function assistantTurn(id: string, text: string): NativeChatMessage {
+ return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
+}
+
+function suppressRendererWarning(): () => void {
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...args)
+ })
+ return () => spy.mockRestore()
+}
+
+/** One render of the route: chat visible or not, the transcript it currently
+ * holds, and the agent-status stream behind it. */
+type Tick = {
+ show?: boolean
+ messages?: NativeChatMessage[]
+ streamingText?: string
+ streamLive?: boolean
+ identity?: string
+}
+
+function overlayElement(tick: Tick): ReturnType {
+ const controller = {
+ showNativeChat: tick.show ?? true,
+ nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
+ nativeChatAgent: 'claude',
+ nativeChatAgentWorking: tick.streamLive ?? false,
+ nativeChatStreamingText: tick.streamingText,
+ nativeChatStreamLive: tick.streamLive ?? false,
+ nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
+ chatPending: [],
+ chatComposerText: '',
+ setChatComposerText: vi.fn()
+ } as unknown as MobileNativeChatController
+ return createElement(MobileNativeChatOverlay, {
+ controller,
+ images: {} as never,
+ onMicPress: vi.fn(),
+ micActive: false,
+ dictationMode: 'toggle',
+ onMicPressIn: vi.fn(),
+ onMicPressOut: vi.fn(),
+ inputLockReason: null,
+ sendErrorMessage: null,
+ onClearSendError: vi.fn(),
+ keyboardInset: 0
+ })
+}
+
+describe('MobileNativeChatOverlay streaming gate', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ async function render(tick: Tick): Promise {
+ const restore = suppressRendererWarning()
+ try {
+ await act(async () => {
+ renderer = create(overlayElement(tick))
+ })
+ } finally {
+ restore()
+ }
+ }
+
+ async function update(tick: Tick): Promise {
+ await act(async () => {
+ renderer?.update(overlayElement(tick))
+ })
+ }
+
+ /** The bubble text handed to the chat list, or `'hidden'` when chat is off. */
+ function streaming(): string | null | 'hidden' {
+ const views = renderer!.root.findAll((node) => node.type === 'ChatView')
+ return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null)
+ }
+
+ it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
+ const prior = [assistantTurn('a1', 'The tests pass.')]
+ await render({ messages: prior })
+ expect(streaming()).toBeNull()
+
+ await update({ messages: prior, streamingText: 'The tests', streamLive: true })
+
+ expect(streaming()).toBe('The tests')
+ })
+
+ it('drops the streaming bubble once the reply lands as its own turn', async () => {
+ const prior = [assistantTurn('a1', 'Done.')]
+ await render({ messages: prior })
+ await update({ messages: prior, streamingText: 'Done.', streamLive: true })
+ expect(streaming()).toBe('Done.')
+
+ await update({
+ messages: [...prior, assistantTurn('a2', 'Done.')],
+ streamingText: 'Done.',
+ streamLive: true
+ })
+
+ expect(streaming()).toBeNull()
+ })
+
+ it('keeps the bubble across a peek at the terminal view', async () => {
+ // Toggling to the terminal unmounts the chat list and unsubscribes its
+ // transcript. The gate lives above that boundary, so the baseline survives
+ // and the repeated-prefix reply keeps streaming on the way back.
+ const prior = [assistantTurn('a1', 'Done.')]
+ await render({ messages: prior })
+ await update({ messages: prior, streamingText: 'Done.', streamLive: true })
+ expect(streaming()).toBe('Done.')
+
+ await update({ show: false, messages: [], streamLive: true })
+ expect(streaming()).toBe('hidden')
+ // Back on chat the session withholds its transcript until a fresh read
+ // settles, so the throttled stream text returns a round trip ahead of it.
+ await update({ messages: [], streamLive: true })
+ await update({ messages: [], streamingText: 'Done.', streamLive: true })
+ await update({ messages: prior, streamingText: 'Done.', streamLive: true })
+
+ expect(streaming()).toBe('Done.')
+ })
+
+ it('keeps the bubble across a peek at the terminal taken between turns', async () => {
+ // Same toggle, but taken while idle: the transcript empties before the next
+ // turn starts, so the gate has to reject that empty tail as a baseline.
+ const prior = [assistantTurn('a1', 'Done.')]
+ await render({ messages: prior })
+
+ await update({ show: false, messages: [] })
+ await update({ show: false, messages: [], streamLive: true })
+ await update({ messages: [], streamLive: true })
+ await update({ messages: [], streamingText: 'Done.', streamLive: true })
+ await update({ messages: prior, streamingText: 'Done.', streamLive: true })
+
+ expect(streaming()).toBe('Done.')
+ })
+
+ it('hides a repeated part whose own turn landed during a mid-turn gap', async () => {
+ // Between parts the status frame carries no assistant text (a tool call), so
+ // the stream goes textless while the turn is still live and the part that
+ // just finished lands in the transcript. Re-anchoring on that tick would
+ // adopt it as history and render it a second time.
+ const prior = [assistantTurn('a1', 'Done.')]
+ await render({ messages: prior })
+ await update({ messages: prior, streamingText: 'Done.', streamLive: true })
+ expect(streaming()).toBe('Done.')
+
+ const landed = [...prior, assistantTurn('a2', 'Done.')]
+ await update({ messages: landed, streamLive: true })
+ await update({ messages: landed, streamingText: 'Done.', streamLive: true })
+
+ expect(streaming()).toBeNull()
+ })
+
+ it("does not carry one chat's baseline into another stream identity", async () => {
+ const prior = [assistantTurn('a1', 'Shared answer text')]
+ await render({ messages: prior, identity: 'tab-a' })
+
+ await update({
+ messages: prior,
+ streamingText: 'Shared answer',
+ streamLive: true,
+ identity: 'tab-b'
+ })
+
+ expect(streaming()).toBeNull()
+ })
+})
diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx
index b8f9cd9277b..77c5ab72fed 100644
--- a/mobile/src/session/MobileNativeChatOverlay.tsx
+++ b/mobile/src/session/MobileNativeChatOverlay.tsx
@@ -1,7 +1,10 @@
+import { useMemo } from 'react'
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
+import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
+import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
type Props = {
controller: MobileNativeChatController
@@ -22,7 +25,9 @@ type Props = {
}
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
- * view toggles while the native surface owns the visible composer. */
+ * view toggles while the native surface owns the visible composer. Also owns
+ * the streaming gate: this component stays mounted across those toggles, while
+ * the chat list below it does not. */
export function MobileNativeChatOverlay({
controller,
images,
@@ -36,19 +41,27 @@ export function MobileNativeChatOverlay({
onClearSendError,
keyboardInset
}: Props): React.JSX.Element | null {
+ const session = controller.nativeChatSession
+ const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages])
+ const streaming = useMobileNativeChatStreamingBubble(
+ folded,
+ controller.nativeChatStreamingText,
+ controller.nativeChatStreamScopeKey,
+ controller.nativeChatStreamLive
+ )
if (!controller.showNativeChat) {
return null
}
- const session = controller.nativeChatSession
return (
({
@@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => {
})
type Overrides = {
+ messages?: Parameters[0]['messages']
+ folded?: Parameters[0]['folded']
+ streaming?: string | null
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
@@ -75,7 +79,25 @@ function suppressRendererWarning(): () => void {
return () => spy.mockRestore()
}
-describe('MobileNativeChatView send-error banner', () => {
+function assistantTurn(id: string, text: string): NativeChatMessage {
+ return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
+}
+
+function chatViewElement(overrides: Overrides): ReturnType {
+ return createElement(MobileNativeChatView, {
+ messages: [],
+ folded: [],
+ status: 'ready',
+ streaming: null,
+ onSend: vi.fn().mockResolvedValue(true),
+ pending: [],
+ composerText: '',
+ onComposerTextChange: vi.fn(),
+ ...overrides
+ })
+}
+
+describe('MobileNativeChatView', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
@@ -91,23 +113,25 @@ describe('MobileNativeChatView send-error banner', () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
- renderer = create(
- createElement(MobileNativeChatView, {
- messages: [],
- status: 'ready',
- onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true),
- pending: [],
- composerText: '',
- onComposerTextChange: vi.fn(),
- ...overrides
- })
- )
+ renderer = create(chatViewElement(overrides))
})
} finally {
restore()
}
}
+ async function update(overrides: Overrides = {}): Promise {
+ await act(async () => {
+ renderer?.update(chatViewElement(overrides))
+ })
+ }
+
+ /** Ids of the rows the list is currently rendering. */
+ function listIds(): string[] {
+ const list = renderer!.root.find((node) => node.type === 'FlatList')
+ return (list.props.data as { id: string }[]).map((row) => row.id)
+ }
+
function banners(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
@@ -161,4 +185,16 @@ describe('MobileNativeChatView send-error banner', () => {
expect(onClearSendError).toHaveBeenCalledOnce()
})
+
+ // The gate that decides `streaming` lives in MobileNativeChatOverlay, which
+ // outlives this view; see MobileNativeChatOverlay.test.ts.
+ it('appends the gated streaming bubble after the folded transcript', async () => {
+ const folded = [assistantTurn('a1', 'The tests pass.')]
+ await render({ folded })
+ expect(listIds()).toEqual(['a1'])
+
+ await update({ folded, streaming: 'The tests' })
+
+ expect(listIds()).toEqual(['a1', 'streaming'])
+ })
})
diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx
index f14efc1f25f..1a2ae71ca66 100644
--- a/mobile/src/session/MobileNativeChatView.tsx
+++ b/mobile/src/session/MobileNativeChatView.tsx
@@ -16,7 +16,6 @@ import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import {
buildMobileNativeChatTransientData,
- foldMobileNativeChatMessages,
mobileNativeChatEmptyState,
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
@@ -39,7 +38,10 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
type Props = {
+ /** Raw transcript, only for telling "still loading" from "loaded and empty". */
messages: NativeChatMessage[]
+ /** `messages` with noise stripped and tool turns folded in, from the overlay. */
+ folded: NativeChatMessage[]
status: MobileNativeChatStatus
error?: string
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
@@ -47,9 +49,9 @@ type Props = {
agentWorking?: boolean
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
onStop?: () => void
- /** Live partial assistant text while a turn is still streaming (from the agent
- * status hook). Shown as an in-progress bubble until the transcript catches up. */
- streamingText?: string
+ /** Live partial assistant text to show as an in-progress bubble, already gated
+ * by the overlay against the transcript catching up. */
+ streaming: string | null
hasMore?: boolean
loadingEarlier?: boolean
onLoadEarlier?: () => void
@@ -102,12 +104,13 @@ type Props = {
export function MobileNativeChatView({
messages,
+ folded,
status,
error,
agent,
agentWorking,
onStop,
- streamingText,
+ streaming,
hasMore,
loadingEarlier,
onLoadEarlier,
@@ -165,10 +168,9 @@ export function MobileNativeChatView({
// `data` is the list source: folded transcript + synthetic streaming bubble +
// route-owned optimistic queued messages. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
- const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages])
const { data } = useMemo(
- () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }),
- [foldedMessages, streamingText, pending]
+ () => buildMobileNativeChatTransientData({ folded, streaming, pending }),
+ [folded, streaming, pending]
)
// Follow the tail as the conversation grows and keep the newest message above
diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts
index 4dabff8f600..b4a273d31ca 100644
--- a/mobile/src/session/mobile-native-chat-render-data.test.ts
+++ b/mobile/src/session/mobile-native-chat-render-data.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
- buildMobileNativeChatData,
+ buildMobileNativeChatTransientData,
+ foldMobileNativeChatMessages,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
@@ -50,13 +51,22 @@ describe('mobileNativeChatEmptyState', () => {
})
})
-describe('buildMobileNativeChatData', () => {
+/** Mirrors the view: fold the raw transcript, then assemble the list. */
+function build(
+ messages: NativeChatMessage[],
+ streaming: string | null,
+ pending: Parameters[0]['pending']
+): NativeChatMessage[] {
+ return buildMobileNativeChatTransientData({
+ folded: foldMobileNativeChatMessages(messages),
+ streaming,
+ pending
+ }).data
+}
+
+describe('buildMobileNativeChatTransientData', () => {
it('appends pending optimistic messages at the tail as user turns', () => {
- const { data } = buildMobileNativeChatData({
- messages: [assistant('a1', 'hello')],
- streamingText: undefined,
- pending: [{ id: 'p1', text: 'queued' }]
- })
+ const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }])
const last = data[data.length - 1]
expect(last.id).toBe('p1')
expect(last.role).toBe('user')
@@ -64,10 +74,9 @@ describe('buildMobileNativeChatData', () => {
})
it('renders a pending send with images as text followed by image-ref thumbnails', () => {
- const { data } = buildMobileNativeChatData({
- messages: [],
- pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }]
- })
+ const data = build([], null, [
+ { id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }
+ ])
const last = data[data.length - 1]
expect(last.role).toBe('user')
expect(last.blocks).toEqual([
@@ -78,10 +87,7 @@ describe('buildMobileNativeChatData', () => {
})
it('renders an image-only pending send (no text) as just the thumbnail', () => {
- const { data } = buildMobileNativeChatData({
- messages: [],
- pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]
- })
+ const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }])
expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
})
@@ -89,14 +95,15 @@ describe('buildMobileNativeChatData', () => {
// Claude records an attached image as `[Image: source: /path]` + an
// `[Image #1] `-prefixed caption turn; the fold must merge them into one
// user turn with an image-ref block instead of showing raw marker text.
- const { data } = buildMobileNativeChatData({
- messages: [
+ const data = build(
+ [
user('u1', '[Image: source: /tmp/a.png]'),
user('u2', '[Image #1] look at this'),
assistant('a1', 'nice photo')
],
- pending: []
- })
+ null,
+ []
+ )
const merged = data.find((message) => message.role === 'user')
expect(merged?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
@@ -105,49 +112,20 @@ describe('buildMobileNativeChatData', () => {
})
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
- const { data } = buildMobileNativeChatData({
- messages: [user('u1', '[Image: source: /tmp/a.png]')],
- pending: []
- })
+ const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
})
- it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
- const { streaming, data } = buildMobileNativeChatData({
- messages: [user('u1', 'hi')],
- streamingText: 'thinking out loud',
- pending: []
- })
- expect(streaming).toBe('thinking out loud')
- expect(data.some((m) => m.id === 'streaming')).toBe(true)
+ it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => {
+ // Whether text streams at all is the gate's call
+ // (`mobile-native-chat-streaming-gate.test.ts`); this only places it.
+ const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }])
+ expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1'])
+ expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }])
})
- it('shows a short new streaming reply even after a longer previous turn', () => {
- // The last folded turn is a long completed reply; a short new stream must not
- // be suppressed just for being shorter than the prior turn.
- const { streaming, data } = buildMobileNativeChatData({
- messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')],
- streamingText: 'Ok',
- pending: []
- })
- expect(streaming).toBe('Ok')
- expect(data.some((m) => m.id === 'streaming')).toBe(true)
- })
-
- it('drops the streaming bubble once the real assistant turn already contains it', () => {
- const { streaming, data } = buildMobileNativeChatData({
- messages: [assistant('a1', 'done answer')],
- streamingText: 'done',
- pending: []
- })
- expect(streaming).toBeNull()
- expect(data.some((m) => m.id === 'streaming')).toBe(false)
- })
-
- it('returns no streaming bubble for empty/whitespace streaming text', () => {
- expect(
- buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming
- ).toBeNull()
- expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull()
+ it('omits the bubble when the gate withheld the streaming text', () => {
+ const data = build([assistant('a1', 'done answer')], null, [])
+ expect(data.some((message) => message.id === 'streaming')).toBe(false)
})
})
diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts
index 068050fc43d..e11b9ac56f4 100644
--- a/mobile/src/session/mobile-native-chat-render-data.ts
+++ b/mobile/src/session/mobile-native-chat-render-data.ts
@@ -43,41 +43,25 @@ export type MobileNativeChatPendingItem = {
images?: string[]
}
-/** Derive the list data from the raw transcript: fold tool turns into the
- * assistant turn, optionally append a synthetic streaming bubble, then the
- * route-owned optimistic "queued" messages at the tail. Returns the
- * intermediate `folded`/`streaming` so the caller can memoize on them. */
-export function buildMobileNativeChatData({
- messages,
- streamingText,
- pending
-}: {
- messages: NativeChatMessage[]
- streamingText?: string
- pending: MobileNativeChatPendingItem[]
-}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
- const folded = foldMobileNativeChatMessages(messages)
- return buildMobileNativeChatTransientData({ folded, streamingText, pending })
-}
-
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
// Normalize first (desktop assembler parity): image marker turns fold into
// image-ref blocks instead of rendering as raw `[Image: …]` text.
return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
}
+/** Assemble the list data the chat renders: the folded transcript, then a
+ * synthetic bubble for the streaming text the gate let through, then the
+ * route-owned optimistic "queued" messages at the tail. */
export function buildMobileNativeChatTransientData({
folded,
- streamingText,
+ streaming,
pending
}: {
folded: NativeChatMessage[]
- streamingText?: string
+ /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
+ streaming: string | null
pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
- // Only show the streaming bubble while its text leads the transcript — once the
- // real assistant turn lands with the same text, drop the synthetic one.
- const streaming = deriveStreaming(folded, streamingText)
const data: NativeChatMessage[] = [
...folded,
...(streaming
@@ -106,26 +90,3 @@ export function buildMobileNativeChatTransientData({
]
return { folded, streaming, data }
}
-
-function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null {
- const text = streamingText?.trim()
- if (!text) {
- return null
- }
- const last = folded[folded.length - 1]
- const lastText =
- last?.role === 'assistant'
- ? last.blocks
- .filter((b) => b.type === 'text')
- .map((b) => (b.type === 'text' ? b.text : ''))
- .join('')
- .trim()
- : ''
- // Hide the synthetic bubble only once the real turn has landed leading with the
- // streamed text. A bare length compare would suppress a short new reply behind a
- // longer previous turn; a completed prior turn won't start with the new prefix.
- if (lastText.startsWith(text)) {
- return null
- }
- return text
-}
diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts
new file mode 100644
index 00000000000..312be591ca9
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts
@@ -0,0 +1,238 @@
+import { describe, expect, it } from 'vitest'
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import {
+ createMobileNativeChatStreamingGate,
+ deriveMobileNativeChatStreaming,
+ type MobileNativeChatStreamingGate
+} from './mobile-native-chat-streaming-gate'
+
+function assistant(id: string, text: string): NativeChatMessage {
+ return {
+ id,
+ role: 'assistant',
+ blocks: [{ type: 'text', text }],
+ timestamp: 0,
+ source: 'transcript'
+ }
+}
+
+/** Run a sequence of (folded, streamingText) ticks through one gate. */
+function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): {
+ gate: MobileNativeChatStreamingGate
+ results: (string | null)[]
+} {
+ let gate = createMobileNativeChatStreamingGate()
+ const results: (string | null)[] = []
+ for (const tick of ticks) {
+ const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, {
+ streamLive: tick.live
+ })
+ gate = step.gate
+ results.push(step.streaming)
+ }
+ return { gate, results }
+}
+
+describe('deriveMobileNativeChatStreaming', () => {
+ it('shows a genuine reply that repeats the previous turn as a prefix', () => {
+ const prior = [assistant('a1', 'The tests pass.')]
+ const { results } = run([
+ { folded: prior }, // idle tick anchors the pre-stream tail
+ { folded: prior, text: 'The' },
+ { folded: prior, text: 'The tests' },
+ { folded: prior, text: 'The tests pass.' }
+ ])
+ expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.'])
+ })
+
+ it('hides the bubble once the real turn lands leading with the streamed text', () => {
+ const prior = [assistant('a1', 'earlier turn')]
+ const landed = [...prior, assistant('a2', 'fresh answer with a tail')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'fresh answer' },
+ { folded: landed, text: 'fresh answer' }
+ ])
+ expect(results).toEqual([null, 'fresh answer', null])
+ })
+
+ it('suppresses an identical repeated reply once its own turn lands', () => {
+ const prior = [assistant('a1', 'Done.')]
+ const landed = [...prior, assistant('a2', 'Done.')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible
+ { folded: landed, text: 'Done.' } // its own turn landed — hide
+ ])
+ expect(results).toEqual([null, 'Done.', null])
+ })
+
+ it('keeps hiding for the rest of a segment after the turn lands', () => {
+ const prior = [assistant('a1', 'earlier')]
+ const landed = [...prior, assistant('a2', 'answer body')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'answer' },
+ { folded: landed, text: 'answer' },
+ { folded: landed, text: 'answer bo' }
+ ])
+ expect(results).toEqual([null, 'answer', null, null])
+ })
+
+ it('keeps the segment baseline through textless ticks while the turn is live', () => {
+ // Chat is hidden mid-stream: the transcript unsubscribes and the status
+ // stops reaching the gate, but the turn has not ended. Coming back, the
+ // stream text returns before the re-read transcript does.
+ const prior = [assistant('a1', 'Done.')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'Done.', live: true },
+ { folded: [], live: true },
+ { folded: [], text: 'Done.', live: true },
+ { folded: prior, text: 'Done.', live: true }
+ ])
+ expect(results).toEqual([null, 'Done.', null, 'Done.', 'Done.'])
+ })
+
+ it('still hides after a hidden gap once the reply landed as its own turn', () => {
+ const prior = [assistant('a1', 'Done.')]
+ const landed = [...prior, assistant('a2', 'Done.')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'Done.', live: true },
+ { folded: [], live: true },
+ { folded: landed, text: 'Done.', live: true }
+ ])
+ expect(results).toEqual([null, 'Done.', null, null])
+ })
+
+ it('hides a reply whose own turn landed before its status text arrived', () => {
+ // The pane stays `working` past the reply (a subagent or a background task
+ // is still live), and the transcript push beats the throttled status text.
+ // Anchoring on that textless tick would adopt the reply as pre-stream
+ // history and render it a second time as a bubble.
+ const prior = [assistant('a1', 'Done.')]
+ const landed = [...prior, assistant('a2', 'Done.')]
+ const { results } = run([
+ { folded: prior, live: true },
+ { folded: landed, live: true },
+ { folded: landed, text: 'Done.', live: true },
+ { folded: landed, text: 'Done.', live: true }
+ ])
+ expect(results).toEqual([null, null, null, null])
+ })
+
+ it('keeps the pre-stream baseline across a hidden gap taken between turns', () => {
+ // Peeking at the terminal while idle tears the transcript down to empty. An
+ // empty tail is not history: adopting it strands the baseline and swallows
+ // the repeated-prefix reply that arrives next.
+ const prior = [assistant('a1', 'Done.')]
+ const { results } = run([
+ { folded: prior },
+ { folded: [] },
+ { folded: [], live: true },
+ { folded: prior, text: 'Done.', live: true }
+ ])
+ expect(results).toEqual([null, null, null, 'Done.'])
+ })
+
+ it('anchors on the first tail it sees when mounted mid-turn', () => {
+ // Opening a workspace whose agent is already working: that first textless
+ // tick is the only pre-stream history the gate will ever get.
+ const prior = [assistant('a1', 'Done.')]
+ const { results } = run([
+ { folded: prior, live: true },
+ { folded: prior, text: 'Done.', live: true }
+ ])
+ expect(results).toEqual([null, 'Done.'])
+ })
+
+ it('anchors on a textless tick once the turn ends', () => {
+ const prior = [assistant('a1', 'first answer')]
+ const landed = [...prior, assistant('a2', 'second answer')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'second answer', live: true },
+ { folded: landed },
+ { folded: landed, text: 'second answer', live: true }
+ ])
+ expect(results).toEqual([null, 'second answer', null, 'second answer'])
+ })
+
+ it('does not treat the previous turn as a segment start after re-anchoring', () => {
+ // The textless anchor clears the remembered text too. Keeping it would read
+ // the next turn's opener as a new segment, re-anchor onto the reply that
+ // just landed, and render it a second time as a bubble.
+ const prior = [assistant('a1', 'context')]
+ const firstLanded = [...prior, assistant('a2', 'Alpha done')]
+ const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'Alpha', live: true },
+ { folded: firstLanded }, // turn ended — re-anchor onto a2
+ { folded: secondLanded, text: 'Beta', live: true } // a3 already landed
+ ])
+ expect(results).toEqual([null, 'Alpha', null, null])
+ })
+
+ it('re-anchors when a new reply part replaces the stream mid-turn', () => {
+ const prior = [assistant('a1', 'context')]
+ const partOneLanded = [...prior, assistant('a2', 'part one full text')]
+ const { results } = run([
+ { folded: prior },
+ { folded: prior, text: 'part one' },
+ { folded: partOneLanded, text: 'part one' }, // caught up — hide
+ // Part two is not an extension of part one: new segment, new baseline.
+ { folded: partOneLanded, text: 'part' }
+ ])
+ expect(results).toEqual([null, 'part one', null, 'part'])
+ })
+
+ it('falls back to suppress-on-prefix when text arrives on the first tick', () => {
+ // No tail ever observed before the text: a duplicate bubble is worse than
+ // briefly hiding a mount-coincident repeated reply.
+ const landed = [assistant('a1', 'flushed part still streaming in status')]
+ const { results } = run([{ folded: landed, text: 'flushed part' }])
+ expect(results).toEqual([null])
+ })
+
+ it('is idempotent for a repeated tick', () => {
+ const prior = [assistant('a1', 'The tests pass.')]
+ const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }])
+ const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests')
+ expect(again.streaming).toBe('The tests')
+ expect(again.gate).toBe(first.gate)
+ })
+
+ it('drops a prior chat baseline when the stream identity changes', () => {
+ // The other chat's tail must not license showing a bubble here — a swapped
+ // scope resets to the mid-stream fallback rather than reusing its baseline.
+ const repeatedId = [assistant('a1', 'new answer landed')]
+ let gate = createMobileNativeChatStreamingGate('tab-a')
+ gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate
+
+ const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', {
+ scopeKey: 'tab-b'
+ })
+
+ expect(switched.streaming).toBeNull()
+ expect(switched.gate.scopeKey).toBe('tab-b')
+ expect(switched.gate.baselineTailId).toBeNull()
+ })
+
+ it('returns null for empty or whitespace streaming text', () => {
+ const prior = [assistant('a1', 'x')]
+ expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null])
+ expect(run([{ folded: prior }]).results).toEqual([null])
+ })
+
+ it('shows the first reply of an empty chat and hides it once the turn lands', () => {
+ const landed = [assistant('a1', 'Hello there')]
+ const { results } = run([
+ { folded: [] },
+ { folded: [], text: 'Hello' },
+ { folded: landed, text: 'Hello' }
+ ])
+ expect(results).toEqual([null, 'Hello', null])
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts
new file mode 100644
index 00000000000..a857803214c
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts
@@ -0,0 +1,93 @@
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+
+/** Decides whether the live streaming preview should render as a synthetic
+ * bubble. Text alone can't tell "the transcript caught up with this stream"
+ * from "a new reply happens to repeat the previous turn's prefix" — the old
+ * prefix test swallowed genuine repeated-prefix replies. The gate keeps the
+ * transcript tail observed when the current stream segment began: the bubble
+ * hides only when the tail MOVED during the segment and leads with the
+ * streamed text (the real turn landed), never for an older identical turn. */
+export type MobileNativeChatStreamingGate = {
+ /** Chat/session identity this baseline belongs to. */
+ scopeKey: string | null
+ /** Streamed text seen on the previous tick ('' while idle). */
+ prevText: string
+ /** Folded tail message id when the current segment began; null while the
+ * gate has never observed a transcript tail (text arrived on its very first
+ * tick), where the legacy suppress-on-prefix rule applies. */
+ baselineTailId: string | null
+}
+
+export function createMobileNativeChatStreamingGate(
+ scopeKey: string | null = null
+): MobileNativeChatStreamingGate {
+ return { scopeKey, prevText: '', baselineTailId: null }
+}
+
+function assistantTailText(tail: NativeChatMessage | undefined): string {
+ if (!tail || tail.role !== 'assistant') {
+ return ''
+ }
+ return tail.blocks
+ .filter((block) => block.type === 'text')
+ .map((block) => (block.type === 'text' ? block.text : ''))
+ .join('')
+ .trim()
+}
+
+// Reuses the incoming gate object when nothing moved, so a caller can detect
+// "no change" by reference (and a render-time state adjustment can settle).
+function advanceGate(
+ gate: MobileNativeChatStreamingGate,
+ prevText: string,
+ baselineTailId: string | null
+): MobileNativeChatStreamingGate {
+ return gate.prevText === prevText && gate.baselineTailId === baselineTailId
+ ? gate
+ : { ...gate, prevText, baselineTailId }
+}
+
+/** Advance the gate one tick and derive the visible streaming text (null hides
+ * the bubble). Pure and idempotent for a repeated (text, tail) pair, so a
+ * re-render without new data cannot flip the decision. */
+export function deriveMobileNativeChatStreaming(
+ gate: MobileNativeChatStreamingGate,
+ folded: readonly NativeChatMessage[],
+ streamingText: string | undefined,
+ options: {
+ scopeKey?: string | null
+ /** Whether the agent is still mid-turn. A textless tick then means "no
+ * observation this render", not "the stream ended". */
+ streamLive?: boolean
+ } = {}
+): { gate: MobileNativeChatStreamingGate; streaming: string | null } {
+ const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey
+ const scopedGate =
+ gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey)
+ const text = streamingText?.trim() ?? ''
+ const tail = folded.at(-1)
+ const tailId = tail?.id ?? null
+ if (!text) {
+ // Only a textless tick that carries a real tail and is outside a live turn
+ // is trustworthy pre-stream history. Mid-turn gaps (a tool call, a throttle
+ // lull, the transcript landing the reply before its status text) would
+ // otherwise adopt that reply as history and render it a second time as a
+ // bubble; a torn-down transcript carries no tail at all. The exception is a
+ // gate that has never anchored — mounted mid-turn, the first real tail it
+ // sees is the best pre-stream history it will ever get.
+ const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null)
+ return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null }
+ }
+ // A stream that is not an extension of the previous tick is a new segment
+ // (next reply part); re-anchor to the tail that predates it.
+ const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText)
+ const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId
+ const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
+ // A null baseline (text on the very first tick, no tail ever seen) is unequal
+ // to every real tail id, so this degrades to the legacy suppress-on-prefix rule.
+ const caughtUp = tailLeadsWithStream && tailId !== baselineTailId
+ return {
+ gate: advanceGate(scopedGate, text, baselineTailId),
+ streaming: caughtUp ? null : text
+ }
+}
diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts
index 2b4fb0e9d0f..35318d7e7f2 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts
@@ -47,8 +47,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
vi.mock('./use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
}))
+const answerSendArgs: { streamIdentity?: string }[] = []
vi.mock('./use-mobile-native-chat-answer-send', () => ({
- useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
+ useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => {
+ answerSendArgs.push(args)
+ return { answerAsk: vi.fn(), cancelPending: vi.fn() }
+ }
}))
vi.mock('./mobile-native-chat-permission-send', () => ({
useMobileNativeChatPermissionSend: () => vi.fn()
@@ -421,3 +425,99 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
})
})
+
+describe('useMobileNativeChatController streaming scope', () => {
+ let renderer: ReactTestRenderer | null = null
+ let controller: MobileNativeChatController | null = null
+ const clientStub = { sendRequest: vi.fn() }
+
+ const workingTab = {
+ type: 'terminal',
+ id: 'tab-1',
+ terminal: 'term-1',
+ launchAgent: 'claude',
+ agentStatus: {
+ state: 'working',
+ agentType: 'claude',
+ providerSession: { id: 'session-1' }
+ },
+ isActive: true
+ }
+
+ function Harness(): null {
+ controller = useMobileNativeChatController({
+ client: clientStub as unknown as RpcClient,
+ connState: 'connected',
+ hostId: 'h',
+ worktreeId: 'w',
+ activeSessionTab: workingTab as never,
+ activeSessionTabId: 'tab-1',
+ activeHandleRef: { current: 'term-1' },
+ deviceTokenRef: { current: null },
+ nativeChatTranscriptIsLocalReadable: true,
+ nativeChatInputLeaseReady: true,
+ onSendError: vi.fn(),
+ onSendResolved: vi.fn()
+ })
+ return null
+ }
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ viewMode.isTabChatView = () => true
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
+ if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...a)
+ })
+ try {
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ } finally {
+ spy.mockRestore()
+ }
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ controller = null
+ viewMode.isTabChatView = () => true
+ })
+
+ it('holds the stream scope and liveness while the user peeks at the terminal', () => {
+ // Both feed the streaming gate, which lives above the chat view's mount and
+ // must not be reset by a view toggle.
+ expect(controller?.showNativeChat).toBe(true)
+ const scopeKey = controller?.nativeChatStreamScopeKey
+ expect(scopeKey).toContain('session-1')
+ expect(controller?.nativeChatStreamLive).toBe(true)
+
+ viewMode.isTabChatView = () => false
+ act(() => renderer?.update(createElement(Harness)))
+
+ expect(controller?.showNativeChat).toBe(false)
+ expect(controller?.nativeChatAgentWorking).toBe(false)
+ expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey)
+ expect(controller?.nativeChatStreamLive).toBe(true)
+ })
+
+ it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
+ // `streamIdentity` fences a delayed answer/stop to the route it was armed
+ // on, so it must still drop its session when chat closes — the scope key is
+ // the one that has to survive the toggle. Same string while chat is open.
+ const before = answerSendArgs.at(-1)?.streamIdentity
+ expect(before).toBe(controller?.nativeChatStreamScopeKey)
+
+ viewMode.isTabChatView = () => false
+ act(() => renderer?.update(createElement(Harness)))
+
+ const after = answerSendArgs.at(-1)?.streamIdentity
+ expect(after).not.toBe(before)
+ expect(after).not.toContain('session-1')
+ expect(controller?.nativeChatStreamScopeKey).toBe(before)
+ })
+})
diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts
index fd0a564b19c..37f7b5399c2 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.ts
@@ -49,6 +49,10 @@ export type MobileNativeChatController = {
nativeChatSession: ReturnType
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
+ /** Agent mid-turn, regardless of whether chat is the visible view. */
+ nativeChatStreamLive: boolean
+ /** Host/workspace/tab/session scope for stateful streaming suppression. */
+ nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType
nativeChatQuestion: ReturnType
nativeChatAsk: ReturnType
@@ -124,7 +128,12 @@ export function useMobileNativeChatController(args: {
activeChatAgentRef.current = activeChatResolution?.agent ?? null
const activeChatSessionId = activeChatResolution?.sessionId ?? null
- const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
+ const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
+ const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
+ // Same chat, but keyed off the tab rather than the view-gated resolution:
+ // `streamIdentity` goes session-less the moment the user peeks at the terminal,
+ // and a scope that flips on a view toggle throws the gate's baseline away.
+ const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
const nativeChatSession = useMobileNativeChatSession({
client,
@@ -160,6 +169,9 @@ export function useMobileNativeChatController(args: {
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
+ // Deliberately not gated on the chat view being visible: the streaming gate
+ // has to tell "hidden mid-turn" from "the turn ended".
+ const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
// part, and each one re-renders and re-parses the whole accumulated markdown.
const nativeChatStreamingText = useThrottledLatestValue(
@@ -273,6 +285,8 @@ export function useMobileNativeChatController(args: {
nativeChatSession,
nativeChatAgentWorking,
nativeChatStreamingText,
+ nativeChatStreamLive,
+ nativeChatStreamScopeKey: streamScopeKey,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
diff --git a/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts
new file mode 100644
index 00000000000..632fa0b2282
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts
@@ -0,0 +1,31 @@
+import { useState } from 'react'
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import {
+ createMobileNativeChatStreamingGate,
+ deriveMobileNativeChatStreaming
+} from './mobile-native-chat-streaming-gate'
+
+/** Live streaming-bubble text for the chat list. The gate remembers which
+ * transcript tail predates the current stream segment, so a reply that repeats
+ * the previous turn's prefix still shows while streaming. Call this from a
+ * component that outlives the chat list itself: the baseline has to survive the
+ * view toggles that unmount it, or the next segment reverts to prefix-matching.
+ * `streamLive` keeps those textless gaps from reading as an idle stream. */
+export function useMobileNativeChatStreamingBubble(
+ folded: readonly NativeChatMessage[],
+ streamingText: string | undefined,
+ scopeKey: string,
+ streamLive: boolean
+): string | null {
+ const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey))
+ const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, {
+ scopeKey,
+ streamLive
+ })
+ if (step.gate !== gate) {
+ // Render-time state adjustment (derived-state pattern): the advance is
+ // idempotent for a repeated (text, tail) pair, so this settles in one pass.
+ setGate(step.gate)
+ }
+ return step.streaming
+}
From e138d28fa65916e7086449357e577a70e808bef7 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:37:59 -0700
Subject: [PATCH 03/42] Fix Linear filter chips showing UUIDs after dropdown
closes (#12564)
Fetch metadata when filters are selected, not only while the popover
is open, so chip labels remain readable after closing the dropdown.
---
...-issue-attribute-filter-dropdowns.test.tsx | 109 +++++++++++++++++-
...inear-issue-attribute-filter-dropdowns.tsx | 13 ++-
tests/e2e/linear-filter-chip-labels.spec.ts | 105 +++++++++++++++++
3 files changed, 222 insertions(+), 5 deletions(-)
create mode 100644 tests/e2e/linear-filter-chip-labels.spec.ts
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx
index 753e24872dc..845ed09b0d3 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx
+++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx
@@ -1,10 +1,48 @@
-import { describe, expect, it } from 'vitest'
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { LinearTeam } from '../../../shared/types'
import {
clearLinearIssueAttributeFacet,
countLinearIssueAttributeFilters,
linearIssueAttributeFilterPillLabels
} from './linear-issue-attribute-filter-sections'
import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter'
+import LinearIssueAttributeFilterDropdowns from './linear-issue-attribute-filter-dropdowns'
+
+const metadataMocks = vi.hoisted(() => ({
+ useTeamsStates: vi.fn((teamIds: readonly string[]) => ({
+ data: teamIds.length > 0 ? [{ id: 'state-1', name: 'Todo' }] : [],
+ loading: false,
+ error: null
+ })),
+ useTeamsLabels: vi.fn((teamIds: readonly string[]) => ({
+ data: teamIds.length > 0 ? [{ id: 'label-1', name: 'Bug' }] : [],
+ loading: false,
+ error: null
+ })),
+ useTeamsMembers: vi.fn((teamIds: readonly string[]) => ({
+ data: teamIds.length > 0 ? [{ id: 'member-1', displayName: 'Ada Lovelace' }] : [],
+ loading: false,
+ error: null
+ }))
+}))
+
+vi.mock('@/hooks/useIssueMetadata', () => metadataMocks)
+
+const roots: Root[] = []
+
+afterEach(() => {
+ roots.splice(0).forEach((root) => {
+ act(() => root.unmount())
+ })
+ document.body.replaceChildren()
+ metadataMocks.useTeamsStates.mockClear()
+ metadataMocks.useTeamsLabels.mockClear()
+ metadataMocks.useTeamsMembers.mockClear()
+})
const sample: LinearIssueAttributeFilter = {
stateIds: ['s1', 's2'],
@@ -38,3 +76,72 @@ describe('linear-issue-attribute-filter helpers', () => {
expect(pills[3]?.value).toBe('Bug')
})
})
+
+describe('LinearIssueAttributeFilterDropdowns', () => {
+ it('keeps metadata lazy while closed with only static filter labels', () => {
+ const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ roots.push(root)
+
+ act(() => {
+ root.render(
+ undefined}
+ workspaceId="workspace-1"
+ isAllWorkspaces={false}
+ primaryTeam={team}
+ selectedTeamIds={[]}
+ availableTeams={[team]}
+ />
+ )
+ })
+
+ expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith([], undefined, null)
+ expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith([], undefined, null)
+ expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith([], undefined, null)
+ })
+
+ it('keeps readable metadata names available after the popover closes', () => {
+ const value: LinearIssueAttributeFilter = {
+ stateIds: ['state-1'],
+ priorities: [],
+ assignee: { kind: 'user', id: 'member-1' },
+ labelIds: ['label-1']
+ }
+ const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' }
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ roots.push(root)
+
+ act(() => {
+ root.render(
+ undefined}
+ workspaceId="workspace-1"
+ isAllWorkspaces={false}
+ primaryTeam={team}
+ selectedTeamIds={[]}
+ availableTeams={[team]}
+ />
+ )
+ })
+
+ expect(container.textContent).toContain('Todo')
+ expect(container.textContent).toContain('Ada Lovelace')
+ expect(container.textContent).toContain('Bug')
+ expect(container.textContent).not.toContain('state-1')
+ expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
+ expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
+ expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1')
+ })
+})
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx
index fa47d6def6b..9cf550e2fb3 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx
+++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx
@@ -76,9 +76,15 @@ export default function LinearIssueAttributeFilterDropdowns({
}: Props): React.JSX.Element {
const [popoverOpen, setPopoverOpen] = useState(false)
const [openSection, setOpenSection] = useState(null)
+ const activeCount = countLinearIssueAttributeFilters(value)
+ const metadataNeeded =
+ popoverOpen ||
+ value.stateIds.length > 0 ||
+ value.labelIds.length > 0 ||
+ value.assignee?.kind === 'user'
const activeTeamIds = useMemo(() => {
- if (!popoverOpen || isAllWorkspaces) {
+ if (!metadataNeeded || isAllWorkspaces) {
return [] as string[]
}
return resolveLinearIssueAttributeFilterTeamIds({
@@ -86,10 +92,10 @@ export default function LinearIssueAttributeFilterDropdowns({
availableTeams,
primaryTeamId: primaryTeam?.id ?? null
})
- }, [popoverOpen, isAllWorkspaces, selectedTeamIds, availableTeams, primaryTeam?.id])
+ }, [metadataNeeded, isAllWorkspaces, selectedTeamIds, availableTeams, primaryTeam?.id])
const concreteWorkspaceId =
- popoverOpen && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null
+ metadataNeeded && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null
// Why: multi-team / All teams must union filter options across every selected team (#8739).
const states = useTeamsStates(activeTeamIds, settings, concreteWorkspaceId)
@@ -179,7 +185,6 @@ export default function LinearIssueAttributeFilterDropdowns({
[members.data]
)
- const activeCount = countLinearIssueAttributeFilters(value)
const pills = linearIssueAttributeFilterPillLabels({
value,
stateNamesById,
diff --git a/tests/e2e/linear-filter-chip-labels.spec.ts b/tests/e2e/linear-filter-chip-labels.spec.ts
new file mode 100644
index 00000000000..1ccb3d06921
--- /dev/null
+++ b/tests/e2e/linear-filter-chip-labels.spec.ts
@@ -0,0 +1,105 @@
+import type { ElectronApplication, Page } from '@stablyai/playwright-test'
+import { test, expect } from './helpers/orca-app'
+import { getStoreState, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+
+const FIXTURE = {
+ workspace: {
+ id: 'linear-workspace-3393',
+ displayName: 'Linear E2E User',
+ email: 'linear-e2e@example.test',
+ organizationId: 'linear-org-3393',
+ organizationName: 'Linear E2E Workspace'
+ },
+ team: {
+ id: 'linear-team-3393',
+ name: 'Engineering',
+ key: 'ENG'
+ },
+ state: {
+ id: 'linear-state-uuid-3393',
+ name: 'In Review',
+ type: 'started',
+ color: '#888888',
+ position: 1
+ },
+ issue: {
+ id: 'linear-issue-3393',
+ workspaceId: 'linear-workspace-3393',
+ identifier: 'ENG-3393',
+ title: 'Keep filter chip labels readable',
+ url: 'https://linear.example.test/ENG-3393',
+ state: { name: 'In Review', type: 'started', color: '#888888' },
+ team: { id: 'linear-team-3393', name: 'Engineering', key: 'ENG' },
+ labels: [],
+ labelIds: [],
+ priority: 0,
+ updatedAt: '2026-08-04T18:00:00.000Z'
+ }
+} as const
+
+async function installLinearFilterBackend(electronApp: ElectronApplication): Promise {
+ await electronApp.evaluate(({ ipcMain }, fixture) => {
+ ipcMain.removeHandler('linear:status')
+ ipcMain.handle('linear:status', async () => ({
+ connected: true,
+ viewer: fixture.workspace,
+ workspaces: [fixture.workspace],
+ activeWorkspaceId: fixture.workspace.id,
+ selectedWorkspaceId: fixture.workspace.id
+ }))
+
+ ipcMain.removeHandler('linear:listTeams')
+ ipcMain.handle('linear:listTeams', async () => [fixture.team])
+
+ ipcMain.removeHandler('linear:listIssues')
+ ipcMain.handle('linear:listIssues', async () => ({ items: [fixture.issue], hasMore: false }))
+
+ ipcMain.removeHandler('linear:teamStates')
+ ipcMain.handle('linear:teamStates', async () => [fixture.state])
+
+ ipcMain.removeHandler('linear:teamLabels')
+ ipcMain.handle('linear:teamLabels', async () => [])
+
+ ipcMain.removeHandler('linear:teamMembers')
+ ipcMain.handle('linear:teamMembers', async () => [])
+ }, FIXTURE)
+}
+
+async function openLinearTasks(page: Page): Promise {
+ await page.evaluate(async () => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is not available')
+ }
+ await store.getState().checkLinearConnection(true)
+ store.getState().openTaskPage({ taskSource: 'linear' })
+ })
+}
+
+test('Linear filter chips keep readable names after the dropdown closes', async ({
+ electronApp,
+ orcaPage
+}) => {
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ await installLinearFilterBackend(electronApp)
+ await openLinearTasks(orcaPage)
+
+ await expect
+ .poll(() => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 })
+ .toBe('tasks')
+ const filtersButton = orcaPage.getByRole('button', { name: 'Filters', exact: true })
+ await expect(filtersButton).toBeVisible()
+ await expect(orcaPage.getByText(FIXTURE.issue.title, { exact: true })).toBeVisible()
+
+ await filtersButton.click()
+ const popover = orcaPage.locator('[data-slot="popover-content"]')
+ await popover.getByRole('button', { name: 'Status', exact: true }).click()
+ await popover.getByText(FIXTURE.state.name, { exact: true }).click()
+ await filtersButton.click()
+ await expect(popover).toHaveCount(0)
+
+ const statusChip = orcaPage.getByRole('button', { name: 'Remove Status filter' }).locator('..')
+ await expect(statusChip).toContainText(FIXTURE.state.name)
+ await expect(statusChip).not.toContainText(FIXTURE.state.id)
+})
From 9759bd2e76470aa642d17414a075b1d998506e41 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:40:36 -0700
Subject: [PATCH 04/42] fix(mobile): center native chat checkmarks (#12565)
---
mobile/src/session/MobileNativeChatAsk.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx
index b485818970c..86fcb0dc4a2 100644
--- a/mobile/src/session/MobileNativeChatAsk.tsx
+++ b/mobile/src/session/MobileNativeChatAsk.tsx
@@ -282,6 +282,7 @@ const styles = StyleSheet.create({
},
option: {
flexDirection: 'row',
+ alignItems: 'center',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radii.card,
@@ -299,8 +300,7 @@ const styles = StyleSheet.create({
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
- justifyContent: 'center',
- marginTop: 1
+ justifyContent: 'center'
},
checkCircle: {
borderRadius: 9
From d27d69fbffc2baf0cd29f609d4b340ac4762b219 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:01:39 -0700
Subject: [PATCH 05/42] fix(mobile): serialize native chat PTY writes and fence
superseded ask keystrokes (STA-3333) (#12502)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mobile): serialize native chat PTY writes
Two composed native-chat write sequences into one PTY interleaved their
bytes: the per-terminal send-in-flight guard lived inside the image
attachments hook, so ask answers, permission choices, and question
answers wrote straight past it. Move the guard to a shared module-scope
write lock (the terminal outlives any one screen) and take it on all
four paths.
Ask answers additionally queue behind the prior chain's RPC rather than
racing it, and a superseding answer is fenced once a key has actually
landed: an accepted or ambiguously-delivered keystroke already moved the
remote selector, so a replacement's from-scratch key plan would answer
the wrong question. A superseding answer inherits the cancelled chain's
hold (refcounted, last chain out releases) so changing your mind
mid-answer still works, and Stop/cancel stay unguarded so an interrupt
can never deadlock against the send it cancels.
Refs STA-3333.
* fix(mobile): report a fenced native-chat answer instead of dropping it
The fence added for superseding Ask answers returned false with no
onSendError, and the card re-enables on a false result — so a queued
answer vanished with no banner and no toast, looking exactly like a dead
button. Every other bail in answerAsk reports. Keep the generation-
mismatch branch silent: a newer answer owns the error surface.
Also covers the hold-count release, which had no test at all: replacing
it with an unconditional release left all 58 tests green while silently
reopening the terminal mid-sequence — the exact interleave this PR fixes.
* fix(mobile): stop a superseded answer from clearing the fence banner
A chain that finished its key plan reported `true` even after a newer answer
superseded it. The route sends through useNativeChatAcceptedAction, whose
accepted callback retires the send-error banner — and that callback runs after
the successor's fence report, because finishTurn() fires in `finally`, before
the chain's own promise settles. So the successful predecessor deterministically
wiped the fence message the successor had just raised: every healthy write took
that branch, which made the previous commit's report vacuous exactly where it
mattered.
A superseded chain now reports no success, matching every other supersession
checkpoint in this hook.
* fix(mobile): fence on the turn slot, not the generation counter
The supersession guards added in c36f2ce14e read `generationRef`, but
`cancelPending()` bumps that counter from three callers with no successor
chain: Stop, ask-cancel, and the lease effect that fires on every
disconnect. An answer whose key had already landed then reported false —
`fail()` never runs on an accepted write, so the card stayed up silent
with Submit re-enabled, and the retry wrote the same key into a selector
that key had already advanced.
Test the turn slot instead: a successor takes it synchronously before
its first await and cannot resolve ahead of this chain, so it means
"a successor took over" exactly, without catching bare cancellations.
* fix(mobile): report a fenced answer when a dropped lease bumps the generation
The fence-report guard tested generationRef, which answers "was I
cancelled", not "did a successor replace me". Stop and ask-cancel both
write an Escape that clears the ask card, so their silence is harmless.
A dropped input lease bumps the same counter and writes nothing: the
card stays up with Submit re-enabled while the predecessor's option key
has already advanced the live selector, so the retry double-steps it.
Gate on the turn slot, matching the two success returns.
* test(mobile): cover the turn-slot release after a landed answer
The slot delete in the finally was the one line in this hook no test
killed: without it a landed answer's resolved-false turn stays parked,
so every later answer on that handle reads a fenced predecessor and the
ask card dies after its first use.
* test(mobile): keep PTY write locks terminal-scoped
---
...mobile-native-chat-permission-send.test.ts | 44 ++
.../mobile-native-chat-permission-send.ts | 27 +-
...le-native-chat-terminal-write-lock.test.ts | 20 +
.../mobile-native-chat-terminal-write-lock.ts | 25 +
...use-mobile-native-chat-answer-send.test.ts | 527 ++++++++++++++++++
.../use-mobile-native-chat-answer-send.ts | 284 ++++++----
...bile-native-chat-image-attachments.test.ts | 6 +-
...se-mobile-native-chat-image-attachments.ts | 16 +-
...se-mobile-native-chat-message-send.test.ts | 33 +-
.../use-mobile-native-chat-message-send.ts | 27 +-
10 files changed, 885 insertions(+), 124 deletions(-)
create mode 100644 mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts
create mode 100644 mobile/src/session/mobile-native-chat-terminal-write-lock.ts
diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts
index 1b595683112..253f8c8b85a 100644
--- a/mobile/src/session/mobile-native-chat-permission-send.test.ts
+++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts
@@ -13,6 +13,11 @@ import {
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite,
+ resetMobileNativeChatTerminalWritesForTests
+} from './mobile-native-chat-terminal-write-lock'
describe('sendMobileNativeChatPermissionResponse', () => {
it('writes an approval as raw bytes without appending Return', async () => {
@@ -64,6 +69,7 @@ describe('useMobileNativeChatPermissionSend', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetMobileNativeChatStaleInputForTests()
+ resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
@@ -102,4 +108,42 @@ describe('useMobileNativeChatPermissionSend', () => {
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
})
+
+ it('rejects a choice while another composed write holds the terminal, then recovers', async () => {
+ const onSendError = vi.fn()
+ const sendRequest = vi.fn().mockResolvedValue({
+ ok: true,
+ result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
+ })
+ function Harness(): null {
+ respond = useMobileNativeChatPermissionSend({
+ client: { sendRequest } as unknown as RpcClient,
+ enabled: true,
+ handleRef: { current: 'terminal' },
+ deviceTokenRef: { current: null },
+ onSendError
+ })
+ return null
+ }
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+
+ // An image paste sequence is mid-flight into the same PTY: the choice
+ // keystroke must not interleave into it.
+ expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
+ await act(async () => {
+ await expect(respond?.('1')).resolves.toBe(false)
+ })
+ expect(sendRequest).not.toHaveBeenCalled()
+ expect(onSendError).toHaveBeenCalledWith('Response not sent')
+
+ releaseMobileNativeChatTerminalWrite('terminal')
+ await act(async () => {
+ await expect(respond?.('1')).resolves.toBe(true)
+ })
+ // The choice released its own hold on the way out.
+ expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
+ releaseMobileNativeChatTerminalWrite('terminal')
+ })
})
diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts
index b06ff88fe48..7f58b0ab44e 100644
--- a/mobile/src/session/mobile-native-chat-permission-send.ts
+++ b/mobile/src/session/mobile-native-chat-permission-send.ts
@@ -4,6 +4,10 @@ import {
sendMobileNativeChatMessageWithOutcome,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite
+} from './mobile-native-chat-terminal-write-lock'
export function sendMobileNativeChatPermissionResponse(args: {
client: RpcClient
@@ -36,15 +40,26 @@ export function useMobileNativeChatPermissionSend(args: {
args.onSendError('Response not sent (disconnected)')
return false
}
+ // A choice keystroke must not interleave into a mid-flight composed write
+ // (image paste, paced answer) on the same PTY.
+ if (!acquireMobileNativeChatTerminalWrite(terminal)) {
+ args.onSendError('Response not sent')
+ return false
+ }
// No stale-input heal here (unlike the text/ask sends): a choice is an
// `enter: false` key for an active overlay that swallows the clear, so it
// would consume the marker still protecting the next real message.
- const outcome = await sendMobileNativeChatPermissionResponse({
- client: args.client,
- terminal,
- deviceToken: args.deviceTokenRef.current,
- text
- })
+ let outcome: MobileNativeChatSendOutcome
+ try {
+ outcome = await sendMobileNativeChatPermissionResponse({
+ client: args.client,
+ terminal,
+ deviceToken: args.deviceTokenRef.current,
+ text
+ })
+ } finally {
+ releaseMobileNativeChatTerminalWrite(terminal)
+ }
if (outcome === 'unknown') {
// Why: the response may have been delivered (ack lost / path cutover) —
// a definite "not sent" would invite a double answer.
diff --git a/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts b/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts
new file mode 100644
index 00000000000..c15ce3c826e
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts
@@ -0,0 +1,20 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite,
+ resetMobileNativeChatTerminalWritesForTests
+} from './mobile-native-chat-terminal-write-lock'
+
+describe('mobile-native-chat-terminal-write-lock', () => {
+ afterEach(resetMobileNativeChatTerminalWritesForTests)
+
+ it('allows composed writes on different terminals to proceed concurrently', () => {
+ expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(true)
+
+ expect(acquireMobileNativeChatTerminalWrite('terminal-b')).toBe(true)
+ expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(false)
+
+ releaseMobileNativeChatTerminalWrite('terminal-a')
+ releaseMobileNativeChatTerminalWrite('terminal-b')
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-terminal-write-lock.ts b/mobile/src/session/mobile-native-chat-terminal-write-lock.ts
new file mode 100644
index 00000000000..5624a384ed3
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-terminal-write-lock.ts
@@ -0,0 +1,25 @@
+// Serializes composed native-chat write sequences (clear/paste/settle/submit,
+// paced answer keystrokes) per HOST terminal. Two concurrent sequences into one
+// PTY interleave their bytes; a second sender must be rejected up front, not
+// woven in. Module scope for the same reason as the stale-input marker: the
+// terminal outlives any one screen, and independent hooks share the same PTY.
+const writeInFlightTerminals = new Set()
+
+/** Claim the terminal for one composed write sequence. False = another
+ * sequence is mid-flight; the caller must reject its send. */
+export function acquireMobileNativeChatTerminalWrite(terminal: string): boolean {
+ if (writeInFlightTerminals.has(terminal)) {
+ return false
+ }
+ writeInFlightTerminals.add(terminal)
+ return true
+}
+
+export function releaseMobileNativeChatTerminalWrite(terminal: string): void {
+ writeInFlightTerminals.delete(terminal)
+}
+
+/** Test-only: module scope outlives a single test's hooks. */
+export function resetMobileNativeChatTerminalWritesForTests(): void {
+ writeInFlightTerminals.clear()
+}
diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts
index 8f22884145e..b296ea4851b 100644
--- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts
@@ -11,7 +11,13 @@ import {
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite,
+ resetMobileNativeChatTerminalWritesForTests
+} from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
+import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
type AnswerSend = ReturnType
@@ -40,17 +46,24 @@ describe('useMobileNativeChatAnswerSend', () => {
let mountedClient: RpcClient | null = null
let mountedOnSendError: ((message: string) => void) | null = null
let mountedAgent: AgentType = 'claude'
+ // The route sends through useNativeChatAcceptedAction, whose accepted callback
+ // retires the shared send-error banner (use-mobile-native-chat-controller.ts).
+ let acceptedAnswerAsk: AnswerSend['answerAsk'] | null = null
+ let onAccepted = vi.fn()
beforeEach(() => {
+ onAccepted = vi.fn()
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetMobileNativeChatStaleInputForTests()
+ resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
answerSend = null
+ acceptedAnswerAsk = null
mountedClient = null
mountedOnSendError = null
mountedAgent = 'claude'
@@ -68,6 +81,7 @@ describe('useMobileNativeChatAnswerSend', () => {
streamIdentity: 'host\0worktree\0tab\0session',
onSendError: mountedOnSendError!
})
+ acceptedAnswerAsk = useNativeChatAcceptedAction(answerSend.answerAsk, onAccepted)
return null
}
@@ -396,4 +410,517 @@ describe('useMobileNativeChatAnswerSend', () => {
await expect(result).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
})
+
+ it('rejects an answer while another composed write holds the terminal', async () => {
+ const onSendError = vi.fn()
+ const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
+ await mount({ sendRequest } as unknown as RpcClient, onSendError)
+
+ // An image paste sequence is mid-flight into the same PTY.
+ expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
+ await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false)
+ expect(sendRequest).not.toHaveBeenCalled()
+ expect(onSendError).toHaveBeenCalledWith('Answer not sent')
+
+ // Once that sequence releases, answers flow again.
+ releaseMobileNativeChatTerminalWrite('terminal')
+ await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
+ })
+
+ it('answers again on the same handle after an earlier answer already landed', async () => {
+ const onSendError = vi.fn()
+ const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
+ await mount({ sendRequest } as unknown as RpcClient, onSendError)
+
+ await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])).resolves.toBe(true)
+ // A landed answer resolves its turn FALSE — correct for a queued successor,
+ // fatal if the turn outlives the chain. Leaving it parked in the slot fences
+ // every later answer on this handle for the life of the hook, not just an
+ // overlapping one, so the ask card dies after its first use.
+ await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
+ expect(sendRequest).toHaveBeenCalledTimes(2)
+ expect(onSendError).not.toHaveBeenCalled()
+ })
+
+ it('fences a superseding answer after the cancelled chain moved the selector', async () => {
+ const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn())
+
+ const prompt: AskPrompt = {
+ questions: [
+ { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
+ { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
+ ]
+ }
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }])
+ })
+ // The first digit already advanced Claude to q2. Replaying a from-q1 key
+ // plan now would answer the wrong question.
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ })
+ await act(async () => vi.runAllTimersAsync())
+
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ // Both chains unwound: the terminal is free for the next composed write.
+ expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
+ releaseMobileNativeChatTerminalWrite('terminal')
+ })
+
+ it('does not write a successor after the prior in-flight key is accepted', async () => {
+ let resolveFirst: (response: unknown) => void = () => undefined
+ const sendRequest = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve
+ })
+ )
+ .mockResolvedValue(acceptedResponse())
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn())
+
+ const prompt: AskPrompt = {
+ questions: [
+ { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
+ { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
+ ]
+ }
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+
+ // The successor is queued behind the in-flight key, not racing it.
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ resolveFirst(acceptedResponse())
+ await Promise.resolve()
+ })
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('lets a queued successor continue after the prior key is definitely rejected', async () => {
+ let resolveFirst: (response: unknown) => void = () => undefined
+ const sendRequest = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve
+ })
+ )
+ .mockResolvedValue(acceptedResponse())
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn())
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ resolveFirst({
+ ...acceptedResponse(),
+ result: { send: { accepted: false } }
+ })
+ await Promise.resolve()
+ })
+
+ // Nothing landed, so the selector never moved — the successor is safe.
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(true)
+ expect(sendRequest).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not write a successor after the prior delivery becomes ambiguous', async () => {
+ let rejectFirst: (error: Error) => void = () => undefined
+ const sendRequest = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirst = reject
+ })
+ )
+ .mockResolvedValue(acceptedResponse())
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn())
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed')))
+ await Promise.resolve()
+ })
+
+ // The key may have landed; a blind successor could double-step the selector.
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('tells the user when a queued answer is fenced instead of dropping it silently', async () => {
+ const onSendError = vi.fn()
+ let rejectFirst: (error: Error) => void = () => undefined
+ const sendRequest = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirst = reject
+ })
+ )
+ .mockResolvedValue(acceptedResponse())
+ await mount({ sendRequest } as unknown as RpcClient, onSendError)
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed')))
+ await Promise.resolve()
+ })
+
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ // The card re-enables on a false result, so an unreported fence looks exactly
+ // like a dead button. The superseded chain stays quiet — the newest answer
+ // owns the error surface, and it is the one the user is waiting on.
+ expect(onSendError).toHaveBeenCalledTimes(1)
+ expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
+ })
+
+ it('tells the user when a dropped lease fences a queued answer whose predecessor landed', async () => {
+ const onSendError = vi.fn()
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, onSendError)
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ // A transport blip, not a user cancel: unlike Stop and ask-cancel, the lost
+ // input lease writes no Escape, so the card stays up with Submit re-enabled.
+ await setEnabled(false)
+ await act(async () => {
+ settle[0]!(acceptedResponse())
+ await Promise.resolve()
+ })
+
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ // The first option key LANDED, so the live selector already moved. Reporting
+ // nothing invites a retry that double-steps it.
+ expect(onSendError).toHaveBeenCalledTimes(1)
+ expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
+ })
+
+ it('keeps the terminal locked while a queued successor writes', async () => {
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn())
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+
+ // Nothing landed, so the successor is cleared to write.
+ await act(async () => {
+ settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } })
+ await Promise.resolve()
+ })
+ expect(sendRequest).toHaveBeenCalledTimes(2)
+ // The successor's key is on the wire. The superseded chain unwinding behind it
+ // must NOT free the terminal, or an image paste interleaves into this sequence.
+ expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(false)
+
+ await act(async () => {
+ settle[1]!(acceptedResponse())
+ await Promise.resolve()
+ })
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(true)
+ expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
+ releaseMobileNativeChatTerminalWrite('terminal')
+ })
+
+ it('does not retire the fence banner when the superseded answer lands', async () => {
+ const onSendError = vi.fn()
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, onSendError)
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ // The healthy path: the first answer LANDS, which is also the case that fences
+ // hardest — its key moved the live selector.
+ await act(async () => {
+ settle[0]!(acceptedResponse())
+ await Promise.resolve()
+ })
+
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
+ // A superseded chain reporting success would clear the banner it just raised —
+ // the accepted hook runs after the fence, so the user would see nothing at all.
+ expect(onAccepted).not.toHaveBeenCalled()
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not retire the fence banner when a superseded pasted answer lands', async () => {
+ const onSendError = vi.fn()
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ await act(async () => {
+ first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ settle[0]!(acceptedResponse())
+ await Promise.resolve()
+ })
+
+ // The pasted shape commits with Enter, so a superseded chain is doubly unsafe
+ // to report as accepted — the answer it committed is not the one on screen.
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ expect(onAccepted).not.toHaveBeenCalled()
+ expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
+ })
+
+ it('reports a landed answer that Stop cancelled with no successor waiting', async () => {
+ const onSendError = vi.fn()
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, onSendError)
+
+ let first: Promise | undefined
+ await act(async () => {
+ first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ // Stop, an ask cancel, and a dropped input lease all bump the generation with
+ // NO successor chain behind them.
+ await act(async () => {
+ answerSend?.cancelPending()
+ settle[0]!(acceptedResponse())
+ await Promise.resolve()
+ })
+
+ // The key is on the PTY and nobody else owns the surface. Calling that a
+ // non-send leaves the card up and silent (fail() never runs on an accepted
+ // write), and the retry double-steps the selector this key already moved.
+ await expect(first).resolves.toBe(true)
+ expect(onAccepted).toHaveBeenCalledTimes(1)
+ expect(onSendError).not.toHaveBeenCalled()
+ })
+
+ it('reports a landed pasted answer that Stop cancelled with no successor', async () => {
+ const onSendError = vi.fn()
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
+
+ let first: Promise | undefined
+ await act(async () => {
+ first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ answerSend?.cancelPending()
+ settle[0]!(acceptedResponse())
+ await Promise.resolve()
+ })
+
+ // The pasted shape already committed with Enter, so suppressing the success
+ // strands an answered card the user is invited to submit a second time.
+ await expect(first).resolves.toBe(true)
+ expect(onAccepted).toHaveBeenCalledTimes(1)
+ expect(onSendError).not.toHaveBeenCalled()
+ })
+
+ it('fences a third answer behind an already-fenced successor, reporting once', async () => {
+ const onSendError = vi.fn()
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, onSendError)
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ let third: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+
+ await act(async () => {
+ settle[0]!(acceptedResponse())
+ await Promise.resolve()
+ })
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ await expect(third).resolves.toBe(false)
+ // The middle chain sent nothing, so only the verdict it INHERITED can stop the
+ // third from replaying a from-scratch plan onto the advanced selector.
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ // Only the newest chain owns the error surface.
+ expect(onSendError).toHaveBeenCalledTimes(1)
+ expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
+ })
+
+ it('queues a late third answer behind the successor already on the wire', async () => {
+ const settle: Array<(response: unknown) => void> = []
+ const sendRequest = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ settle.push(resolve)
+ })
+ )
+ await mount({ sendRequest } as unknown as RpcClient, vi.fn())
+
+ let first: Promise | undefined
+ let second: Promise | undefined
+ let third: Promise | undefined
+ await act(async () => {
+ first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ })
+ await act(async () => {
+ second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
+ await Promise.resolve()
+ })
+ // Nothing landed, so the successor is cleared and puts its own key on the wire.
+ await act(async () => {
+ settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } })
+ await Promise.resolve()
+ })
+ expect(sendRequest).toHaveBeenCalledTimes(2)
+
+ await act(async () => {
+ third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+ // The first chain unwound while the second was mid-write: it must not have
+ // dropped the second's turn, or this one writes into the same PTY concurrently.
+ expect(sendRequest).toHaveBeenCalledTimes(2)
+
+ await act(async () => {
+ settle[1]!(acceptedResponse())
+ await Promise.resolve()
+ })
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+ await expect(third).resolves.toBe(false)
+ expect(sendRequest).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts
index 11a3a030788..b893f7ae071 100644
--- a/mobile/src/session/use-mobile-native-chat-answer-send.ts
+++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts
@@ -14,6 +14,10 @@ import {
sendMobileNativeChatMessageWithOutcome
} from './mobile-native-chat-send'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite
+} from './mobile-native-chat-terminal-write-lock'
import {
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
@@ -72,6 +76,12 @@ export function useMobileNativeChatAnswerSend(args: {
const generationRef = useRef(0)
const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity })
activeRouteRef.current = { client, enabled, sessionId, streamIdentity }
+ // Per-terminal count of this hook's chains sharing one write-lock hold: a
+ // superseding answer inherits the cancelled chain's hold (it re-enters before
+ // the old chain unwinds), and only the last chain out releases the lock.
+ const writeHoldsRef = useRef(new Map())
+ // Successors wait for the prior RPC and inherit any delivery ambiguity.
+ const writeTurnsRef = useRef(new Map>())
const delaysRef = useRef<
Set<{ timer: ReturnType; resolve: (completed: boolean) => void }>
>(new Set())
@@ -103,125 +113,191 @@ export function useMobileNativeChatAnswerSend(args: {
if (!hasAskAnswer(prompt, selections)) {
return false
}
+ // One composed write sequence per terminal: an answer landing mid-flight
+ // in an image paste (or vice versa) would interleave bytes into the PTY.
+ // A superseding answer shares the cancelled chain's hold on this terminal
+ // (that chain has not unwound to its release yet).
+ const holds = writeHoldsRef.current
+ const heldCount = holds.get(handle) ?? 0
+ if (heldCount === 0 && !acquireMobileNativeChatTerminalWrite(handle)) {
+ onSendError('Answer not sent')
+ return false
+ }
+ holds.set(handle, heldCount + 1)
+ const previousTurn = writeTurnsRef.current.get(handle) ?? Promise.resolve(true)
+ let finishTurn: (safeToContinue: boolean) => void = () => undefined
+ const turn = new Promise((resolve) => {
+ finishTurn = resolve
+ })
+ writeTurnsRef.current.set(handle, turn)
// A new answer supersedes any still-pending keystroke writes.
cancelPending()
const generation = generationRef.current
let sawUnknownOutcome = false
let sawAcceptedGroup = false
- // One budget for the whole answer instead of a fresh timeout per keystroke
- // group, which let an N-group selector hold the card for N × the send timeout.
- // It bounds transport time only: each deliberate pacing wait is credited back
- // below, so a long multi-question answer still gets a full budget to write in.
- let deadline = openMobileNativeChatSendBudget()
- const sendTerminal = async (body: string, enter: boolean): Promise => {
- const activeRoute = activeRouteRef.current
- if (
- !activeRoute.enabled ||
- activeRoute.client !== client ||
- activeRoute.sessionId !== sessionId ||
- activeRoute.streamIdentity !== streamIdentity ||
- handleRef.current !== handle
- ) {
- return false
- }
- const outcome = await sendMobileNativeChatMessageWithOutcome({
- client,
- terminal: handle,
- text: body,
- enter,
- deadline,
- ...(deviceTokenRef.current
- ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
- : {})
- })
- if (outcome === 'unknown') {
- sawUnknownOutcome = true
- }
- if (outcome === 'accepted') {
- sawAcceptedGroup = true
- }
- return outcome === 'accepted'
- }
- const wait = (ms: number): Promise =>
- new Promise((resolve) => {
- const delay = {
- timer: setTimeout(() => {
- delaysRef.current.delete(delay)
- resolve(generationRef.current === generation)
- }, ms),
- resolve
- }
- delaysRef.current.add(delay)
- })
- const fail = (): false => {
- if (generationRef.current === generation) {
- // Why: keystrokes that may have landed (ack lost / path cutover) must
- // not read as a definite failure — a blind resend could double-step
- // the selector. An earlier group that WAS accepted is the same hazard
- // in definite form: a multi-question answer whose shared budget ran out
- // mid-sequence left the remote selector half-stepped, and telling the
- // user nothing was sent invites a retry on top of the advanced state.
- onSendError(
- sawAcceptedGroup
- ? 'Answer partly sent — check chat before retrying'
- : sawUnknownOutcome
- ? 'Answer unconfirmed — check chat before retrying'
- : 'Answer not sent'
- )
- }
- return false
- }
- // Grok commits pasted labels; Claude and Codex need their selector-specific
- // keystrokes paced so each step renders before the next lands.
- if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
- // This shape pastes the label into the composer and commits it, so an
- // orphaned image paste would be submitted along with the answer (#10228).
- // The selector shapes below deliberately skip the heal: their keys are
- // `enter: false` for an active overlay, and a single-select answer is a
- // bare option digit that cannot submit the line at all, so clearing there
- // would consume the marker still protecting the next real message.
- // Desktop splits it identically — use-native-chat-interactive-send.ts
- // routes only the pasted-label shape through the clearing sender.
- if (
- !(await healMobileNativeChatStaleInput({
- client,
- terminal: handle,
- deviceToken: deviceTokenRef.current,
- deadline
- }))
- ) {
- if (generationRef.current === generation) {
- onSendError('Answer not sent')
+ let predecessorSafe = true
+ try {
+ predecessorSafe = await previousTurn
+ if (!predecessorSafe) {
+ // Fenced. Report it: the card re-enables on a false result, so silence
+ // here is indistinguishable from a dead button. "Check chat" rather than
+ // a bare "not sent" because the PREVIOUS answer's keys may have landed.
+ // Gate on the turn slot, not the generation: a dropped input lease bumps
+ // the generation without writing the Escape that Stop and ask-cancel do,
+ // so the card is still up and silence there strands an advanced selector.
+ if (writeTurnsRef.current.get(handle) === turn) {
+ onSendError('Answer not sent — check chat before retrying')
}
return false
}
+ // Superseded by a newer answer, which owns the error surface from here.
if (generationRef.current !== generation) {
return false
}
- return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
- }
- const groups =
- resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
- ? buildCodexAskAnswerKeys(prompt, selections)
- : buildAskAnswerKeys(prompt, selections)
- for (let index = 0; index < groups.length; index += 1) {
- if (generationRef.current !== generation) {
- return false
- }
- const group = groups[index]!
- const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text)
- if (!(await sendTerminal(body, false))) {
- return fail()
- }
- if (index < groups.length - 1) {
- if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
+ // One budget for the whole answer instead of a fresh timeout per keystroke
+ // group, which let an N-group selector hold the card for N × the send timeout.
+ // It bounds transport time only: each deliberate pacing wait is credited back
+ // below, so a long multi-question answer still gets a full budget to write in.
+ let deadline = openMobileNativeChatSendBudget()
+ const sendTerminal = async (body: string, enter: boolean): Promise => {
+ const activeRoute = activeRouteRef.current
+ if (
+ !activeRoute.enabled ||
+ activeRoute.client !== client ||
+ activeRoute.sessionId !== sessionId ||
+ activeRoute.streamIdentity !== streamIdentity ||
+ handleRef.current !== handle
+ ) {
return false
}
- // Pacing is deliberate, not transport latency — don't charge it to the budget.
- deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
+ const outcome = await sendMobileNativeChatMessageWithOutcome({
+ client,
+ terminal: handle,
+ text: body,
+ enter,
+ deadline,
+ ...(deviceTokenRef.current
+ ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
+ : {})
+ })
+ if (outcome === 'unknown') {
+ sawUnknownOutcome = true
+ }
+ if (outcome === 'accepted') {
+ sawAcceptedGroup = true
+ }
+ return outcome === 'accepted'
+ }
+ const wait = (ms: number): Promise => {
+ // Already superseded: don't hold the successor for a full pacing step
+ // waiting on a timer whose only job is to report the cancellation.
+ if (generationRef.current !== generation) {
+ return Promise.resolve(false)
+ }
+ return new Promise((resolve) => {
+ const delay = {
+ timer: setTimeout(() => {
+ delaysRef.current.delete(delay)
+ resolve(generationRef.current === generation)
+ }, ms),
+ resolve
+ }
+ delaysRef.current.add(delay)
+ })
+ }
+ const fail = (): false => {
+ if (generationRef.current === generation) {
+ // Why: keystrokes that may have landed (ack lost / path cutover) must
+ // not read as a definite failure — a blind resend could double-step
+ // the selector. An earlier group that WAS accepted is the same hazard
+ // in definite form: a multi-question answer whose shared budget ran out
+ // mid-sequence left the remote selector half-stepped, and telling the
+ // user nothing was sent invites a retry on top of the advanced state.
+ onSendError(
+ sawAcceptedGroup
+ ? 'Answer partly sent — check chat before retrying'
+ : sawUnknownOutcome
+ ? 'Answer unconfirmed — check chat before retrying'
+ : 'Answer not sent'
+ )
+ }
+ return false
+ }
+ // Grok commits pasted labels; Claude and Codex need their selector-specific
+ // keystrokes paced so each step renders before the next lands.
+ if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
+ // This shape pastes the label into the composer and commits it, so an
+ // orphaned image paste would be submitted along with the answer (#10228).
+ // The selector shapes below deliberately skip the heal: their keys are
+ // `enter: false` for an active overlay, and a single-select answer is a
+ // bare option digit that cannot submit the line at all, so clearing there
+ // would consume the marker still protecting the next real message.
+ // Desktop splits it identically — use-native-chat-interactive-send.ts
+ // routes only the pasted-label shape through the clearing sender.
+ if (
+ !(await healMobileNativeChatStaleInput({
+ client,
+ terminal: handle,
+ deviceToken: deviceTokenRef.current,
+ deadline
+ }))
+ ) {
+ if (generationRef.current === generation) {
+ onSendError('Answer not sent')
+ }
+ return false
+ }
+ if (generationRef.current !== generation) {
+ return false
+ }
+ // A chain a successor took over from must not report success either: an
+ // accepted answer retires the shared send-error banner, wiping the
+ // successor's fence. Test the turn slot, not the generation counter —
+ // Stop, ask-cancel and a dropped lease all bump the generation with no
+ // successor, and there a landed answer IS a success.
+ const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
+ return sent && writeTurnsRef.current.get(handle) === turn
+ }
+ const groups =
+ resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
+ ? buildCodexAskAnswerKeys(prompt, selections)
+ : buildAskAnswerKeys(prompt, selections)
+ for (let index = 0; index < groups.length; index += 1) {
+ if (generationRef.current !== generation) {
+ return false
+ }
+ const group = groups[index]!
+ const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text)
+ if (!(await sendTerminal(body, false))) {
+ return fail()
+ }
+ if (index < groups.length - 1) {
+ if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
+ return false
+ }
+ // Pacing is deliberate, not transport latency — don't charge it to the budget.
+ deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
+ }
+ }
+ // Taken over on the last key: same as above, the successor owns the surface.
+ return groups.length > 0 && writeTurnsRef.current.get(handle) === turn
+ } finally {
+ // Any accepted key changed the live selector, so a queued replacement
+ // cannot safely apply its from-scratch key plan to that new position.
+ finishTurn(predecessorSafe && !sawUnknownOutcome && !sawAcceptedGroup)
+ if (writeTurnsRef.current.get(handle) === turn) {
+ writeTurnsRef.current.delete(handle)
+ }
+ // Last chain out releases; a superseded chain unwinding late must not
+ // free the lock out from under the successor sharing its hold.
+ const remaining = (holds.get(handle) ?? 1) - 1
+ if (remaining <= 0) {
+ holds.delete(handle)
+ releaseMobileNativeChatTerminalWrite(handle)
+ } else {
+ holds.set(handle, remaining)
}
}
- return groups.length > 0
},
[
agentRef,
diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
index be2f9a3730e..13cb5d96c48 100644
--- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
+import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
// Fully stub the picker so the real expo/react-native chain never loads under
@@ -88,9 +89,10 @@ describe('useMobileNativeChatImageAttachments', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
pick.mockReset()
- // Stale markers live at module scope now (they outlive the screen), so they
- // also outlive a test.
+ // Stale markers and write locks live at module scope (they outlive the
+ // screen), so they also outlive a test.
resetMobileNativeChatStaleInputForTests()
+ resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
act(() => renderer?.unmount())
diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
index a692b11acf1..38f9e462e46 100644
--- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts
+++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
@@ -26,6 +26,10 @@ import {
isMobileNativeChatInputStale,
markMobileNativeChatInputStale
} from './mobile-native-chat-stale-input'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite
+} from './mobile-native-chat-terminal-write-lock'
type CurrentRef = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -127,8 +131,6 @@ export function useMobileNativeChatImageAttachments({
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
const connStateRef = useRef(connState)
connStateRef.current = connState
- // Serialize clear/paste/submit ownership per terminal while allowing other tabs to send.
- const sendInFlightTerminalsRef = useRef(new Set())
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
@@ -219,15 +221,15 @@ export function useMobileNativeChatImageAttachments({
const sendNativeChat = useCallback(
async (text: string): Promise => {
+ // Serialize clear/paste/submit ownership per terminal while allowing other
+ // tabs to send. Shared with the prompt-card writes (answer/permission), so
+ // a card tap can't interleave into a mid-flight paste sequence either.
const operationTerminal = activeHandleRef.current
- if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) {
+ if (operationTerminal && !acquireMobileNativeChatTerminalWrite(operationTerminal)) {
onError?.()
onSendError('Message not sent')
return false
}
- if (operationTerminal) {
- sendInFlightTerminalsRef.current.add(operationTerminal)
- }
// One budget for the whole user action. The paste loop, the settle, and the
// text body that follows are a single send from the composer's point of view;
// opening a budget per leg let `sending` run to twice the stated ceiling.
@@ -347,7 +349,7 @@ export function useMobileNativeChatImageAttachments({
}
} finally {
if (operationTerminal) {
- sendInFlightTerminalsRef.current.delete(operationTerminal)
+ releaseMobileNativeChatTerminalWrite(operationTerminal)
}
}
},
diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts
index da36cdb925b..7c7c8e5814f 100644
--- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts
@@ -19,6 +19,11 @@ vi.mock('./mobile-native-chat-stale-input', () => ({
}))
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite,
+ resetMobileNativeChatTerminalWritesForTests
+} from './mobile-native-chat-terminal-write-lock'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
type Send = ReturnType
@@ -28,6 +33,7 @@ const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
describe('useMobileNativeChatMessageSend', () => {
let renderer: ReactTestRenderer | null = null
let api: Send | null = null
+ let onSendError = vi.fn()
const mount = (
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
@@ -44,7 +50,7 @@ describe('useMobileNativeChatMessageSend', () => {
restoreRejectedDraft: () => {},
acceptSend: () => {},
holdUnconfirmedSend: () => {},
- onSendError: () => {}
+ onSendError
})
return null
}
@@ -70,6 +76,8 @@ describe('useMobileNativeChatMessageSend', () => {
sendWithOutcome.mockResolvedValue('accepted')
clearInputWrite.mockReset()
clearInputWrite.mockResolvedValue(true)
+ onSendError = vi.fn()
+ resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
act(() => {
@@ -173,4 +181,27 @@ describe('useMobileNativeChatMessageSend', () => {
})
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
+
+ it('rejects a question answer while another composed write holds the terminal', async () => {
+ mount(() => null)
+ // An image paste sequence is mid-flight into the same PTY.
+ expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
+
+ let result: boolean | undefined
+ await act(async () => {
+ result = await api!.answerQuestion('1')
+ })
+ expect(result).toBe(false)
+ expect(sendWithOutcome).not.toHaveBeenCalled()
+ expect(onSendError).toHaveBeenCalledWith('Answer not sent')
+
+ releaseMobileNativeChatTerminalWrite('term')
+ await act(async () => {
+ result = await api!.answerQuestion('1')
+ })
+ expect(result).toBe(true)
+ // The answer released its own hold on the way out.
+ expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
+ releaseMobileNativeChatTerminalWrite('term')
+ })
})
diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts
index 4143af338c7..7847f83c8e0 100644
--- a/mobile/src/session/use-mobile-native-chat-message-send.ts
+++ b/mobile/src/session/use-mobile-native-chat-message-send.ts
@@ -7,6 +7,10 @@ import {
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
+import {
+ acquireMobileNativeChatTerminalWrite,
+ releaseMobileNativeChatTerminalWrite
+} from './mobile-native-chat-terminal-write-lock'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
@@ -210,11 +214,26 @@ export function useMobileNativeChatMessageSend(args: {
[sendWithOutcome]
)
- // A question answer is not composer text, so it never syncs the draft.
+ // A question answer is not composer text, so it never syncs the draft. It
+ // reaches this send directly (not through the image hook's locked path), so
+ // it takes the per-terminal write lock itself: an answer landing mid-flight
+ // in an image paste sequence would interleave bytes into the PTY.
const answerQuestion = useCallback(
- async (text: string): Promise =>
- (await sendMessage(text, undefined, false)) !== 'rejected',
- [sendMessage]
+ async (text: string): Promise => {
+ const terminal = handleRef.current
+ if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
+ onSendError('Answer not sent')
+ return false
+ }
+ try {
+ return (await sendMessage(text, undefined, false)) !== 'rejected'
+ } finally {
+ if (terminal) {
+ releaseMobileNativeChatTerminalWrite(terminal)
+ }
+ }
+ },
+ [handleRef, onSendError, sendMessage]
)
return { send, sendWithOutcome, answerQuestion }
From 1816d3eee29588f612643ec66635c8852bd1b1d3 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:10:07 -0700
Subject: [PATCH 06/42] test(terminal): pin IME chord survival across
mid-composition re-renders (STA-3291) (#12357)
Busy panes render mid-composition (title updates drive renders), and the
modified-Enter chord owner must survive those renders or held-modifier
CJK input leaks newlines. Wires the hook TerminalPane-shaped (dep objects
rebuilt per render) and interleaves re-renders through hold, composition,
and auto-repeat; fails under pre-fix churn wiring (verified: redispatch
not absorbed), passes with memoized actions.
---
.../keyboard-handlers-ime.test.tsx | 71 +++++++++++++++++++
1 file changed, 71 insertions(+)
diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx
index 6d25e856ef5..536d3e9c9cb 100644
--- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx
+++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx
@@ -3,6 +3,7 @@ import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
+import { useExpandCollapseActions } from './expand-collapse'
import { useTerminalKeyboardShortcuts } from './keyboard-handlers'
import {
installTerminalImeCompositionRoute,
@@ -365,4 +366,74 @@ describe('Windows IME keyboard ownership', () => {
hook.unmount()
harness.dispose()
})
+
+ // Why: STA-3291 — busy panes render mid-composition (title updates), and the
+ // chord owner must survive those renders or held-modifier CJK input leaks
+ // newlines. Wiring mirrors TerminalPane: dep objects rebuilt every render.
+ it('absorbs the bare Enter redispatch when re-renders land mid-composition', () => {
+ const harness = createHarness()
+ const factoryFields = {
+ expandedPaneIdRef: { current: null },
+ expandedStyleSnapshotRef: { current: new Map() },
+ containerRef: { current: null },
+ managerRef: { current: null },
+ setExpandedPaneId: vi.fn(),
+ setTabPaneExpanded: vi.fn(),
+ pendingPaneSizeRefreshFrameIdsRef: { current: [] },
+ persistLayoutSnapshot: vi.fn()
+ }
+ const hook = renderHook(() => {
+ const actions = useExpandCollapseActions({ ...factoryFields, tabId: 'tab-1' })
+ useTerminalKeyboardShortcuts({
+ ...harness.deps,
+ setExpandedPane: actions.setExpandedPane,
+ restoreExpandedLayout: actions.restoreExpandedLayout,
+ refreshPaneSizes: actions.refreshPaneSizes,
+ toggleExpandPane: actions.toggleExpandPane
+ })
+ })
+
+ harness.terminalInput.dispatchEvent(
+ keyboardEvent('keydown', {
+ key: 'Shift',
+ code: 'ShiftLeft',
+ keyCode: 16,
+ timeStamp: 1,
+ shiftKey: true
+ })
+ )
+ hook.rerender()
+ harness.startComposition()
+ hook.rerender()
+ for (let repeat = 0; repeat < 5; repeat++) {
+ harness.terminalInput.dispatchEvent(
+ keyboardEvent('keydown', {
+ key: 'Process',
+ code: 'Enter',
+ keyCode: 229,
+ timeStamp: 10 + repeat,
+ isComposing: true,
+ shiftKey: true,
+ repeat: repeat > 0
+ })
+ )
+ hook.rerender()
+ }
+
+ const redispatch = keyboardEvent('keydown', {
+ key: 'Enter',
+ code: 'Enter',
+ keyCode: 13,
+ timeStamp: 40
+ })
+ harness.terminalInput.dispatchEvent(redispatch)
+
+ expect(redispatch.defaultPrevented).toBe(true)
+ const newlineSends = harness.sendInput.mock.calls.filter(
+ ([data]) => typeof data === 'string' && data.includes('\r')
+ )
+ expect(newlineSends).toHaveLength(0)
+ hook.unmount()
+ harness.dispose()
+ })
})
From 88549619d204b29d93396217b76bbd43b5a90d08 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:37:04 -0700
Subject: [PATCH 07/42] feat(sidebar): inline SSH reconnect control on
workspace cards (#12396)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(sidebar): inline SSH reconnect control on workspace cards
Replaces the blocking SshDisconnectedDialog with an inline pill in the
workspace card title row, and unifies the SSH connect vocabulary across the
sidebar card, terminal overlay, host-header menu, and status-bar row.
- new src/renderer/src/ssh/ modules: typechecker-total status predicates
(recoverability), a shared in-flight connect registry, the promoted UI
connect timeout, and the shared connect verb table
- WorktreeCardSshHostControl: one 16px pill shape for every state, icon-only
in compact/new card modes, passive glyph for connected/null/removed hosts
- migrates the four duplicated status predicates to the shared module
- deletes SshDisconnectedDialog (and its window-capture Enter handler)
* fix(ssh): address review round 1 on the inline reconnect control
- reconnect surfaces get a 180s UI connect fence instead of the composer's
20s: main allows 120s for an interactive passphrase before the 30s connect
even starts, so the short cap toasted "timed out" and ran the stale-metadata
resync against a host that was about to connect fine
- carries the existing es/ja/ko/zh translations onto the shared connect verbs
(they were en-only, regressing four locales) and drops the dead
SshDisconnectedDialog key namespace
- migrates the three remaining copies of the connecting predicate
(SshStatusSegment, SshTargetRow, external-automation-source-availability)
- SshTargetStatusRow and SshTargetRow now join the shared in-flight registry,
so a connect started on one surface disables the others immediately
- drops the per-card aria-live region that duplicated the button's own label
* fix(ssh): address review round 2 on the inline reconnect control
- guard sshTargetRemoved on isRuntimeOwnedSshTargetId: runtime-owned targets
are filtered out of ssh:listTargets, so absence is not evidence of removal
(every ephemeral-VM card otherwise read "SSH host removed")
- drop the card-root SSH dim: an ancestor opacity composited the control's
destructive tint and spinner down to an illegible alpha
- aria-disabled instead of disabled while connecting, so the pill stays
pointer-reachable for its tooltip and focus
- stop Enter/Space propagation so WorktreeList's container key handler does
not steal activation
- prefer a live connection over a stale removal tombstone
- reclaim title width in labeled mode (no leading icon, no min-width floor)
- register composer connects in the shared in-flight registry
* temp checkin of files
* fix(ssh): hold connect lock for backend request, not UI timeout
Replace manual begin/end pairs with trackSshConnect wrapper that holds
the lock for the full backend request duration. Previously, the lock
was released after the UI timeout fired, even though the backend was
still dialing — a second click on any surface for this host would
trigger a second connect and a duplicate credential prompt on
passphrase-gated targets. The wrapper survives unmount, unlike a
finally block in a component handler.
* fix(ssh): scope connect locks to their acquisition, not target
A tracked request settling after its lock was cleared (via reset or
explicit end) must not unlock a newer connect on the same target. Lock
IDs ensure only the owning acquisition releases, preventing stale
settlements from clearing active locks.
---
.../components/NewWorkspaceComposerCard.tsx | 45 +--
...external-automation-source-availability.ts | 3 +-
.../settings/ssh-target-action-state.ts | 9 +-
.../sidebar/AutoRenameFailedDialog.tsx | 4 +-
.../sidebar/HostSectionHeaderMenu.tsx | 11 +-
.../sidebar/SshDisconnectedDialog.tsx | 205 ----------
.../src/components/sidebar/SshTargetRow.tsx | 35 +-
.../WorktreeCard.affiliate-list-mode.test.tsx | 4 -
.../WorktreeCard.compact-hover.test.tsx | 4 -
....compact-ports-hover-independence.test.tsx | 4 -
...orktreeCard.hosted-review-refresh.test.tsx | 4 -
.../sidebar/WorktreeCard.lineage.test.tsx | 4 -
.../WorktreeCard.merged-pr-display.test.tsx | 4 -
.../WorktreeCard.pinned-repo-icon.test.tsx | 4 -
.../sidebar/WorktreeCard.pr-display.test.tsx | 4 -
.../WorktreeCard.quick-actions.test.tsx | 4 -
...WorktreeCard.ssh-reconnect-prompt.test.tsx | 107 +++--
.../src/components/sidebar/WorktreeCard.tsx | 73 ++--
.../WorktreeCardSshHostControl.test.tsx | 377 ++++++++++++++++++
.../sidebar/WorktreeCardSshHostControl.tsx | 283 +++++++++++++
....lineage-agent-expansion-coupling.test.tsx | 4 -
.../WorktreeList.lineage-child-card.test.ts | 20 -
...ktreeList.lineage-child-real-card.test.tsx | 4 -
...treeList.status-lane-lineage-drop.test.tsx | 4 -
.../status-bar/SshStatusSegment.tsx | 7 +-
.../status-bar/SshTargetStatusRow.tsx | 26 +-
.../TerminalSshReconnectOverlay.test.tsx | 46 ++-
.../TerminalSshReconnectOverlay.tsx | 62 ++-
.../terminal-pane/pty-connection.ts | 2 +-
src/renderer/src/hooks/useIpcEvents.ts | 3 +-
src/renderer/src/i18n/locales/en.json | 34 +-
src/renderer/src/i18n/locales/es.json | 21 +-
src/renderer/src/i18n/locales/ja.json | 21 +-
src/renderer/src/i18n/locales/ko.json | 21 +-
src/renderer/src/i18n/locales/zh.json | 21 +-
.../src/lib/new-workspace-ssh-gate.ts | 3 +-
.../src/ssh/ssh-connect-in-flight.test.ts | 150 +++++++
src/renderer/src/ssh/ssh-connect-in-flight.ts | 90 +++++
.../src/ssh/ssh-connect-ui-timeout.test.ts | 64 +++
.../src/ssh/ssh-connect-ui-timeout.ts | 40 ++
src/renderer/src/ssh/ssh-connect-verb.test.ts | 35 ++
src/renderer/src/ssh/ssh-connect-verb.ts | 27 ++
.../ssh/ssh-connection-recoverability.test.ts | 72 ++++
.../src/ssh/ssh-connection-recoverability.ts | 36 ++
44 files changed, 1475 insertions(+), 526 deletions(-)
delete mode 100644 src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx
create mode 100644 src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx
create mode 100644 src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx
create mode 100644 src/renderer/src/ssh/ssh-connect-in-flight.test.ts
create mode 100644 src/renderer/src/ssh/ssh-connect-in-flight.ts
create mode 100644 src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts
create mode 100644 src/renderer/src/ssh/ssh-connect-ui-timeout.ts
create mode 100644 src/renderer/src/ssh/ssh-connect-verb.test.ts
create mode 100644 src/renderer/src/ssh/ssh-connect-verb.ts
create mode 100644 src/renderer/src/ssh/ssh-connection-recoverability.test.ts
create mode 100644 src/renderer/src/ssh/ssh-connection-recoverability.ts
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
index 72ad520f2de..6bea00d9906 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
@@ -66,6 +66,8 @@ import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { translate } from '@/i18n/i18n'
+import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
+import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight'
type RepoOption = React.ComponentProps['repos'][number]
type EphemeralVmRecipeOption = NonNullable[number]
@@ -210,33 +212,6 @@ function getSshStatusLabel(status: SshConnectionStatus): string {
return SSH_STATUS_LABELS[status] ?? status
}
-// Why: bound how long the run-target picker waits on a host connect so a stalled backend
-// connect can't leave the row's disabled/spinner state stuck forever. The backend keeps going.
-const RUN_TARGET_CONNECT_UI_TIMEOUT_MS = 20_000
-
-async function withUiConnectTimeout(promise: Promise): Promise {
- let timer: ReturnType | undefined
- const timeout = new Promise((_, reject) => {
- timer = setTimeout(() => {
- reject(
- new Error(
- translate(
- 'auto.components.NewWorkspaceComposerCard.connectTimedOut',
- 'Connection timed out. It may still be connecting in the background.'
- )
- )
- )
- }, RUN_TARGET_CONNECT_UI_TIMEOUT_MS)
- })
- try {
- return await Promise.race([promise, timeout])
- } finally {
- if (timer) {
- clearTimeout(timer)
- }
- }
-}
-
function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element {
// Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and
// the run-setup toggle live in the section header above, so the card carries no chrome of its
@@ -534,10 +509,18 @@ export default function NewWorkspaceComposerCard({
}
try {
if (action.kind === 'ssh') {
- // Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave the
- // row's spinner/disabled state stuck forever. Bound the UI wait — the backend keeps
- // connecting and the picker updates from store SSH state if it later succeeds.
- await withUiConnectTimeout(window.api.ssh.connect({ targetId: action.targetId }))
+ if (isSshConnectInFlight(action.targetId)) {
+ return
+ }
+ // Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave
+ // the row's spinner/disabled state stuck forever. Bound the UI wait — the backend
+ // keeps connecting and the picker updates from store SSH state if it later succeeds.
+ // The shared registry tracks that backend request (not this bounded wait), so the
+ // sidebar card control and terminal overlay for this host stay locked until it
+ // settles — a second dial on a passphrase-gated target means a second prompt.
+ await withUiConnectTimeout(
+ trackSshConnect(action.targetId, window.api.ssh.connect({ targetId: action.targetId }))
+ )
return
}
diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts
index 92f0749f892..acb51056e73 100644
--- a/src/renderer/src/components/automations/external-automation-source-availability.ts
+++ b/src/renderer/src/components/automations/external-automation-source-availability.ts
@@ -3,6 +3,7 @@ import type {
ExternalAutomationProvider
} from '../../../../shared/automations-types'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
+import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
export type ExternalAutomationSourceAvailability = {
statusLabel: string
@@ -75,7 +76,7 @@ export function getExternalAutomationSourceAvailability({
}
export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean {
- return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
+ return isConnectingSshStatus(status)
}
export function getExternalAutomationActionDisabledMessage(args: {
diff --git a/src/renderer/src/components/settings/ssh-target-action-state.ts b/src/renderer/src/components/settings/ssh-target-action-state.ts
index 38db0cdbf28..c0f076cc282 100644
--- a/src/renderer/src/components/settings/ssh-target-action-state.ts
+++ b/src/renderer/src/components/settings/ssh-target-action-state.ts
@@ -1,15 +1,10 @@
+import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
export type SshTargetBusyAction = 'terminate' | 'reset' | 'remove'
-const SSH_TARGET_CONNECTING_STATUSES: ReadonlySet = new Set([
- 'connecting',
- 'deploying-relay',
- 'reconnecting'
-])
-
export function isSshTargetConnecting(status: SshConnectionStatus): boolean {
- return SSH_TARGET_CONNECTING_STATUSES.has(status)
+ return isConnectingSshStatus(status)
}
export function shouldClearPendingSshReset({
diff --git a/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx
index caf4a5ab5eb..6ea256e5aa3 100644
--- a/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx
+++ b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx
@@ -26,7 +26,7 @@ type AutoRenameFailedDialogProps = {
* CLI output when main still holds it (in-memory, lost on restart), falling
* back to the persisted excerpt — either can run many lines, so it gets a
* dedicated scrollable surface rather than a tooltip — see the sibling
- * SshDisconnectedDialog pattern.
+ * AddRemoteHostDialog pattern.
*/
export function AutoRenameFailedDialog({
open,
@@ -150,7 +150,7 @@ export function AutoRenameFailedDialog({
{/* Why: Close backs the user out, so it stays quiet (outline, not a
- solid CTA) — matching the sibling SshDisconnectedDialog. */}
+ solid CTA) — matching the other sidebar dialogs. */}
onOpenChange(false)}>
{translate('auto.components.sidebar.AutoRenameFailedDialog.aed1623b1e', 'Close')}
diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx
index 5c944fa3a1e..3aead74fd81 100644
--- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx
+++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx
@@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
+import { sshConnectVerb } from '@/ssh/ssh-connect-verb'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat'
import {
@@ -74,18 +75,18 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
const [renameOpen, setRenameOpen] = useState(false)
const [removeOpen, setRemoveOpen] = useState(false)
const mountedRef = useMountedRef()
- const sshConnected = useAppStore((s) => {
+ const sshStatus = useAppStore((s) => {
const parsed = parseExecutionHostId(row.hostId)
if (parsed?.kind !== 'ssh') {
- return false
+ return null
}
- return s.sshConnectionStates.get(parsed.targetId)?.status === 'connected'
+ return s.sshConnectionStates.get(parsed.targetId)?.status ?? null
})
const model = buildHostHeaderMenuModel({
kind: row.kind,
health: row.health,
- sshConnected,
+ sshConnected: sshStatus === 'connected',
compatibility: row.compatibility
})
const removalTarget = resolveHostRemoval(row.hostId)
@@ -242,7 +243,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS
{model.actions.includes('ssh-reconnect') && (
void runSshAction('connect')}>
- {translate('auto.components.sidebar.HostSectionHeaderMenu.63f36455cc', 'Reconnect')}
+ {sshConnectVerb(sshStatus)}
)}
{model.actions.includes('ssh-disconnect') && (
diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx
deleted file mode 100644
index fd4a460e29a..00000000000
--- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { toast } from 'sonner'
-import { Loader2, Server, ServerOff } from 'lucide-react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { useMountedRef } from '@/hooks/useMountedRef'
-import { statusColor } from '@/components/settings/SshTargetCard'
-import type { SshConnectionStatus } from '../../../../shared/ssh-types'
-import { translate } from '@/i18n/i18n'
-
-type SshDisconnectedDialogProps = {
- open: boolean
- onOpenChange: (open: boolean) => void
- targetId: string
- targetLabel: string
- status: SshConnectionStatus
-}
-
-const STATUS_MESSAGES: Partial> = {
- get disconnected() {
- return translate(
- 'auto.components.sidebar.SshDisconnectedDialog.disconnected',
- 'This SSH host is not connected.'
- )
- },
- get reconnecting() {
- return translate(
- 'auto.components.sidebar.SshDisconnectedDialog.reconnecting',
- 'Reconnecting to the remote host...'
- )
- },
- get 'reconnection-failed'() {
- return translate(
- 'auto.components.sidebar.SshDisconnectedDialog.reconnectionFailed',
- 'Reconnection to the remote host failed.'
- )
- },
- get error() {
- return translate(
- 'auto.components.sidebar.SshDisconnectedDialog.376bed88e5',
- 'The connection to the remote host encountered an error.'
- )
- },
- get 'auth-failed'() {
- return translate(
- 'auto.components.sidebar.SshDisconnectedDialog.authFailed',
- 'Authentication to the remote host failed.'
- )
- }
-}
-
-function isReconnectable(status: SshConnectionStatus): boolean {
- return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status)
-}
-
-export function SshDisconnectedDialog({
- open,
- onOpenChange,
- targetId,
- targetLabel,
- status
-}: SshDisconnectedDialogProps): React.JSX.Element {
- const [connecting, setConnecting] = useState(false)
- const mountedRef = useMountedRef()
-
- const handleReconnect = useCallback(async () => {
- setConnecting(true)
- try {
- await window.api.ssh.connect({ targetId })
- if (mountedRef.current) {
- onOpenChange(false)
- }
- } catch (err) {
- toast.error(
- err instanceof Error
- ? err.message
- : translate(
- 'auto.components.sidebar.SshDisconnectedDialog.656368f3a2',
- 'Reconnection failed'
- )
- )
- } finally {
- if (mountedRef.current) {
- setConnecting(false)
- }
- }
- }, [mountedRef, targetId, onOpenChange])
-
- const isConnecting =
- connecting ||
- status === 'connecting' ||
- status === 'deploying-relay' ||
- status === 'reconnecting'
- const reconnectingMessage =
- STATUS_MESSAGES.reconnecting ??
- translate(
- 'auto.components.sidebar.SshDisconnectedDialog.reconnecting',
- 'Reconnecting to the remote host...'
- )
- const disconnectedMessage =
- STATUS_MESSAGES.disconnected ??
- translate(
- 'auto.components.sidebar.SshDisconnectedDialog.disconnected',
- 'This SSH host is not connected.'
- )
- const message = isConnecting
- ? reconnectingMessage
- : (STATUS_MESSAGES[status] ?? disconnectedMessage)
- const showReconnect = isReconnectable(status)
-
- useEffect(() => {
- // Window-level Enter handler. The dialog typically appears while focus
- // is inside an embedded terminal (xterm) or editor (monaco) that
- // aggressively reclaims focus, so dialog-scoped key handlers never
- // fire. Listening on window (capture phase) catches Enter regardless
- // of where focus actually lives while the dialog is open.
- if (!open || !showReconnect || isConnecting) {
- return undefined
- }
- const onKeyDown = (event: KeyboardEvent): void => {
- if (event.key !== 'Enter' || event.defaultPrevented) {
- return
- }
- if (event.isComposing) {
- return
- }
- event.preventDefault()
- event.stopPropagation()
- void handleReconnect()
- }
- window.addEventListener('keydown', onKeyDown, true)
- return () => window.removeEventListener('keydown', onKeyDown, true)
- }, [open, showReconnect, isConnecting, handleReconnect])
-
- return (
-
-
-
-
- {isConnecting ? (
-
- ) : (
-
- )}
- {isConnecting
- ? translate(
- 'auto.components.sidebar.SshDisconnectedDialog.cb5938ae79',
- 'Reconnecting...'
- )
- : translate(
- 'auto.components.sidebar.SshDisconnectedDialog.11552bf786',
- 'SSH Disconnected'
- )}
-
- {message}
-
-
-
-
-
- {targetLabel}
-
- {isConnecting ? (
-
- ) : (
-
- )}
-
-
-
- onOpenChange(false)}
- disabled={isConnecting}
- >
- {translate('auto.components.sidebar.SshDisconnectedDialog.89385db176', 'Dismiss')}
-
- {showReconnect && (
- void handleReconnect()} disabled={isConnecting}>
- {isConnecting ? (
- <>
-
- {translate(
- 'auto.components.sidebar.SshDisconnectedDialog.ca4a7892af',
- 'Connecting...'
- )}
- >
- ) : (
- translate('auto.components.sidebar.SshDisconnectedDialog.4afcca1d24', 'Reconnect')
- )}
-
- )}
-
-
-
- )
-}
diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx
index 3ce6db39e76..98323550c97 100644
--- a/src/renderer/src/components/sidebar/SshTargetRow.tsx
+++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx
@@ -4,10 +4,17 @@
* Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit
* while isolating the inline-connect interaction logic.
*/
-import React, { useCallback, useRef, useState } from 'react'
+import React from 'react'
import { Loader2 } from 'lucide-react'
import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
+import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
+import {
+ beginSshConnect,
+ endSshConnect,
+ isSshConnectInFlight,
+ useSshConnectInFlight
+} from '@/ssh/ssh-connect-in-flight'
type Props = {
target: SshTarget & { state?: SshConnectionState }
@@ -22,15 +29,12 @@ export function SshTargetRow({
onSelect,
onConnect
}: Props): React.JSX.Element {
- const [connecting, setConnecting] = useState(false)
- const mountedRef = useRef(true)
+ // Why: the shared registry replaces local state — every SSH surface dials one connection
+ // per target, and it survives this row unmounting mid-connect.
+ const connecting = useSshConnectInFlight(target.id)
const status = target.state?.status ?? 'disconnected'
const isConnected = status === 'connected'
- const isBusy =
- connecting ||
- status === 'connecting' ||
- status === 'deploying-relay' ||
- status === 'reconnecting'
+ const isBusy = connecting || isConnectingSshStatus(status)
const dotColor = isConnected
? 'bg-green-500'
: isBusy
@@ -47,26 +51,17 @@ export function SshTargetRow({
// Why: prevent the row's onClick from also firing and treating the click
// as a selection when the target is disconnected.
e.stopPropagation()
- if (isBusy) {
+ if (isBusy || isSshConnectInFlight(target.id)) {
return
}
- setConnecting(true)
+ beginSshConnect(target.id)
void onConnect(target.id).finally(() => {
- if (mountedRef.current) {
- setConnecting(false)
- }
+ endSshConnect(target.id)
})
}
- const handleRowRootRef = useCallback((node: HTMLDivElement | null): void => {
- // Why: SSH connects can resolve after this row is removed; the row ref
- // gives the async completion the same guard without a mount-only Effect.
- mountedRef.current = node !== null
- }, [])
-
return (
({
default: () =>
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => (
{children}
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx
index 9aa2f31b577..707ce16ec61 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx
@@ -111,10 +111,6 @@ vi.mock('./WorktreeCardAgents', () => ({
)
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx
index 3887c52c79d..5aee22c8b16 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx
@@ -124,10 +124,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () =>
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx
index 3cc0f9dd1a0..98c2a1b3712 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx
@@ -63,10 +63,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx
index 71fa30bed26..cbbf5a35b86 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx
@@ -53,10 +53,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx
index 9df826e66d0..02927202e29 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx
@@ -68,10 +68,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx
index 88d5d65d818..9da7873b388 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx
@@ -59,10 +59,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
index 7df0cd25c94..fef27ea71cc 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
@@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx
index d21085ab3cb..7456cce2faa 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx
@@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({
default: () => null
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx
index 1b2b6c9f664..19ca3a4e441 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx
@@ -13,6 +13,7 @@ const updateWorktreeMeta = vi.fn()
let WorktreeCard: typeof WorktreeCardComponent
let sshConnectionStates = new Map
()
let sshTargetLabels = new Map()
+let removedSshTargetLabels = new Map()
let runtimeStatusByEnvironmentId = new Map()
let runtimeEnvironments: { id: string; name: string }[] = []
let sshStateByEnvironment = new Map()
@@ -35,7 +36,7 @@ vi.mock('@/store', () => ({
remoteBranchConflictByWorktreeId: {},
runtimeEnvironments,
runtimeStatusByEnvironmentId,
- removedSshTargetLabels: new Map(),
+ removedSshTargetLabels,
settings: null,
sshConnectionStates,
sshStateByEnvironment,
@@ -70,24 +71,6 @@ vi.mock('./use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'idle'
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: ({
- open,
- status,
- targetLabel
- }: {
- open: boolean
- status: string
- targetLabel: string
- }) => (
-
- )
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
@@ -137,6 +120,7 @@ describe('WorktreeCard SSH reconnect prompt', () => {
vi.clearAllMocks()
sshConnectionStates = new Map()
sshTargetLabels = new Map()
+ removedSshTargetLabels = new Map()
runtimeStatusByEnvironmentId = new Map()
runtimeEnvironments = []
sshStateByEnvironment = new Map()
@@ -144,7 +128,9 @@ describe('WorktreeCard SSH reconnect prompt', () => {
worktreeCardProperties = ['status']
})
- it('does not auto-open the blocking reconnect dialog for a restored active disconnected SSH worktree', () => {
+ // Supersedes the old "does not auto-open the blocking reconnect dialog" assertion:
+ // the dialog is gone, so no card state can open one.
+ it('offers an inline reconnect control and never a blocking dialog for a disconnected SSH worktree', () => {
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
@@ -152,11 +138,82 @@ describe('WorktreeCard SSH reconnect prompt', () => {
)
- // The dialog is blocking, so being the active/restored card must not steal
- // focus app-wide; it only opens on deliberate click (see handleClick).
- expect(markup).toContain('data-ssh-disconnected-dialog="closed"')
- // The disconnected state is still discoverable via the non-blocking card chip.
- expect(markup).toContain('SSH disconnected')
+ expect(markup).toContain('Connect to SSH host Remote target')
+ expect(markup).toContain('Connect')
+ expect(markup).not.toContain('ssh-disconnected-dialog')
+ // Why: a card-root opacity composites the whole subtree, so it would dim the control's
+ // destructive tint and spinner in the exact state the control exists for.
+ expect(markup).not.toContain('opacity-60')
+ })
+
+ it('names the failure state in the control verb rather than a generic Connect', () => {
+ sshConnectionStates.set('ssh-target-1', { status: 'auth-failed' })
+ sshTargetLabels.set('ssh-target-1', 'Remote target')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Reconnect SSH host Remote target')
+ expect(markup).toContain('authentication failed')
+ })
+
+ it('renders the passive host glyph, not a control, when the SSH host is connected', () => {
+ sshConnectionStates.set('ssh-target-1', { status: 'connected' })
+ sshTargetLabels.set('ssh-target-1', 'Remote target')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Project on SSH host Remote target')
+ expect(markup).not.toContain('Connect to SSH host')
+ })
+
+ // Why: the control keys off repo.connectionId, which is orthogonal to workspace kind —
+ // a folder workspace on a disconnected SSH host must get the same affordance.
+ it('offers the control for a folder workspace on a disconnected SSH host', () => {
+ sshConnectionStates.set('ssh-target-1', { status: 'error' })
+ sshTargetLabels.set('ssh-target-1', 'Remote target')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Retry SSH connection to Remote target')
+ })
+
+ // Why: ssh:listTargets filters runtime-owned targets out, so "absent from the target list"
+ // is not evidence of removal — deriving targetRemoved from it would put every ephemeral-VM
+ // workspace card into the dead "host removed" state.
+ it('never reports a runtime-owned SSH target as removed', () => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).not.toContain('was removed')
+ expect(markup).toContain('Project on SSH host')
+ })
+
+ // Why: a removed host can never connect, so the card must not offer a Connect that only fails.
+ it('reports a removed SSH host once the target list no longer carries it', () => {
+ sshConnectionStates.set('ssh-target-1', { status: 'error' })
+ removedSshTargetLabels.set('ssh-target-1', 'Remote target (removed)')
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('was removed')
+ expect(markup).not.toContain('Retry SSH connection')
})
it('marks a runtime-host worktree disconnected when its environment has no status', () => {
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 9cc437fc513..2d3e2852cea 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -21,13 +21,13 @@ import {
} from 'lucide-react'
import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer'
import WorktreeContextMenu from './WorktreeContextMenu'
-import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt'
import WorktreeCardAgents from './WorktreeCardAgents'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { cn } from '@/lib/utils'
+import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
import { isFolderRepo } from '../../../../shared/repo-kind'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
@@ -94,7 +94,8 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
selectRuntimeAwareSshStatus,
- selectRuntimeAwareSshTargetLabel
+ selectRuntimeAwareSshTargetLabel,
+ selectRuntimeAwareSshTargetRemoved
} from '@/store/slices/runtime-environment-ssh'
import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state'
@@ -362,9 +363,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
}, [sshOwnerEnvironmentId])
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
- // Why: terminal views have their own reconnect overlay; reserve the blocking dialog for non-terminal views (default to terminal when ambiguous).
- const activeViewIsTerminal = useAppStore(
- (s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal'
+ // Why: only reported on positive evidence, so a removed host never offers a Connect that can
+ // only fail. Runtime-owned targets are excluded for the same reason sshStatus excludes them —
+ // ssh:listTargets filters them out, so "absent from the target list" is not evidence of removal.
+ const sshTargetRemoved = useAppStore((s) =>
+ repo?.connectionId && !isRuntimeOwnedSshTargetId(repo.connectionId)
+ ? selectRuntimeAwareSshTargetRemoved(s, sshOwnerEnvironmentId, repo.connectionId)
+ : false
)
const parsedRepoHost = parseExecutionHostId(repo?.executionHostId)
@@ -390,8 +395,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status
})
- // Why: the reconnect dialog blocks, so it never auto-shows for the active card (would steal app-wide focus); opens only on deliberate focus (handleClick).
- const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
const [titleRenaming, setTitleRenaming] = useState(false)
const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false)
// Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state.
@@ -877,10 +880,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree.id,
worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined)
)
- // Why: a deliberate card click warrants the blocking reconnect prompt; skip it when a terminal already shows the overlay.
- if (isSshDisconnected && !activeViewIsTerminal) {
- setShowDisconnectedDialog(true)
- }
onActivate?.()
},
[
@@ -893,7 +892,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
isDeleting,
activationRowKey,
isSshDisconnected,
- activeViewIsTerminal,
onActivate,
onImmediateActivate,
onSelectionGesture
@@ -1428,28 +1426,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{repo?.connectionId && (
-
-
-
- {isSshDisconnected ? (
-
- ) : (
-
- )}
-
-
-
- {isSshDisconnected
- ? translate(
- 'auto.components.sidebar.WorktreeCard.021538e1d1',
- 'SSH disconnected'
- )
- : translate(
- 'auto.components.sidebar.WorktreeCard.ca74db7550',
- 'Project on SSH host'
- )}
-
-
+
)}
{!repo?.connectionId && parsedRepoHost?.kind === 'runtime' && (
@@ -1457,7 +1442,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
{isRuntimeDisconnected ? (
-
+ // Passive by design: runtime ("Orca server") hosts have no
+ // renderer-reachable connect API, unlike the SSH glyph above which is
+ // now a control. Don't "fix" the inconsistency by wiring one up.
+
) : (
)}
@@ -1889,7 +1877,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
],
titleRenaming && '!border-transparent !bg-transparent !shadow-none !ring-0',
isDeleting && 'opacity-50 grayscale cursor-not-allowed',
- (isSshDisconnected || isRuntimeDisconnected) && !isDeleting && 'opacity-60'
+ // Why: no SSH dim — the inline host control now states the disconnected state
+ // explicitly, and a subtree opacity would composite its destructive tint and spinner
+ // down to an illegible alpha (a descendant cannot escape an ancestor's opacity).
+ isRuntimeDisconnected && !isDeleting && 'opacity-60'
)}
data-worktree-card-surface="true"
data-worktree-card-active={isActiveSurface ? activeSurfaceVariant : undefined}
@@ -1940,16 +1931,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
- {repo?.connectionId && (
-
- )}
-
{typeof worktree.firstAgentMessageRenameError === 'string' &&
worktree.firstAgentMessageRenameError.length > 0 && (
({ error: vi.fn() }))
+
+const environmentSshMocks = vi.hoisted(() => ({
+ connectRuntimeEnvironmentSshTarget: vi.fn(),
+ resyncRuntimeEnvironmentSshTargets: vi.fn()
+}))
+
+vi.mock('sonner', () => ({ toast: { error: toastMocks.error } }))
+
+vi.mock('@/runtime/runtime-environment-ssh-state', () => environmentSshMocks)
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string, values?: Record) =>
+ fallback.replace('{{value0}}', values?.value0 ?? '')
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>
+}))
+
+function installSshApi(
+ connect: ReturnType,
+ overrides: Record> = {}
+): void {
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ ssh: {
+ connect,
+ listTargets: vi.fn().mockResolvedValue([]),
+ listRemovedTargetLabels: vi.fn().mockResolvedValue({}),
+ ...overrides
+ }
+ }
+ })
+}
+
+function renderControl(
+ props: Partial> = {}
+) {
+ return render(
+ {}}
+ {...props}
+ />
+ )
+}
+
+describe('WorktreeCardSshHostControl', () => {
+ beforeEach(() => {
+ useAppStore.setState(useAppStore.getInitialState(), true)
+ resetSshConnectInFlightForTests()
+ toastMocks.error.mockReset()
+ environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset()
+ environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockReset()
+ installSshApi(vi.fn().mockResolvedValue(undefined))
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('offers a Connect control naming the host for a disconnected target', () => {
+ renderControl()
+
+ expect(screen.getByRole('button', { name: 'Connect to SSH host devbox' })).toBeEnabled()
+ })
+
+ // Why: the verb must match the terminal overlay, host-header menu, and status-bar row.
+ it.each([
+ ['auth-failed', 'Reconnect'],
+ ['error', 'Retry'],
+ ['reconnection-failed', 'Retry'],
+ ['disconnected', 'Connect']
+ ] as const)('labels the %s state %s', (status, verb) => {
+ renderControl({ status })
+
+ expect(screen.getByRole('button')).toHaveTextContent(verb)
+ })
+
+ it('distinguishes an auth failure from a generic connection failure in the tooltip', () => {
+ const { container } = renderControl({ status: 'auth-failed' })
+ expect(container.querySelector('[data-tooltip]')).toHaveTextContent(
+ 'devbox · authentication failed'
+ )
+
+ cleanup()
+ const retry = renderControl({ status: 'error' })
+ expect(retry.container.querySelector('[data-tooltip]')).toHaveTextContent(
+ 'devbox · connection failed'
+ )
+ })
+
+ it.each(['error', 'reconnection-failed', 'auth-failed'] as const)(
+ 'tints the %s state with the destructive token, not the quiet one',
+ (status) => {
+ renderControl({ status })
+
+ expect(screen.getByRole('button')).toHaveClass('text-destructive')
+ }
+ )
+
+ it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)(
+ 'shows a disabled busy control while the host is %s',
+ (status) => {
+ renderControl({ status })
+
+ const button = screen.getByRole('button', { name: 'Connecting to SSH host devbox' })
+ // Why: aria-disabled, not disabled — a real `disabled` would let a second click fall
+ // through to the card and would kill the tooltip for the whole in-flight window.
+ expect(button).toHaveAttribute('aria-disabled', 'true')
+ expect(button).toHaveAttribute('aria-busy', 'true')
+ expect(button).not.toBeDisabled()
+ }
+ )
+
+ it('renders the passive host glyph, not a control, when connected', () => {
+ renderControl({ status: 'connected' })
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument()
+ })
+
+ // Why: runtime-owned targets deliberately have no renderer-visible status; the card has
+ // always shown the plain host glyph there rather than a false disconnected state.
+ it('renders the passive host glyph for a null status', () => {
+ renderControl({ status: null })
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument()
+ })
+
+ // Why: this is the exact bug targetRemoved exists to prevent — a Connect that can only fail.
+ it('never offers Connect for a removed host, even in a failed state', () => {
+ renderControl({ status: 'error', targetRemoved: true })
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.getByText('SSH host devbox was removed')).toBeInTheDocument()
+ })
+
+ it('keeps the label available to assistive tech but not on screen in icon-only mode', () => {
+ renderControl({ iconOnly: true })
+
+ // Why: aria-label is the accessible name, so a second sr-only copy would be dead markup.
+ const button = screen.getByRole('button', { name: 'Connect to SSH host devbox' })
+ expect(button).not.toHaveTextContent('Connect')
+ expect(button).toHaveClass('w-4')
+ })
+
+ it('connects and mirrors the returned state so deferred PTY reattach can resume', async () => {
+ const connectedState: SshConnectionState = {
+ targetId: 'ssh-target-1',
+ status: 'connected',
+ error: null,
+ reconnectAttempt: 0,
+ remotePlatform: 'linux'
+ }
+ const connect = vi.fn().mockResolvedValue(connectedState)
+ installSshApi(connect)
+ const user = userEvent.setup()
+ renderControl()
+
+ await user.click(screen.getByRole('button'))
+
+ expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-target-1' })
+ await waitFor(() =>
+ expect(useAppStore.getState().sshConnectionStates.get('ssh-target-1')).toEqual(connectedState)
+ )
+ })
+
+ // Why: reconnecting a host and navigating to its workspace are separate intents; the
+ // control sits inside the card's own click target.
+ it('does not activate the surrounding card when clicked', async () => {
+ const onCardClick = vi.fn()
+ const user = userEvent.setup()
+ render(
+
+ {}}
+ />
+
+ )
+
+ await user.click(screen.getByRole('button'))
+
+ expect(onCardClick).not.toHaveBeenCalled()
+ })
+
+ it('reports connect failures and resyncs target metadata so a ghost host converges', async () => {
+ const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-target-1" not found'))
+ const listTargets = vi
+ .fn()
+ .mockResolvedValue([
+ { id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' }
+ ])
+ const listRemovedTargetLabels = vi
+ .fn()
+ .mockResolvedValue({ 'ssh-target-1': 'devbox (removed)' })
+ installSshApi(connect, { listTargets, listRemovedTargetLabels })
+ const user = userEvent.setup()
+ renderControl()
+
+ await user.click(screen.getByRole('button'))
+
+ await waitFor(() =>
+ expect(toastMocks.error).toHaveBeenCalledWith('SSH target "ssh-target-1" not found')
+ )
+ await waitFor(() => {
+ expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox')
+ expect(useAppStore.getState().removedSshTargetLabels.get('ssh-target-1')).toBe(
+ 'devbox (removed)'
+ )
+ })
+ })
+
+ it('routes connect to the owning Orca server for a remote-owned target', async () => {
+ const connect = vi.fn().mockResolvedValue(undefined)
+ installSshApi(connect)
+ environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue(null)
+ const user = userEvent.setup()
+ renderControl({ sshOwnerEnvironmentId: 'env-1' })
+
+ await user.click(screen.getByRole('button'))
+
+ await waitFor(() =>
+ expect(environmentSshMocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith(
+ 'env-1',
+ 'ssh-target-1'
+ )
+ )
+ // The local ssh API must never see a remote host's target.
+ expect(connect).not.toHaveBeenCalled()
+ })
+
+ // Why: N cards can share one host, and a passphrase-gated target would prompt N times.
+ it('suppresses a sibling card dialing a host that is already connecting', async () => {
+ const connect = vi.fn().mockReturnValue(new Promise(() => {}))
+ installSshApi(connect)
+ const user = userEvent.setup()
+ render(
+ <>
+ {}}
+ />
+ {}}
+ />
+ >
+ )
+
+ const [first, second] = screen.getAllByRole('button')
+ await user.click(first)
+
+ await waitFor(() => expect(second).toHaveAttribute('aria-disabled', 'true'))
+ expect(connect).toHaveBeenCalledTimes(1)
+ })
+
+ // Why: aria-disabled leaves the button clickable, so the handler owns the guard.
+ it('ignores a second click while its own connect is in flight', async () => {
+ const connect = vi.fn().mockReturnValue(new Promise(() => {}))
+ installSshApi(connect)
+ const user = userEvent.setup()
+ renderControl()
+
+ const button = screen.getByRole('button')
+ await user.click(button)
+ await user.click(button)
+
+ expect(connect).toHaveBeenCalledTimes(1)
+ })
+
+ // Why: the sidebar scroll root treats a bubbled Enter as "focus the terminal", which would
+ // cancel the button's activation and move focus off the card.
+ it('does not let Enter or Space bubble to the sidebar key handler', async () => {
+ const onContainerKeyDown = vi.fn()
+ const user = userEvent.setup()
+ render(
+
+ {}}
+ />
+
+ )
+
+ screen.getByRole('button').focus()
+ await user.keyboard('{Enter}')
+ await user.keyboard(' ')
+
+ expect(onContainerKeyDown).not.toHaveBeenCalled()
+ })
+
+ it('shows the connected glyph even if a stale removal tombstone is present', () => {
+ renderControl({ status: 'connected', targetRemoved: true })
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument()
+ })
+
+ // Why: the title row hosts sibling h-4 pills; a taller state would shift every card on the host.
+ it('carries one height class across every actionable state', () => {
+ const heights = new Set()
+ for (const status of [
+ 'disconnected',
+ 'error',
+ 'auth-failed',
+ 'reconnection-failed',
+ 'connecting',
+ 'deploying-relay',
+ 'reconnecting'
+ ] as const) {
+ cleanup()
+ renderControl({ status })
+ const classes = screen.getByRole('button').className
+ heights.add(classes.split(' ').find((token) => token.startsWith('h-')) ?? 'none')
+ }
+
+ expect([...heights]).toEqual(['h-4'])
+ })
+
+ // Why: the passive branch swaps the 16px button for a bare glyph span; a larger icon there
+ // would make the row taller in exactly the states with nothing to act on.
+ it.each([
+ ['connected', false],
+ [null, false],
+ ['error', true]
+ ] as const)('sizes the passive glyph for %s at size-3', (status, targetRemoved) => {
+ const { container } = renderControl({ status, targetRemoved })
+
+ expect(container.querySelector('svg')).toHaveClass('size-3')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx
new file mode 100644
index 00000000000..890baba32e7
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx
@@ -0,0 +1,283 @@
+import { useCallback } from 'react'
+import { Loader2, Server, ServerOff } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '@/store'
+import {
+ connectRuntimeEnvironmentSshTarget,
+ resyncRuntimeEnvironmentSshTargets
+} from '@/runtime/runtime-environment-ssh-state'
+import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
+import { sshConnectingLabel, sshConnectVerb } from '@/ssh/ssh-connect-verb'
+import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
+import {
+ isSshConnectInFlight,
+ trackSshConnect,
+ useSshConnectInFlight
+} from '@/ssh/ssh-connect-in-flight'
+import type { SshConnectionStatus } from '../../../../shared/ssh-types'
+
+type WorktreeCardSshHostControlProps = {
+ targetId: string
+ /** Card passes `sshTargetLabel || repo.displayName` — the selector can return a bare target id. */
+ targetLabel: string
+ /** Null for runtime-owned targets: renders the passive connected glyph, as before. */
+ status: SshConnectionStatus | null
+ targetRemoved: boolean
+ /** Non-null when the SSH target belongs to a remote Orca server; routes connect to that runtime. */
+ sshOwnerEnvironmentId: string | null
+ /** True when the row cannot afford a visible label: icon-only with an sr-only label. */
+ iconOnly: boolean
+ onPointerDown: React.PointerEventHandler
+}
+
+// One shape for every state, from the sibling rename-failed control in the same title row
+// (WorktreeCard.tsx). States differ only by color token, so the pill never changes height.
+const PILL_BASE =
+ 'h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none has-[>svg]:!px-0.5'
+const PILL_QUIET =
+ 'text-muted-foreground border border-worktree-sidebar-border bg-worktree-sidebar shadow-none hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:border-worktree-sidebar-border focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring'
+const PILL_FAILED =
+ 'text-destructive border border-destructive/40 bg-destructive/10 hover:bg-destructive/15 hover:text-destructive focus-visible:border-destructive/40 focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring'
+
+function PassiveGlyph({
+ icon,
+ tooltip,
+ accessibleName,
+ targetLabel
+}: {
+ icon: React.ReactNode
+ tooltip: string
+ accessibleName: string
+ targetLabel: string
+}): React.JSX.Element {
+ return (
+
+
+
+ {icon}
+ {accessibleName}
+
+
+
+ {tooltip}
+
+
+ )
+}
+
+export function WorktreeCardSshHostControl({
+ targetId,
+ targetLabel,
+ status,
+ targetRemoved,
+ sshOwnerEnvironmentId,
+ iconOnly,
+ onPointerDown
+}: WorktreeCardSshHostControlProps): React.JSX.Element | null {
+ const setSshConnectionState = useAppStore((store) => store.setSshConnectionState)
+ // Why: shared registry, not local state — the terminal overlay and every other card on
+ // this host dial the same connection, and the store status lags a click by one IPC hop.
+ const inFlight = useSshConnectInFlight(targetId)
+
+ const handleConnect = useCallback(async () => {
+ if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) {
+ return
+ }
+ try {
+ if (sshOwnerEnvironmentId) {
+ // Bucket state is written inside the helper, mirroring the local path.
+ await trackSshConnect(
+ targetId,
+ connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId)
+ )
+ } else {
+ // Why: track the connect request, not this bounded wait — the backend is still
+ // dialing after the UI timeout fires, so releasing here would let the next click
+ // raise a second credential prompt.
+ const connectState = await withUiConnectTimeout(
+ trackSshConnect(targetId, window.api.ssh.connect({ targetId })),
+ SSH_RECONNECT_UI_TIMEOUT_MS
+ )
+ if (connectState) {
+ // Why: ssh.connect can resolve before the global state-change IPC lands;
+ // the waiting deferred PTY reattach path keys off this renderer store.
+ setSshConnectionState(targetId, connectState)
+ }
+ }
+ } catch (err) {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed',
+ 'SSH connection failed'
+ )
+ )
+ // Why: a failed connect usually means the renderer's target metadata is stale
+ // (target removed, or re-added under a new id). Resync so the control converges to
+ // the removed state instead of offering the same failing Connect forever (STA-1468).
+ // Apply the target list first — a removed-labels failure must not discard it.
+ if (sshOwnerEnvironmentId) {
+ void resyncRuntimeEnvironmentSshTargets(sshOwnerEnvironmentId).catch(() => {})
+ } else {
+ void (async () => {
+ const targets = await window.api.ssh.listTargets()
+ useAppStore.getState().setSshTargetsMetadata(targets)
+ const removedLabels = await window.api.ssh.listRemovedTargetLabels()
+ useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
+ })().catch(() => {})
+ }
+ }
+ }, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId])
+
+ // A live connection outranks a stale removal tombstone. A null status is a runtime-owned
+ // target: no renderer-reachable connect, and the card has always shown the plain host glyph
+ // there rather than a false disconnected state.
+ if (status === null || status === 'connected') {
+ return (
+ }
+ tooltip={translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.connectedTooltip',
+ 'Project on SSH host'
+ )}
+ accessibleName={translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.connectedName',
+ 'Project on SSH host {{value0}}',
+ { value0: targetLabel }
+ )}
+ />
+ )
+ }
+
+ // Why: a removed host can never connect, so it is checked before any failure status —
+ // offering Connect there is the exact bug targetRemoved exists to prevent. It also drops the
+ // destructive tint: a removed host is a settled fact, not an error to act on.
+ if (targetRemoved) {
+ return (
+ }
+ tooltip={translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.removedTooltip',
+ 'SSH host removed — reconnect unavailable'
+ )}
+ accessibleName={translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.removedName',
+ 'SSH host {{value0}} was removed',
+ { value0: targetLabel }
+ )}
+ />
+ )
+ }
+
+ const connecting = inFlight || isConnectingSshStatus(status)
+ const canConnect = canConnectSshStatus(status)
+ if (!connecting && !canConnect) {
+ // Defensive: every remaining member is either connecting or recoverable, but never
+ // render a dead button if the union grows.
+ return null
+ }
+
+ const failed = status === 'error' || status === 'reconnection-failed' || status === 'auth-failed'
+ const label = connecting ? sshConnectingLabel() : sshConnectVerb(status)
+ const accessibleName = connecting
+ ? translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.connectingName',
+ 'Connecting to SSH host {{value0}}',
+ { value0: targetLabel }
+ )
+ : status === 'auth-failed'
+ ? translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.authFailedName',
+ 'Reconnect SSH host {{value0}} — authentication failed',
+ { value0: targetLabel }
+ )
+ : failed
+ ? translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.retryName',
+ 'Retry SSH connection to {{value0}}',
+ { value0: targetLabel }
+ )
+ : translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.connectName',
+ 'Connect to SSH host {{value0}}',
+ { value0: targetLabel }
+ )
+ const tooltip = connecting
+ ? accessibleName
+ : status === 'auth-failed'
+ ? translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.authFailedTooltip',
+ '{{value0}} · authentication failed',
+ { value0: targetLabel }
+ )
+ : failed
+ ? translate(
+ 'auto.components.sidebar.WorktreeCardSshHostControl.failedTooltip',
+ '{{value0}} · connection failed',
+ { value0: targetLabel }
+ )
+ : accessibleName
+
+ return (
+
+
+ svg]:!px-0'
+ )}
+ aria-label={accessibleName}
+ data-ssh-target-label={targetLabel}
+ aria-busy={connecting || undefined}
+ // Why: aria-disabled, not disabled — `disabled` adds pointer-events-none, so an
+ // impatient second click would fall through to the card (activating the workspace,
+ // or opening Edit metadata on a double-click), drop focus to , and kill the
+ // tooltip for the entire in-flight window.
+ aria-disabled={connecting || undefined}
+ onPointerDown={onPointerDown}
+ onKeyDown={(event) => {
+ // Why: the sidebar scroll root treats a bubbled Enter as "focus the terminal"
+ // (WorktreeList handleContainerKeyDown), which would cancel this button's own
+ // activation and move focus off the card.
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.stopPropagation()
+ }
+ }}
+ onClick={(event) => {
+ // Reconnecting a host and navigating to a workspace are separate intents.
+ event.stopPropagation()
+ event.preventDefault()
+ if (connecting) {
+ return
+ }
+ void handleConnect()
+ }}
+ >
+ {connecting ? (
+
+ ) : (
+ iconOnly &&
+ )}
+ {/* Why: aria-label already names the control, so a second sr-only copy would be
+ dead markup; the label span exists only for sighted users. */}
+ {!iconOnly && {label} }
+
+
+
+ {tooltip}
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx
index 82630b66d8b..5a644f0d265 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx
@@ -144,10 +144,6 @@ vi.mock('./CacheTimer', () => ({
// NOTE: intentionally NOT mocking ./WorktreeCardAgents — we render the real one.
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
index 53d16e0d7b4..9d97e502065 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
+++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
@@ -212,26 +212,6 @@ vi.mock('./WorktreeContextMenu', () => ({
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope'
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: ({
- open,
- status,
- targetId,
- targetLabel
- }: {
- open: boolean
- status: string
- targetId: string
- targetLabel: string
- }) =>
- React.createElement('aside', {
- 'data-lineage-ssh-dialog': open ? 'open' : 'closed',
- 'data-ssh-status': status,
- 'data-ssh-target-id': targetId,
- 'data-ssh-target-label': targetLabel
- })
-}))
-
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx
index 7a5dfbb13f9..52f3a591473 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx
@@ -142,10 +142,6 @@ vi.mock('./WorktreeCardAgents', () => ({
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx
index 0311879c5b7..00c9e47a0c8 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx
@@ -143,10 +143,6 @@ vi.mock('./WorktreeCardAgents', () => ({
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
}))
-vi.mock('./SshDisconnectedDialog', () => ({
- SshDisconnectedDialog: () => null
-}))
-
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
index 42049cb5a25..70e500ad09a 100644
--- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx
+++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
@@ -21,10 +21,7 @@ import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './Runtime
import { SshTargetStatusRow } from './SshTargetStatusRow'
import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types'
import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect'
-
-function isConnecting(status: SshConnectionStatus): boolean {
- return ['connecting', 'deploying-relay', 'reconnecting'].includes(status)
-}
+import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
type HostStatus = 'connected' | 'disconnected' | 'connecting'
@@ -70,7 +67,7 @@ function sshStatusForOverall(status: SshConnectionStatus): HostStatus {
if (status === 'connected') {
return 'connected'
}
- return isConnecting(status) ? 'connecting' : 'disconnected'
+ return isConnectingSshStatus(status) ? 'connecting' : 'disconnected'
}
function runtimeHostConnectionState({
diff --git a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx
index a19b17b40e9..c8fa9100b90 100644
--- a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx
+++ b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx
@@ -5,13 +5,17 @@ import { translate } from '@/i18n/i18n'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '../../store'
import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard'
+import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability'
+import { sshConnectVerb } from '@/ssh/ssh-connect-verb'
+import {
+ beginSshConnect,
+ endSshConnect,
+ isSshConnectInFlight,
+ useSshConnectInFlight
+} from '@/ssh/ssh-connect-in-flight'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh'
-function isReconnectable(status: SshConnectionStatus): boolean {
- return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status)
-}
-
function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string | null {
switch (status?.phase) {
case 'pulling':
@@ -62,9 +66,16 @@ export function SshTargetStatusRow({
const [busy, setBusy] = useState(false)
const mountedRef = useMountedRef()
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
+ // Why: shared with the sidebar card control and terminal overlay — a connect started here
+ // must disable those too, in the window before main broadcasts 'connecting'.
+ const connectInFlight = useSshConnectInFlight(targetId)
const visibleSyncStatusLabel = syncStatusLabel(syncStatus)
const handleConnect = useCallback(async () => {
+ if (isSshConnectInFlight(targetId)) {
+ return
+ }
+ beginSshConnect(targetId)
setBusy(true)
try {
await window.api.ssh.connect({ targetId })
@@ -76,6 +87,7 @@ export function SshTargetStatusRow({
: translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed')
)
} finally {
+ endSshConnect(targetId)
if (mountedRef.current) {
setBusy(false)
}
@@ -130,15 +142,15 @@ export function SshTargetStatusRow({
) : null}
- {busy ? (
+ {busy || connectInFlight ? (
- ) : isReconnectable(status) ? (
+ ) : canConnectSshStatus(status) ? (
void handleConnect()}
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent/70"
>
- {translate('auto.components.status.bar.SshStatusSegment.63f36455cc', 'Connect')}
+ {sshConnectVerb(status)}
) : status === 'connected' ? (
({
@@ -59,6 +60,7 @@ function installSshConnect(
describe('TerminalSshReconnectOverlay', () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
+ resetSshConnectInFlightForTests()
toastMocks.error.mockReset()
deleteFlowMocks.runWorktreeDelete.mockReset()
environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset()
@@ -106,7 +108,7 @@ describe('TerminalSshReconnectOverlay', () => {
)
expect(screen.getByText(/Connecting to devbox/)).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /Connecting.../ })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Connecting…/ })).toBeDisabled()
expect(connect).not.toHaveBeenCalled()
})
@@ -123,10 +125,10 @@ describe('TerminalSshReconnectOverlay', () => {
/>
)
- await user.click(screen.getByRole('button', { name: 'Connect' }))
+ await user.click(screen.getByRole('button', { name: 'Reconnect' }))
await waitFor(() => expect(toastMocks.error).toHaveBeenCalledWith('Passphrase rejected'))
- expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled()
+ expect(screen.getByRole('button', { name: 'Reconnect' })).toBeEnabled()
})
it('resyncs target metadata after a failed connect so a stale overlay converges', async () => {
@@ -262,6 +264,44 @@ describe('TerminalSshReconnectOverlay', () => {
expect(useAppStore.getState().sshTargetsHydrated).toBe(false)
})
+ // Why: the sidebar card control, the host-header menu, and this overlay can be on screen
+ // at once; a shared verb table keeps them from naming the same click three ways.
+ it.each([
+ ['disconnected', 'Connect'],
+ ['auth-failed', 'Reconnect'],
+ ['error', 'Retry'],
+ ['reconnection-failed', 'Retry']
+ ] as const)('labels the %s action %s, matching every other SSH surface', (status, verb) => {
+ installSshConnect(vi.fn().mockResolvedValue(undefined))
+
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: verb })).toBeEnabled()
+ })
+
+ it('suppresses a second connect while one is already in flight for the same target', async () => {
+ const connect = vi.fn().mockReturnValue(new Promise(() => {}))
+ installSshConnect(connect)
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: 'Connect' }))
+
+ // Why: N surfaces share one connection; a second dial on a passphrase-gated
+ // target means a second credential prompt.
+ await waitFor(() => expect(screen.getByRole('button', { name: /Connecting…/ })).toBeDisabled())
+ expect(connect).toHaveBeenCalledTimes(1)
+ })
+
it('publishes the returned SSH state so deferred terminal reattach can resume', async () => {
const connectedState: SshConnectionState = {
targetId: 'ssh-target-1',
diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
index b0506a28a37..bf74d617f3f 100644
--- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
@@ -1,8 +1,7 @@
-import { useCallback, useState } from 'react'
+import { useCallback } from 'react'
import { Loader2, Server, ServerOff } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
-import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
@@ -11,6 +10,14 @@ import {
connectRuntimeEnvironmentSshTarget,
resyncRuntimeEnvironmentSshTargets
} from '@/runtime/runtime-environment-ssh-state'
+import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
+import { sshConnectingLabel, sshConnectVerb } from '@/ssh/ssh-connect-verb'
+import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout'
+import {
+ isSshConnectInFlight,
+ trackSshConnect,
+ useSshConnectInFlight
+} from '@/ssh/ssh-connect-in-flight'
type TerminalSshReconnectOverlayProps = {
targetId: string
@@ -26,16 +33,6 @@ type TerminalSshReconnectOverlayProps = {
sshOwnerEnvironmentId?: string | null
}
-// Why: relay deployment/reconnect are host-driven transient states; the
-// failure statuses need a user-initiated retry before the PTY can resume.
-function isConnectingStatus(status: SshConnectionStatus): boolean {
- return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
-}
-
-function canConnectStatus(status: SshConnectionStatus): boolean {
- return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status)
-}
-
function messageForStatus(status: SshConnectionStatus, targetLabel: string): string {
switch (status) {
case 'auth-failed':
@@ -81,24 +78,33 @@ export function TerminalSshReconnectOverlay({
worktreeId,
sshOwnerEnvironmentId = null
}: TerminalSshReconnectOverlayProps): React.JSX.Element {
- const [connecting, setConnecting] = useState(false)
- const mountedRef = useMountedRef()
const setSshConnectionState = useAppStore((store) => store.setSshConnectionState)
- const isConnecting = connecting || isConnectingStatus(status)
+ // Why: shared registry, not local state — the sidebar card control can dial the same
+ // target, and the store status lags a click by one IPC hop.
+ const connecting = useSshConnectInFlight(targetId)
+ const isConnecting = connecting || isConnectingSshStatus(status)
// Why: a removed target can never reconnect, so never offer Connect for it.
- const showConnect = !targetRemoved && canConnectStatus(status)
+ const showConnect = !targetRemoved && canConnectSshStatus(status)
const handleConnect = useCallback(async () => {
- if (isConnecting) {
+ if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) {
return
}
- setConnecting(true)
try {
if (sshOwnerEnvironmentId) {
// Bucket state is written inside the helper, mirroring the local path.
- await connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId)
+ await trackSshConnect(
+ targetId,
+ connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId)
+ )
} else {
- const connectState = await window.api.ssh.connect({ targetId })
+ // Why: track the connect request, not this bounded wait — the backend is still
+ // dialing after the UI timeout fires, so releasing here would let the next click
+ // raise a second credential prompt.
+ const connectState = await withUiConnectTimeout(
+ trackSshConnect(targetId, window.api.ssh.connect({ targetId })),
+ SSH_RECONNECT_UI_TIMEOUT_MS
+ )
if (connectState) {
// Why: ssh.connect can resolve before the global state-change IPC lands;
// the waiting deferred PTY reattach path keys off this renderer store.
@@ -129,12 +135,8 @@ export function TerminalSshReconnectOverlay({
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
})().catch(() => {})
}
- } finally {
- if (mountedRef.current) {
- setConnecting(false)
- }
}
- }, [isConnecting, mountedRef, setSshConnectionState, sshOwnerEnvironmentId, targetId])
+ }, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId])
// Why: z-40 clears pane-local chrome (focus rim z-30); bg-card is fully opaque so terminal text cannot paint through.
return (
@@ -204,16 +206,10 @@ export function TerminalSshReconnectOverlay({
{!showConnect || isConnecting ? (
<>
- {translate(
- 'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectingButton',
- 'Connecting...'
- )}
+ {sshConnectingLabel()}
>
) : (
- translate(
- 'auto.components.terminal.pane.TerminalSshReconnectOverlay.connectButton',
- 'Connect'
- )
+ sshConnectVerb(status)
)}
)}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index fdace14f0d6..b1eb0900865 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -8246,7 +8246,7 @@ export function connectPanePty(
const alreadyConnected =
useAppStore.getState().sshConnectionStates.get(connectionId)?.status === 'connected'
if (!alreadyConnected) {
- // Wait for the user-driven connect (SshDisconnectedDialog → passphrase → ssh.connect) to complete.
+ // Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete.
// Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails.
const outcome = await new Promise((resolve) => {
// Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected').
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index 35c48344e63..bed3e4e86c8 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -24,6 +24,7 @@ import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constan
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
+import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
@@ -2806,7 +2807,7 @@ export function useIpcEvents(): void {
const previous = store.sshConnectionStates?.get(targetId)
store.setSshConnectionState(targetId, state)
- if (['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)) {
+ if (canConnectSshStatus(state.status)) {
reconnectAuthorityByTarget.delete(targetId)
reconnectCoordinator.invalidate(targetId)
// Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay.
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index b7f0deb0871..78658fcebce 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -4447,19 +4447,6 @@
"folderPathIdentity": "Branch / folder path",
"cli": "Orca CLI"
},
- "SshDisconnectedDialog": {
- "ca4a7892af": "Connecting...",
- "89385db176": "Dismiss",
- "656368f3a2": "Reconnection failed",
- "376bed88e5": "The connection to the remote host encountered an error.",
- "4afcca1d24": "Reconnect",
- "11552bf786": "SSH Disconnected",
- "cb5938ae79": "Reconnecting...",
- "disconnected": "This SSH host is not connected.",
- "reconnecting": "Reconnecting to the remote host...",
- "reconnectionFailed": "Reconnection to the remote host failed.",
- "authFailed": "Authentication to the remote host failed."
- },
"SshTargetRow": {
"4677394048": "Connecting…",
"75ad429b5d": "Connect"
@@ -5155,6 +5142,19 @@
"3b7ea51793": "Clear search",
"7f1c2e94a5": "Search text is too long — the board is unfiltered",
"9a4d0f6b21": "Too long"
+ },
+ "WorktreeCardSshHostControl": {
+ "connectFailed": "SSH connection failed",
+ "removedTooltip": "SSH host removed — reconnect unavailable",
+ "removedName": "SSH host {{value0}} was removed",
+ "connectedTooltip": "Project on SSH host",
+ "connectedName": "Project on SSH host {{value0}}",
+ "connectingName": "Connecting to SSH host {{value0}}",
+ "authFailedName": "Reconnect SSH host {{value0}} — authentication failed",
+ "retryName": "Retry SSH connection to {{value0}}",
+ "connectName": "Connect to SSH host {{value0}}",
+ "authFailedTooltip": "{{value0}} · authentication failed",
+ "failedTooltip": "{{value0}} · connection failed"
}
},
"shared": {
@@ -14619,6 +14619,14 @@
"emptyTrace": "No log is available for this GitLab job.",
"timedOut": "Timed out loading the GitLab job log."
}
+ },
+ "ssh": {
+ "sshConnectVerb": {
+ "reconnect": "Reconnect",
+ "retry": "Retry",
+ "connect": "Connect",
+ "connecting": "Connecting…"
+ }
}
},
"components": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index a970938ac40..dbe94a85ab4 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -4356,19 +4356,6 @@
"folderPathIdentity": "Rama / ruta de carpeta",
"cli": "Orca CLI"
},
- "SshDisconnectedDialog": {
- "ca4a7892af": "Conectando...",
- "89385db176": "Descartar",
- "656368f3a2": "Falló la reconexión",
- "376bed88e5": "La conexión al host remoto encontró un error.",
- "4afcca1d24": "Reconectar",
- "11552bf786": "SSH desconectado",
- "cb5938ae79": "Reconectando...",
- "disconnected": "Este host SSH no está conectado.",
- "reconnecting": "Reconectando con el host remoto...",
- "reconnectionFailed": "Falló la reconexión con el host remoto.",
- "authFailed": "Falló la autenticación con el host remoto."
- },
"SshTargetRow": {
"4677394048": "Conectando…",
"75ad429b5d": "Conectar"
@@ -14365,6 +14352,14 @@
"emptyTrace": "No hay ningún registro disponible para este job de GitLab.",
"timedOut": "Se agotó el tiempo de espera al cargar el registro del job de GitLab."
}
+ },
+ "ssh": {
+ "sshConnectVerb": {
+ "connect": "Conectar",
+ "connecting": "Conectando…",
+ "reconnect": "Reconectar",
+ "retry": "Reintentar"
+ }
}
},
"components": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 769a06c354f..830e17809cd 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -4337,19 +4337,6 @@
"folderPathIdentity": "ブランチ / フォルダパス",
"cli": "Orca CLI"
},
- "SshDisconnectedDialog": {
- "ca4a7892af": "接続中...",
- "89385db176": "閉じる",
- "656368f3a2": "再接続に失敗しました",
- "376bed88e5": "リモート ホストへの接続でエラーが発生しました。",
- "4afcca1d24": "再接続",
- "11552bf786": "SSH が切断されました",
- "cb5938ae79": "再接続中...",
- "disconnected": "This remote repository is not connected.",
- "reconnecting": "リモートホストに再接続中...",
- "reconnectionFailed": "リモートホストへの再接続に失敗しました。",
- "authFailed": "リモートホストへの認証に失敗しました。"
- },
"SshTargetRow": {
"4677394048": "接続中…",
"75ad429b5d": "接続"
@@ -14365,6 +14352,14 @@
"emptyTrace": "この GitLab ジョブのログはありません。",
"timedOut": "GitLab ジョブのログの読み込みがタイムアウトしました。"
}
+ },
+ "ssh": {
+ "sshConnectVerb": {
+ "connect": "接続",
+ "connecting": "接続中…",
+ "reconnect": "再接続",
+ "retry": "再試行"
+ }
}
},
"components": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index a1309686c99..4d1ed1e286e 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -4337,19 +4337,6 @@
"folderPathIdentity": "브랜치 / 폴더 경로",
"cli": "Orca CLI"
},
- "SshDisconnectedDialog": {
- "ca4a7892af": "연결 중...",
- "89385db176": "닫기",
- "656368f3a2": "재연결 실패",
- "376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.",
- "4afcca1d24": "다시 연결",
- "11552bf786": "SSH 연결 끊김",
- "cb5938ae79": "다시 연결하는 중...",
- "disconnected": "이 SSH 호스트는 연결되어 있지 않습니다.",
- "reconnecting": "원격 호스트에 다시 연결하는 중...",
- "reconnectionFailed": "원격 호스트에 다시 연결하지 못했습니다.",
- "authFailed": "원격 호스트 인증에 실패했습니다."
- },
"SshTargetRow": {
"4677394048": "연결 중…",
"75ad429b5d": "연결"
@@ -14376,6 +14363,14 @@
"emptyTrace": "이 GitLab 작업의 로그를 사용할 수 없습니다.",
"timedOut": "GitLab 작업 로그를 불러오는 중 시간이 초과되었습니다."
}
+ },
+ "ssh": {
+ "sshConnectVerb": {
+ "connect": "연결",
+ "connecting": "연결 중…",
+ "reconnect": "다시 연결",
+ "retry": "재시도"
+ }
}
},
"components": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 2d7abce6580..a76a2b01b06 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -4349,19 +4349,6 @@
"folderPathIdentity": "分支 / 文件夹路径",
"cli": "Orca CLI"
},
- "SshDisconnectedDialog": {
- "ca4a7892af": "正在连接...",
- "89385db176": "关闭",
- "656368f3a2": "重连失败",
- "376bed88e5": "与远程主机的连接遇到错误。",
- "4afcca1d24": "重新连接",
- "11552bf786": "SSH 已断开",
- "cb5938ae79": "正在重新连接...",
- "disconnected": "此 SSH 主机未连接。",
- "reconnecting": "正在重新连接远程主机...",
- "reconnectionFailed": "重新连接到远程主机失败。",
- "authFailed": "远程主机身份验证失败。"
- },
"SshTargetRow": {
"4677394048": "正在连接…",
"75ad429b5d": "连接"
@@ -14385,6 +14372,14 @@
"emptyTrace": "此 GitLab 作业没有可用的日志。",
"timedOut": "加载 GitLab 作业日志超时。"
}
+ },
+ "ssh": {
+ "sshConnectVerb": {
+ "connect": "连接",
+ "connecting": "正在连接…",
+ "reconnect": "重新连接",
+ "retry": "重试"
+ }
}
},
"components": {
diff --git a/src/renderer/src/lib/new-workspace-ssh-gate.ts b/src/renderer/src/lib/new-workspace-ssh-gate.ts
index f597e18e245..ac1ae7afe55 100644
--- a/src/renderer/src/lib/new-workspace-ssh-gate.ts
+++ b/src/renderer/src/lib/new-workspace-ssh-gate.ts
@@ -1,3 +1,4 @@
+import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
@@ -9,7 +10,7 @@ export type SelectedRepoSshGate = {
}
export function isSshConnectInProgress(status: SshConnectionStatus | null): boolean {
- return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
+ return isConnectingSshStatus(status)
}
export function getSelectedRepoSshGate(input: {
diff --git a/src/renderer/src/ssh/ssh-connect-in-flight.test.ts b/src/renderer/src/ssh/ssh-connect-in-flight.test.ts
new file mode 100644
index 00000000000..4225c0d9f06
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connect-in-flight.test.ts
@@ -0,0 +1,150 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ beginSshConnect,
+ endSshConnect,
+ isSshConnectInFlight,
+ resetSshConnectInFlightForTests,
+ subscribeSshConnectInFlight,
+ trackSshConnect
+} from './ssh-connect-in-flight'
+import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from './ssh-connect-ui-timeout'
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+describe('ssh connect in-flight registry', () => {
+ beforeEach(() => {
+ resetSshConnectInFlightForTests()
+ })
+
+ it('tracks connects per target, so one host dialing does not disable another', () => {
+ beginSshConnect('ssh-a')
+
+ expect(isSshConnectInFlight('ssh-a')).toBe(true)
+ expect(isSshConnectInFlight('ssh-b')).toBe(false)
+ })
+
+ it('clears the target when the connect settles', () => {
+ beginSshConnect('ssh-a')
+ endSshConnect('ssh-a')
+
+ expect(isSshConnectInFlight('ssh-a')).toBe(false)
+ })
+
+ it('notifies subscribers on both edges', () => {
+ const listener = vi.fn()
+ subscribeSshConnectInFlight(listener)
+
+ beginSshConnect('ssh-a')
+ endSshConnect('ssh-a')
+
+ expect(listener).toHaveBeenCalledTimes(2)
+ })
+
+ // Why: every surface renders from one registry entry, so a duplicate begin must not
+ // emit again — and the paired end must not clear the flag while a connect is still live.
+ it('ignores a duplicate begin without re-notifying', () => {
+ const listener = vi.fn()
+ subscribeSshConnectInFlight(listener)
+
+ beginSshConnect('ssh-a')
+ beginSshConnect('ssh-a')
+
+ expect(listener).toHaveBeenCalledTimes(1)
+ expect(isSshConnectInFlight('ssh-a')).toBe(true)
+ })
+
+ // Why: a connect handler's finally can run for a target it never began (early return
+ // paths), and a spurious notify would re-render every subscribed card.
+ it('ignores an end for a target that was never in flight', () => {
+ const listener = vi.fn()
+ subscribeSshConnectInFlight(listener)
+
+ endSshConnect('ssh-a')
+
+ expect(listener).not.toHaveBeenCalled()
+ expect(isSshConnectInFlight('ssh-a')).toBe(false)
+ })
+
+ describe('trackSshConnect', () => {
+ it('holds the lock while the request is pending and clears it on resolve', async () => {
+ let settle: (value: string) => void = () => {}
+ const request = trackSshConnect(
+ 'ssh-a',
+ new Promise((resolve) => {
+ settle = resolve
+ })
+ )
+
+ expect(isSshConnectInFlight('ssh-a')).toBe(true)
+
+ settle('connected')
+ await request
+
+ expect(isSshConnectInFlight('ssh-a')).toBe(false)
+ })
+
+ it('clears the lock when the request rejects', async () => {
+ const request = trackSshConnect('ssh-a', Promise.reject(new Error('Passphrase rejected')))
+
+ await expect(request).rejects.toThrow('Passphrase rejected')
+ await Promise.resolve()
+
+ expect(isSshConnectInFlight('ssh-a')).toBe(false)
+ })
+
+ // The regression this guards: the UI wait is a race that rejects at the timeout while the
+ // backend keeps dialing. Releasing the lock there would let the next click on any surface
+ // for this host fire a second connect — a second credential prompt on a gated target.
+ it('keeps the lock after a UI timeout abandons the wait, so a second dial is suppressed', async () => {
+ vi.useFakeTimers()
+ try {
+ const request = trackSshConnect('ssh-a', new Promise(() => {}))
+ const uiWait = withUiConnectTimeout(request, SSH_RECONNECT_UI_TIMEOUT_MS)
+ const settled = uiWait.catch((error: Error) => error.message)
+
+ await vi.advanceTimersByTimeAsync(SSH_RECONNECT_UI_TIMEOUT_MS)
+
+ expect(await settled).toContain('timed out')
+ expect(isSshConnectInFlight('ssh-a')).toBe(true)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ // A tracked request outlives whatever dropped its lock — a reset between specs, or an
+ // explicit end. Its release must not unlock a later connect on the same target.
+ it('ignores a settle whose lock was already cleared, so a later connect keeps its lock', async () => {
+ let settleAbandoned: (value: string) => void = () => {}
+ const abandoned = trackSshConnect(
+ 'ssh-a',
+ new Promise((resolve) => {
+ settleAbandoned = resolve
+ })
+ )
+ resetSshConnectInFlightForTests()
+
+ const listener = vi.fn()
+ subscribeSshConnectInFlight(listener)
+ beginSshConnect('ssh-a')
+
+ settleAbandoned('connected')
+ await abandoned
+ await Promise.resolve()
+
+ expect(isSshConnectInFlight('ssh-a')).toBe(true)
+ expect(listener).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('stops notifying after unsubscribe, so unmounted sidebar rows do not leak', () => {
+ const listener = vi.fn()
+ const unsubscribe = subscribeSshConnectInFlight(listener)
+
+ unsubscribe()
+ beginSshConnect('ssh-a')
+
+ expect(listener).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/ssh/ssh-connect-in-flight.ts b/src/renderer/src/ssh/ssh-connect-in-flight.ts
new file mode 100644
index 00000000000..4a06f078c1f
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connect-in-flight.ts
@@ -0,0 +1,90 @@
+import { useCallback, useSyncExternalStore } from 'react'
+
+// Why: the store status lags a user click by one IPC hop (main broadcasts 'connecting'
+// after ssh.connect starts), and every workspace card on a host shares one connection.
+// Component-local state would let two surfaces — or N cards on the same host — each fire
+// a connect, which on a passphrase-gated target means N credential prompts.
+// Keyed by target, valued by the id of the acquisition holding the lock: a release can then
+// clear only its own acquisition, so a settle that lands after the lock was dropped (a test
+// reset, an explicit end) cannot take down a newer connect's lock.
+const inFlightLockIds = new Map()
+let lastLockId = 0
+const listeners = new Set<() => void>()
+
+function emit(): void {
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+export function subscribeSshConnectInFlight(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+function acquire(targetId: string): number {
+ const held = inFlightLockIds.get(targetId)
+ if (held !== undefined) {
+ return held
+ }
+ lastLockId += 1
+ inFlightLockIds.set(targetId, lastLockId)
+ emit()
+ return lastLockId
+}
+
+function releaseOwned(targetId: string, lockId: number): void {
+ if (inFlightLockIds.get(targetId) !== lockId) {
+ return
+ }
+ inFlightLockIds.delete(targetId)
+ emit()
+}
+
+export function beginSshConnect(targetId: string): void {
+ acquire(targetId)
+}
+
+export function endSshConnect(targetId: string): void {
+ if (!inFlightLockIds.delete(targetId)) {
+ return
+ }
+ emit()
+}
+
+export function isSshConnectInFlight(targetId: string): boolean {
+ return inFlightLockIds.has(targetId)
+}
+
+/**
+ * Holds the lock for `request`'s whole life and returns it unchanged.
+ * Why: UI callers race the request against a display timeout, but the backend keeps dialing
+ * past it — releasing when the caller stops waiting would let the next click raise a second
+ * credential prompt. Also survives unmount, unlike a `finally` in a component handler.
+ */
+export function trackSshConnect(targetId: string, request: Promise): Promise {
+ const lockId = acquire(targetId)
+ const release = (): void => {
+ releaseOwned(targetId, lockId)
+ }
+ // Two-arg then, not finally: a derived rejected promise here would go unhandled.
+ void request.then(release, release)
+ return request
+}
+
+export function useSshConnectInFlight(targetId: string): boolean {
+ const getSnapshot = useCallback(() => inFlightLockIds.has(targetId), [targetId])
+ return useSyncExternalStore(subscribeSshConnectInFlight, getSnapshot, getSnapshot)
+}
+
+/**
+ * Test-only: the registry is module state, so specs must reset it between cases.
+ * A request tracked before the reset stays pending; its release is scoped to the cleared
+ * lock id, so it settles into a no-op rather than unlocking the next case's target.
+ */
+export function resetSshConnectInFlightForTests(): void {
+ inFlightLockIds.clear()
+ emit()
+}
diff --git a/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts b/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts
new file mode 100644
index 00000000000..0d984e94d6c
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ SSH_CONNECT_UI_TIMEOUT_MS,
+ SSH_RECONNECT_UI_TIMEOUT_MS,
+ withUiConnectTimeout
+} from './ssh-connect-ui-timeout'
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+describe('withUiConnectTimeout', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('passes a resolved connect state straight through', async () => {
+ await expect(withUiConnectTimeout(Promise.resolve('connected'))).resolves.toBe('connected')
+ })
+
+ it('propagates the connect rejection rather than the timeout message', async () => {
+ const failing = withUiConnectTimeout(Promise.reject(new Error('Passphrase rejected')))
+
+ await expect(failing).rejects.toThrow('Passphrase rejected')
+ })
+
+ // Why: ssh.connect has no timeout of its own, so a stalled backend would otherwise leave
+ // the control disabled with a spinner forever.
+ it('rejects a stalled connect once the UI budget elapses', async () => {
+ const stalled = withUiConnectTimeout(new Promise(() => {}))
+ const assertion = expect(stalled).rejects.toThrow(/Connection timed out/)
+
+ await vi.advanceTimersByTimeAsync(SSH_CONNECT_UI_TIMEOUT_MS)
+
+ await assertion
+ })
+
+ // Why: an interactive passphrase alone allows 120s in main, so the reconnect surfaces pass
+ // a longer budget; the default composer budget must not fence them.
+ it('honours a caller-supplied budget instead of the composer default', async () => {
+ const stalled = withUiConnectTimeout(new Promise(() => {}), SSH_RECONNECT_UI_TIMEOUT_MS)
+ const assertion = expect(stalled).rejects.toThrow(/Connection timed out/)
+
+ await vi.advanceTimersByTimeAsync(SSH_CONNECT_UI_TIMEOUT_MS)
+ expect(vi.getTimerCount()).toBe(1)
+
+ await vi.advanceTimersByTimeAsync(SSH_RECONNECT_UI_TIMEOUT_MS - SSH_CONNECT_UI_TIMEOUT_MS)
+ await assertion
+ })
+
+ it('gives a reconnect more time than main allows for an interactive passphrase', () => {
+ expect(SSH_RECONNECT_UI_TIMEOUT_MS).toBeGreaterThan(120_000)
+ })
+
+ it('clears the timer when the connect settles first, leaving no pending work', async () => {
+ await withUiConnectTimeout(Promise.resolve('connected'))
+
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/src/renderer/src/ssh/ssh-connect-ui-timeout.ts b/src/renderer/src/ssh/ssh-connect-ui-timeout.ts
new file mode 100644
index 00000000000..ab917ff35aa
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connect-ui-timeout.ts
@@ -0,0 +1,40 @@
+import { translate } from '@/i18n/i18n'
+
+// Why: ssh.connect has no built-in timeout, so bound how long a UI control waits on it —
+// a stalled backend connect must not leave a disabled spinner stuck forever. The backend
+// keeps going regardless.
+export const SSH_CONNECT_UI_TIMEOUT_MS = 20_000
+
+// Why: a reconnect can legitimately outlast the composer budget — an interactive passphrase
+// prompt alone allows 120s (main's CREDENTIAL_TIMEOUT_MS) before the 30s connect even starts,
+// and a first relay deploy uploads a binary. A shorter fence would toast "timed out" and run
+// the stale-metadata resync against a host that is about to connect fine.
+export const SSH_RECONNECT_UI_TIMEOUT_MS = 180_000
+
+export async function withUiConnectTimeout(
+ promise: Promise,
+ timeoutMs: number = SSH_CONNECT_UI_TIMEOUT_MS
+): Promise {
+ let timer: ReturnType | undefined
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => {
+ reject(
+ new Error(
+ // Key kept from the original NewWorkspaceComposerCard home so existing
+ // translations survive the move.
+ translate(
+ 'auto.components.NewWorkspaceComposerCard.connectTimedOut',
+ 'Connection timed out. It may still be connecting in the background.'
+ )
+ )
+ )
+ }, timeoutMs)
+ })
+ try {
+ return await Promise.race([promise, timeout])
+ } finally {
+ if (timer) {
+ clearTimeout(timer)
+ }
+ }
+}
diff --git a/src/renderer/src/ssh/ssh-connect-verb.test.ts b/src/renderer/src/ssh/ssh-connect-verb.test.ts
new file mode 100644
index 00000000000..b3fcdcdcdc3
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connect-verb.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from 'vitest'
+import { sshConnectingLabel, sshConnectVerb } from './ssh-connect-verb'
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+// Why: the sidebar card control, terminal overlay, host-header menu, and status-bar row all
+// read this table. It is the only thing keeping them from naming one click three ways.
+describe('sshConnectVerb', () => {
+ it('names an authentication failure a reconnect', () => {
+ expect(sshConnectVerb('auth-failed')).toBe('Reconnect')
+ })
+
+ it.each(['error', 'reconnection-failed'] as const)('names %s a retry', (status) => {
+ expect(sshConnectVerb(status)).toBe('Retry')
+ })
+
+ it.each(['disconnected', 'connecting', 'deploying-relay', 'reconnecting', 'connected'] as const)(
+ 'falls back to Connect for %s',
+ (status) => {
+ expect(sshConnectVerb(status)).toBe('Connect')
+ }
+ )
+
+ it.each([null, undefined])('falls back to Connect for %s', (status) => {
+ expect(sshConnectVerb(status)).toBe('Connect')
+ })
+})
+
+describe('sshConnectingLabel', () => {
+ it('uses the single-character ellipsis, matching the rest of the catalog', () => {
+ expect(sshConnectingLabel()).toBe('Connecting…')
+ })
+})
diff --git a/src/renderer/src/ssh/ssh-connect-verb.ts b/src/renderer/src/ssh/ssh-connect-verb.ts
new file mode 100644
index 00000000000..d5dd98a06e8
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connect-verb.ts
@@ -0,0 +1,27 @@
+import { translate } from '@/i18n/i18n'
+import type { SshConnectionStatus } from '../../../shared/ssh-types'
+
+// Why: the sidebar card, the terminal overlay, the host-header menu, and the status-bar row
+// can all be on screen at once. One vocabulary here stops them describing the same click
+// three different ways.
+export function sshConnectVerb(status: SshConnectionStatus | null | undefined): string {
+ switch (status) {
+ case 'auth-failed':
+ return translate('auto.ssh.sshConnectVerb.reconnect', 'Reconnect')
+ case 'error':
+ case 'reconnection-failed':
+ return translate('auto.ssh.sshConnectVerb.retry', 'Retry')
+ case null:
+ case undefined:
+ case 'connected':
+ case 'connecting':
+ case 'deploying-relay':
+ case 'disconnected':
+ case 'reconnecting':
+ return translate('auto.ssh.sshConnectVerb.connect', 'Connect')
+ }
+}
+
+export function sshConnectingLabel(): string {
+ return translate('auto.ssh.sshConnectVerb.connecting', 'Connecting…')
+}
diff --git a/src/renderer/src/ssh/ssh-connection-recoverability.test.ts b/src/renderer/src/ssh/ssh-connection-recoverability.test.ts
new file mode 100644
index 00000000000..05aa06eb26d
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connection-recoverability.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import type { SshConnectionStatus } from '../../../shared/ssh-types'
+import { canConnectSshStatus, isConnectingSshStatus } from './ssh-connection-recoverability'
+
+// Union growth is caught by the typechecker (the modules use total Records), not here.
+// These cases pin the classification itself, which four call sites now depend on.
+const ALL_STATUSES: SshConnectionStatus[] = [
+ 'disconnected',
+ 'connecting',
+ 'auth-failed',
+ 'deploying-relay',
+ 'connected',
+ 'reconnecting',
+ 'reconnection-failed',
+ 'error'
+]
+
+describe('isConnectingSshStatus', () => {
+ it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)(
+ 'treats %s as an attempt already under way',
+ (status) => {
+ expect(isConnectingSshStatus(status)).toBe(true)
+ }
+ )
+
+ it.each(['disconnected', 'auth-failed', 'connected', 'reconnection-failed', 'error'] as const)(
+ 'does not treat %s as connecting',
+ (status) => {
+ expect(isConnectingSshStatus(status)).toBe(false)
+ }
+ )
+})
+
+describe('canConnectSshStatus', () => {
+ it.each(['disconnected', 'auth-failed', 'reconnection-failed', 'error'] as const)(
+ 'offers a user-driven connect for %s',
+ (status) => {
+ expect(canConnectSshStatus(status)).toBe(true)
+ }
+ )
+
+ it.each(['connecting', 'deploying-relay', 'reconnecting', 'connected'] as const)(
+ 'withholds connect for %s',
+ (status) => {
+ expect(canConnectSshStatus(status)).toBe(false)
+ }
+ )
+})
+
+// Why: runtime-owned targets deliberately yield a null status. Both predicates must read
+// that as "nothing to offer" so the card falls through to the passive host glyph.
+describe('absent status', () => {
+ it.each([null, undefined])('classifies %s as neither connecting nor connectable', (status) => {
+ expect(isConnectingSshStatus(status)).toBe(false)
+ expect(canConnectSshStatus(status)).toBe(false)
+ })
+})
+
+describe('the two predicates together', () => {
+ it('never claims a status is both connecting and connectable', () => {
+ for (const status of ALL_STATUSES) {
+ expect(isConnectingSshStatus(status) && canConnectSshStatus(status)).toBe(false)
+ }
+ })
+
+ it('leaves only connected outside both sets, so no state renders a dead control', () => {
+ const unclassified = ALL_STATUSES.filter(
+ (status) => !isConnectingSshStatus(status) && !canConnectSshStatus(status)
+ )
+ expect(unclassified).toEqual(['connected'])
+ })
+})
diff --git a/src/renderer/src/ssh/ssh-connection-recoverability.ts b/src/renderer/src/ssh/ssh-connection-recoverability.ts
new file mode 100644
index 00000000000..83e87b75216
--- /dev/null
+++ b/src/renderer/src/ssh/ssh-connection-recoverability.ts
@@ -0,0 +1,36 @@
+import type { SshConnectionStatus } from '../../../shared/ssh-types'
+
+// Why: a total Record makes a new SshConnectionStatus member a typecheck failure. An
+// array + .includes() would silently classify it as "not recoverable", leaving its cards
+// with a dead glyph and no way to reconnect.
+const CONNECTING_BY_STATUS: Record = {
+ disconnected: false,
+ connecting: true,
+ 'auth-failed': false,
+ 'deploying-relay': true,
+ connected: false,
+ reconnecting: true,
+ 'reconnection-failed': false,
+ error: false
+}
+
+const CAN_CONNECT_BY_STATUS: Record = {
+ disconnected: true,
+ connecting: false,
+ 'auth-failed': true,
+ 'deploying-relay': false,
+ connected: false,
+ reconnecting: false,
+ 'reconnection-failed': true,
+ error: true
+}
+
+/** Relay deployment and reconnect are host-driven transients: no user action helps yet. */
+export function isConnectingSshStatus(status: SshConnectionStatus | null | undefined): boolean {
+ return status ? CONNECTING_BY_STATUS[status] : false
+}
+
+/** Failure states a user-initiated connect can recover from. */
+export function canConnectSshStatus(status: SshConnectionStatus | null | undefined): boolean {
+ return status ? CAN_CONNECT_BY_STATUS[status] : false
+}
From 999e3a3a6d069e6179b762b68d153f07121d5601 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:42:25 -0700
Subject: [PATCH 08/42] feat(sidebar): link Linear issues from Edit Worktree
Details (#12380)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(sidebar): link Linear issues from Edit Worktree Details
The Issue field only accepted GitHub numbers, so a workspace tracking a
Linear issue had no way to say so from the dialog — the link could only be
set at creation time or through `orca worktree set --linear-issue`.
Replaces the field with one provider-aware row: a chip suffix inside the
input selects GitHub or Linear, and pasting a URL flips the chip to match.
A bare key never steers the provider — Linear and Jira issue keys are
byte-identical in shape, so shape alone cannot decide one.
One issue per workspace. A changed field displaces the other provider's
slot and the row names what Save is about to unlink. GitLab and Jira links
are left alone: the row cannot display them, and nothing else in the UI
could restore one it dropped.
- Folder workspaces read-only (their link is creation-time only)
- Remote runtimes assert the capability before writing or clearing, since
`worktree.set` parses in strip mode and would silently drop the keys
- `updateWorktreeMeta` now reports failure so the dialog can stay open
instead of closing over a save that refetch reverted
- Parses are length-bounded — `matchGitHubItemPath` strips trailing
slashes with an unanchored regex that is quadratic on a large paste
* fix(sidebar): respect one-issue-per-workspace rule conditionally
Only clear displaced issue links when they actually existed, preventing
unnecessary Linear keys in GitHub-only workspaces. Skip comment updates
when unchanged to avoid workspace reordering. Add accessibility to
displacement messages and improve folder workspace error handling.
* fix(sidebar): resolve workspace ambiguity and improve Linear issue linki
The same workspace ID can exist under multiple hosts — the owner index reports
this as ambiguous rather than guessing. Dialog callers now pass their repoId so
lookups are unambiguous. Linear identifiers without an org key are resolved
across all workspaces (not just the active organization). Added race-condition
protection for async issue lookups and better change detection to avoid clearing
work-item titles when re-saving an identifier in different spelling.
---
.../handlers/worktree-linear-issue-link.ts | 36 +-
src/cli/index.test.ts | 3 +
.../components/right-sidebar/ChecksPanel.tsx | 3 +
.../src/components/sidebar/WorktreeCard.tsx | 14 +-
.../sidebar/WorktreeContextMenu.tsx | 4 +
.../sidebar/WorktreeIssueLinkField.tsx | 260 ++++++++
.../sidebar/WorktreeMetaDialog.test.tsx | 574 ++++++++++++++++++
.../components/sidebar/WorktreeMetaDialog.tsx | 236 ++++---
.../sidebar/use-worktree-issue-link.ts | 223 ++++++-
.../sidebar/use-worktree-meta-workspace.ts | 102 ++++
.../sidebar/worktree-issue-displacement.ts | 48 ++
.../sidebar/worktree-meta-updates.test.ts | 391 ++++++++++--
.../sidebar/worktree-meta-updates.ts | 265 ++++++--
src/renderer/src/i18n/locales/en.json | 27 +-
...wake-sleeping-agents-in-background.test.ts | 4 +-
.../src/store/slices/worktree-helpers.ts | 4 +-
.../src/store/slices/worktrees.test.ts | 31 +
src/renderer/src/store/slices/worktrees.ts | 66 +-
src/shared/issue-link-input.test.ts | 118 ++++
src/shared/issue-link-input.ts | 75 +++
src/shared/linear-links.test.ts | 81 +++
src/shared/linear-links.ts | 52 ++
22 files changed, 2381 insertions(+), 236 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx
create mode 100644 src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx
create mode 100644 src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts
create mode 100644 src/renderer/src/components/sidebar/worktree-issue-displacement.ts
create mode 100644 src/shared/issue-link-input.test.ts
create mode 100644 src/shared/issue-link-input.ts
diff --git a/src/cli/handlers/worktree-linear-issue-link.ts b/src/cli/handlers/worktree-linear-issue-link.ts
index 2285da4f01a..c8e1020d847 100644
--- a/src/cli/handlers/worktree-linear-issue-link.ts
+++ b/src/cli/handlers/worktree-linear-issue-link.ts
@@ -1,17 +1,15 @@
-import { parseLinearIssueInput } from '../../shared/linear-links'
+import {
+ buildLinearIssueLinkUpdates,
+ LINEAR_ISSUE_LINK_CLEARED,
+ type LinearIssueLinkUpdates
+} from '../../shared/linear-links'
import { RuntimeClientError } from '../runtime-client'
-type LinearIssueLinkParams = {
- linkedLinearIssue: string | null
- linkedLinearIssueWorkspaceId: string | null
- linkedLinearIssueOrganizationUrlKey: string | null
-}
-
export function getOptionalLinearIssueLinkFlag(
flags: Map,
name: string,
options: { allowNull?: boolean } = {}
-): LinearIssueLinkParams | undefined {
+): LinearIssueLinkUpdates | undefined {
const value = getPresentStringFlag(flags, name)
if (value === undefined) {
return undefined
@@ -24,28 +22,22 @@ export function getOptionalLinearIssueLinkFlag(
'Omit --linear-issue on create, or pass a Linear issue identifier or URL.'
)
}
- return {
- linkedLinearIssue: null,
- linkedLinearIssueWorkspaceId: null,
- linkedLinearIssueOrganizationUrlKey: null
- }
+ return { ...LINEAR_ISSUE_LINK_CLEARED }
}
- const parsed = parseLinearIssueInput(value)
- if (!parsed) {
+ // Why: the shared builder treats empty input as "clear", which is right for a
+ // text field the user can blank but wrong for a flag — `--linear-issue " "`
+ // is a mistyped argument, not a request to unlink. Only the literal `null`
+ // clears, and that is handled above.
+ const updates = value.trim() === '' ? null : buildLinearIssueLinkUpdates(value)
+ if (!updates) {
throw new RuntimeClientError(
'invalid_argument',
'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.'
)
}
- return {
- linkedLinearIssue: parsed.identifier,
- // Why: changing a link must not keep a workspace id from a previous issue.
- // The org key from URLs is enough for current-resolution to safely rehydrate it.
- linkedLinearIssueWorkspaceId: null,
- linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
- }
+ return updates
}
function getPresentStringFlag(
diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts
index bca1910cb10..2dad82ffdd1 100644
--- a/src/cli/index.test.ts
+++ b/src/cli/index.test.ts
@@ -1245,6 +1245,9 @@ describe('orca cli worktree awareness', () => {
linkedIssue: undefined,
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
+ // Why: a bare identifier carries no org, and the two scoping fields
+ // describe the issue being replaced. Inheriting a stale org key would
+ // pin the lookup to a workspace this issue does not live in.
linkedLinearIssueOrganizationUrlKey: null,
comment: undefined,
runHooks: false,
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
index 2a1412cde94..028abac821c 100644
--- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
@@ -3700,6 +3700,9 @@ export default function ChecksPanel(): React.JSX.Element {
}
openModal('edit-meta', {
worktreeId: activeWorktreeId,
+ // Why: the same workspace ID can exist under two hosts. Naming the owner
+ // keeps the dialog on this workspace instead of the ambiguous lookup.
+ repoId: activeWorktree.repoId,
currentDisplayName: activeWorktree.displayName,
currentIssue: activeWorktree.linkedIssue,
currentPR: activeWorktree.linkedPR ?? activeReview.number,
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 2d3e2852cea..b53a03ec833 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -265,6 +265,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
e.stopPropagation()
openModal('edit-meta', {
worktreeId: worktree.id,
+ // Why: the same workspace ID can exist under two hosts. Naming the owner
+ // keeps the dialog on the clicked row instead of the ambiguous lookup.
+ repoId: worktree.repoId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -280,6 +283,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
e.stopPropagation()
openModal('edit-meta', {
worktreeId: worktree.id,
+ repoId: worktree.repoId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -899,7 +903,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
)
const handleRenameTitle = useCallback(
- (displayName: string) => updateWorktreeMeta(worktree.id, { displayName }),
+ // Inline rename has no surface for the failure; the store already logs and
+ // refetches, which reverts the optimistic title in place.
+ async (displayName: string): Promise => {
+ await updateWorktreeMeta(worktree.id, { displayName })
+ },
[updateWorktreeMeta, worktree.id]
)
@@ -913,6 +921,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
openModal('edit-meta', {
worktreeId: worktree.id,
+ repoId: worktree.repoId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -926,7 +935,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree.displayName,
worktree.id,
worktree.linkedIssue,
- worktree.linkedPR
+ worktree.linkedPR,
+ worktree.repoId
]
)
diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
index ea77889e6c7..ac01350de30 100644
--- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
@@ -598,6 +598,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const handleRename = useCallback(() => {
openModal('edit-meta', {
worktreeId: worktree.id,
+ // Why: the same workspace ID can exist under two hosts. Naming the owner
+ // keeps the dialog on this row instead of the ambiguous lookup.
+ repoId: worktree.repoId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -606,6 +609,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
})
}, [
worktree.id,
+ worktree.repoId,
worktree.displayName,
worktree.linkedIssue,
worktree.linkedPR,
diff --git a/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx
new file mode 100644
index 00000000000..6a7306d0100
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx
@@ -0,0 +1,260 @@
+import React, { useCallback, useId, useMemo } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { LinearIcon } from '@/components/icons/LinearIcon'
+import { ChevronDown, ExternalLink, Github, LoaderCircle } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import {
+ ISSUE_LINK_PROVIDERS,
+ isIssueLinkProvider,
+ type IssueLinkProvider
+} from '../../../../shared/issue-link-input'
+
+// Why: the value must clear the chip and the open-issue button that float over
+// the input's right edge. Provider labels are localized, so the text share is
+// measured in `ch` rather than baked into a Tailwind `pr-*` step that a longer
+// translation would overlap.
+const ADORNMENT_RESERVE_PX = 70
+const MAX_RESERVE = '55%'
+
+/** Exported for test: happy-dom drops `min()`, so this cannot be read back off a style. */
+export function issueAdornmentReserve(providerLabel: string): string {
+ return `min(calc(${ADORNMENT_RESERVE_PX}px + ${providerLabel.length}ch), ${MAX_RESERVE})`
+}
+
+function providerLabel(provider: IssueLinkProvider): string {
+ return provider === 'linear'
+ ? translate('auto.components.sidebar.WorktreeIssueLinkField.25852bfc59', 'Linear')
+ : translate('auto.components.sidebar.WorktreeIssueLinkField.5b440069e6', 'GitHub')
+}
+
+function ProviderIcon({
+ provider,
+ className
+}: {
+ provider: IssueLinkProvider
+ className?: string
+}): React.JSX.Element {
+ return provider === 'linear' ? (
+
+ ) : (
+
+ )
+}
+
+export type WorktreeIssueLinkFieldProps = {
+ inputRef: React.RefObject
+ value: string
+ provider: IssueLinkProvider
+ /** Set when the current value cannot be saved, so the row can explain why. */
+ isInvalid: boolean
+ /** Names the links this save would drop; both slots can be filled at once. */
+ displacedLinkLabels: string[] | null
+ /** Folder workspaces have no persisted link slots — see the design doc. */
+ isReadOnly: boolean
+ canOpenIssue: boolean
+ openingIssue: boolean
+ /** The last open attempt resolved to nothing — see useWorktreeIssueLink. */
+ openIssueFailed: boolean
+ onValueChange: (value: string) => void
+ onProviderChange: (provider: IssueLinkProvider) => void
+ onOpenIssue: () => void
+ onKeyDown: (event: React.KeyboardEvent) => void
+}
+
+export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): React.JSX.Element {
+ const {
+ inputRef,
+ value,
+ provider,
+ isInvalid,
+ displacedLinkLabels,
+ isReadOnly,
+ canOpenIssue,
+ openingIssue,
+ openIssueFailed,
+ onValueChange,
+ onProviderChange,
+ onOpenIssue,
+ onKeyDown
+ } = props
+
+ // Why: the helper `` is the only surface for the invalid and displacement
+ // states, so it has to be announced with the field rather than just seen.
+ const helperId = useId()
+ const inputId = useId()
+ const label = providerLabel(provider)
+ const openIssueLabel = translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.161b2d053a',
+ 'Open linked issue'
+ )
+
+ const handleProviderChange = useCallback(
+ (next: string) => {
+ if (isIssueLinkProvider(next)) {
+ onProviderChange(next)
+ }
+ },
+ [onProviderChange]
+ )
+
+ const helperText = useMemo(() => {
+ if (isReadOnly) {
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.d4785f9954',
+ "Issue links are set when a folder workspace is created and can't be changed here yet."
+ )
+ }
+ if (isInvalid) {
+ return provider === 'linear'
+ ? translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.964d9bc00a',
+ 'Not a Linear issue key or linear.app issue URL.'
+ )
+ : translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.0a7a2c6efd',
+ 'Not a GitHub issue number or issue URL.'
+ )
+ }
+ // Why: ranked above displacement because it answers the click the user just
+ // made, and it clears as soon as they edit the value that caused it.
+ if (openIssueFailed) {
+ return provider === 'linear'
+ ? translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.d8c8a30d1f',
+ "Couldn't open that issue. Check the identifier and your Linear connection."
+ )
+ : translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.269198eeda',
+ "Couldn't open that issue. Check the number and your GitHub connection."
+ )
+ }
+ // Whole sentences per arity rather than a joined list: a translated " and "
+ // fragment would not survive languages that order or punctuate lists differently.
+ if (displacedLinkLabels && displacedLinkLabels.length > 1) {
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.72486800ff',
+ 'Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.',
+ { first: displacedLinkLabels[0], second: displacedLinkLabels[1] }
+ )
+ }
+ if (displacedLinkLabels?.length) {
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.2c245ac134',
+ 'Saving unlinks {{link}} — a workspace tracks one issue.',
+ { link: displacedLinkLabels[0] }
+ )
+ }
+ return translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.f047887705',
+ 'Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.'
+ )
+ }, [displacedLinkLabels, isInvalid, isReadOnly, openIssueFailed, provider])
+
+ return (
+
+
+ {translate('auto.components.sidebar.WorktreeIssueLinkField.ad78f9bee2', 'Issue')}
+
+
+
onValueChange(e.target.value)}
+ onKeyDown={onKeyDown}
+ disabled={isReadOnly}
+ aria-invalid={isInvalid || undefined}
+ placeholder={translate(
+ 'auto.components.sidebar.WorktreeIssueLinkField.662ae142f8',
+ 'Issue #, or a GitHub or Linear URL'
+ )}
+ className="h-8 text-xs"
+ style={{ paddingRight: issueAdornmentReserve(label) }}
+ />
+
+ {/* Why: `modal` would stack a second scroll-lock layer over the
+ Dialog's, whose nested teardown strands pointer-events on body. */}
+
+
+
+
+ {label}
+
+
+
+
+
+ {ISSUE_LINK_PROVIDERS.map((item) => (
+
+
+ {providerLabel(item)}
+
+ ))}
+
+
+
+
+
+
+ {openingIssue ? (
+
+ ) : (
+
+ )}
+
+
+
+ {openIssueLabel}
+
+
+
+
+ {/* Why: two lines are reserved up front. The dialog is centered by
+ transform, so a wrap-count change would shift the field being typed in. */}
+ {/* Why: the failure and displacement messages appear without focus moving
+ here, so only a live region announces them. */}
+
+ {helperText}
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx
new file mode 100644
index 00000000000..b45585a4628
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx
@@ -0,0 +1,574 @@
+// @vitest-environment happy-dom
+
+import { act, type ReactNode } from 'react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAppStore } from '@/store'
+import type {
+ FolderWorkspace,
+ LinearIssue,
+ Repo,
+ Worktree,
+ WorktreeMeta
+} from '../../../../shared/types'
+import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+
+// Why: Radix tooltips need a provider the dialog does not own, and the menu's
+// portal needs real layout. Stand-ins keep these tests on provider selection.
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children?: ReactNode }) => <>{children}>,
+ TooltipContent: () => null,
+ TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}>
+}))
+
+vi.mock('@/components/ui/dropdown-menu', async () => {
+ const React = await import('react')
+ const SelectContext = React.createContext<(value: string) => void>(() => {})
+ const Passthrough = ({ children }: { children?: ReactNode }) => <>{children}>
+ return {
+ DropdownMenu: Passthrough,
+ DropdownMenuTrigger: Passthrough,
+ DropdownMenuContent: Passthrough,
+ DropdownMenuRadioGroup: ({
+ value,
+ onValueChange,
+ children
+ }: {
+ value: string
+ onValueChange: (value: string) => void
+ children?: ReactNode
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenuRadioItem: ({ value, children }: { value: string; children?: ReactNode }) => {
+ const onSelect = React.useContext(SelectContext)
+ return (
+ onSelect(value)}>
+ {children}
+
+ )
+ }
+ }
+})
+
+import WorktreeMetaDialog from './WorktreeMetaDialog'
+
+const REPO_ID = 'repo-1'
+const WORKTREE_ID = 'repo-1::/repo/worktrees/feature'
+
+const initialState = useAppStore.getInitialState()
+const updateWorktreeMeta =
+ vi.fn<
+ (
+ id: string,
+ updates: Partial
+ ) => Promise<{ ok: true } | { ok: false; error: string }>
+ >()
+const fetchLinearIssue = vi.fn<(...args: never[]) => Promise>()
+const openUrl = vi.fn<(url: string) => void>()
+
+/** Only `url` is read by the open-issue path. */
+function makeLinearIssue(url: string): LinearIssue {
+ return { url } as LinearIssue
+}
+
+function makeRepo(id: string = REPO_ID, path: string = '/repo'): Repo {
+ return { id, path, displayName: 'orca', badgeColor: '#999999', addedAt: 1 }
+}
+
+function makeWorktree(overrides: Partial = {}): Worktree {
+ return {
+ id: WORKTREE_ID,
+ repoId: REPO_ID,
+ path: '/repo/worktrees/feature',
+ displayName: 'Feature work',
+ branch: 'feature',
+ head: 'abc123',
+ isBare: false,
+ isMainWorktree: false,
+ comment: 'existing note',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ ...overrides
+ }
+}
+
+function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace {
+ return {
+ id: 'fw-1',
+ projectGroupId: 'pg-1',
+ name: 'Docs folder',
+ folderPath: '/repo/docs',
+ linkedTask: {
+ provider: 'linear',
+ type: 'issue',
+ number: 901,
+ title: 'Fix auth',
+ url: 'https://linear.app/acme/issue/STA-901',
+ linearIdentifier: 'STA-901'
+ },
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides
+ }
+}
+
+function openDialog(
+ options: {
+ worktree?: Partial
+ worktreeId?: string
+ folderWorkspace?: Partial
+ /** Extra owners of the same workspace ID, which the index reads as ambiguous. */
+ otherRepos?: { repoId: string; worktree?: Partial }[]
+ modalRepoId?: string
+ linearViewerOrganizationUrlKey?: string
+ } = {}
+): void {
+ const worktree = makeWorktree(options.worktree)
+ const otherRepos = options.otherRepos ?? []
+ useAppStore.setState({
+ repos: [makeRepo(), ...otherRepos.map((other) => makeRepo(other.repoId, `/${other.repoId}`))],
+ worktreesByRepo: {
+ [REPO_ID]: [worktree],
+ ...Object.fromEntries(
+ otherRepos.map((other) => [
+ other.repoId,
+ [makeWorktree({ repoId: other.repoId, ...other.worktree })]
+ ])
+ )
+ },
+ ...(options.folderWorkspace
+ ? { folderWorkspaces: [makeFolderWorkspace(options.folderWorkspace)] }
+ : {}),
+ ...(options.linearViewerOrganizationUrlKey
+ ? {
+ linearStatus: {
+ connected: true,
+ viewer: {
+ displayName: 'Viewer',
+ email: null,
+ organizationName: 'Active',
+ organizationUrlKey: options.linearViewerOrganizationUrlKey
+ }
+ }
+ }
+ : {}),
+ activeModal: 'edit-meta',
+ modalData: {
+ worktreeId: options.worktreeId ?? worktree.id,
+ ...(options.modalRepoId ? { repoId: options.modalRepoId } : {}),
+ currentDisplayName: worktree.displayName,
+ currentComment: worktree.comment,
+ focus: 'comment'
+ },
+ updateWorktreeMeta,
+ fetchLinearIssue: fetchLinearIssue as unknown as ReturnType<
+ typeof useAppStore.getState
+ >['fetchLinearIssue']
+ })
+ render( )
+}
+
+function issueInput(): HTMLInputElement {
+ return screen.getByPlaceholderText('Issue #, or a GitHub or Linear URL')
+}
+
+function providerChip(): HTMLButtonElement {
+ return screen.getByRole('button', { name: 'Issue provider' })
+}
+
+function saveButton(): HTMLButtonElement {
+ return screen.getByRole('button', { name: 'Save' })
+}
+
+function openIssueButton(): HTMLButtonElement {
+ return screen.getByRole('button', { name: 'Open linked issue' })
+}
+
+describe('WorktreeMetaDialog issue link row', () => {
+ beforeEach(() => {
+ useAppStore.setState(initialState, true)
+ updateWorktreeMeta.mockReset()
+ updateWorktreeMeta.mockResolvedValue({ ok: true })
+ fetchLinearIssue.mockReset()
+ fetchLinearIssue.mockResolvedValue(null)
+ openUrl.mockReset()
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { shell: { openUrl } }
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ useAppStore.setState(initialState, true)
+ })
+
+ it('seeds the chip and value from a GitHub link', () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ expect(providerChip().textContent).toContain('GitHub')
+ expect(issueInput().value).toBe('42')
+ })
+
+ it('seeds the chip and value from a Linear link', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ expect(providerChip().textContent).toContain('Linear')
+ expect(issueInput().value).toBe('STA-335')
+ })
+
+ it('flips to Linear when a linear.app issue URL is pasted', () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ fireEvent.change(issueInput(), {
+ target: { value: 'https://linear.app/acme/issue/STA-335/fix-the-thing' }
+ })
+
+ expect(providerChip().textContent).toContain('Linear')
+ })
+
+ // Why: Linear and Jira issue keys are the same shape, so only a URL may steer
+ // the provider — a bare key must never override the user's explicit choice.
+ it('keeps the chip on GitHub when a bare issue key is typed', () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ fireEvent.change(issueInput(), { target: { value: 'GH-1234' } })
+
+ expect(providerChip().textContent).toContain('GitHub')
+ expect(providerChip().textContent).not.toContain('Linear')
+ })
+
+ it('flips to GitHub when a GitHub issue URL is pasted over a Linear link', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), {
+ target: { value: 'https://github.com/acme/orca/issues/77' }
+ })
+
+ expect(providerChip().textContent).toContain('GitHub')
+ })
+
+ it('names the Linear issue that switching to GitHub would unlink', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.click(screen.getByRole('menuitemradio', { name: 'GitHub' }))
+ fireEvent.change(issueInput(), { target: { value: '99' } })
+
+ expect(
+ screen.getByText('Saving unlinks Linear STA-335 — a workspace tracks one issue.')
+ ).toBeTruthy()
+ })
+
+ // Both slots can hold a link at once — naming only one understates the save.
+ it('names both links when clearing the field would drop both', () => {
+ openDialog({ worktree: { linkedIssue: 42, linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: '' } })
+
+ expect(
+ screen.getByText(
+ 'Saving unlinks Linear STA-335 and GitHub #42 — a workspace tracks one issue.'
+ )
+ ).toBeTruthy()
+ })
+
+ // The warning above only promises the displacement — this asserts the payload
+ // that carries it out, which is where the one-issue-per-workspace rule lives.
+ it('clears the displaced GitHub link when a Linear value is saved', async () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ fireEvent.click(screen.getByRole('menuitemradio', { name: 'Linear' }))
+ fireEvent.change(issueInput(), { target: { value: 'STA-335' } })
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(updates.linkedLinearIssue).toBe('STA-335')
+ expect(updates.linkedIssue).toBeNull()
+ })
+
+ // A GitHub-only save must carry no Linear keys: persistence gates the remote
+ // Linear capability on key presence, so a synthetic clear fails the save.
+ it('sends no Linear keys when the workspace has no Linear link', async () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ fireEvent.change(issueInput(), { target: { value: '99' } })
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(updates.linkedIssue).toBe(99)
+ expect(updates).not.toHaveProperty('linkedLinearIssue')
+ })
+
+ // updateWorktreeMeta stamps lastActivityAt on any comment write, which would
+ // reorder the workspace under the time-decay sidebar sort.
+ it('sends no comment when only the issue link changed', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: 'STA-999' } })
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
+ expect(updateWorktreeMeta.mock.calls[0]?.[1] ?? {}).not.toHaveProperty('comment')
+ })
+
+ // A failed save refetches and reverts the optimistic write, so closing here
+ // would report success for an edit that silently undid itself.
+ it('keeps the dialog open and reports why when the save fails', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+ updateWorktreeMeta.mockResolvedValue({ ok: false, error: 'Runtime is offline' })
+
+ fireEvent.change(issueInput(), { target: { value: 'STA-999' } })
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ expect(screen.getByRole('alert').textContent).toBe('Runtime is offline')
+ expect(useAppStore.getState().activeModal).toBe('edit-meta')
+ })
+
+ it('leaves the Linear link alone when only the comment is edited', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), {
+ target: { value: 'updated note' }
+ })
+
+ expect(screen.queryByText(/Saving unlinks/)).toBeNull()
+
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(Object.keys(updates)).not.toContain('linkedLinearIssue')
+ expect(Object.keys(updates)).not.toContain('linkedIssue')
+ expect(updates.comment).toBe('updated note')
+ })
+
+ it('blocks saving an unparseable Linear value', () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: 'not an issue' } })
+
+ expect(screen.getByText('Not a Linear issue key or linear.app issue URL.')).toBeTruthy()
+ expect(saveButton().disabled).toBe(true)
+ })
+
+ it('is read-only for a folder workspace', () => {
+ openDialog({ worktreeId: folderWorkspaceKey('fw-1') })
+
+ expect(issueInput().disabled).toBe(true)
+ expect(providerChip().disabled).toBe(true)
+ expect(
+ screen.getByText(
+ "Issue links are set when a folder workspace is created and can't be changed here yet."
+ )
+ ).toBeTruthy()
+ })
+
+ // Folder workspaces live outside worktreesByRepo, so the indexed lookup alone
+ // leaves the row blank and the link it does hold looks lost.
+ it('shows a folder workspace its own linked issue', () => {
+ openDialog({ worktreeId: folderWorkspaceKey('fw-1'), folderWorkspace: {} })
+
+ expect(issueInput().value).toBe('STA-901')
+ expect(providerChip().textContent).toContain('Linear')
+ })
+
+ // A background `orca worktree set` must not move the baseline mid-edit: the
+ // field would read as dirty and a comment-only save would write the stale seed.
+ it('keeps the baseline frozen when the store changes while open', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ act(() => {
+ useAppStore.setState({
+ worktreesByRepo: {
+ [REPO_ID]: [makeWorktree({ linkedLinearIssue: 'STA-999' })]
+ }
+ })
+ })
+ fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), {
+ target: { value: 'still working' }
+ })
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(updates.comment).toBe('still working')
+ expect(updates).not.toHaveProperty('linkedLinearIssue')
+ expect(updates).not.toHaveProperty('linkedIssue')
+ })
+
+ // A bare key names no organization. Building one from the connected viewer
+ // opens a not-found page — or a colliding issue — for every other workspace
+ // the user belongs to, and skips the lookup that would have said so.
+ it('resolves a bare Linear key across workspaces rather than the active organization', async () => {
+ fetchLinearIssue.mockResolvedValue(makeLinearIssue('https://linear.app/other/issue/STA-999'))
+ openDialog({
+ worktree: { linkedLinearIssue: 'STA-335' },
+ linearViewerOrganizationUrlKey: 'active-org'
+ })
+
+ fireEvent.change(issueInput(), { target: { value: 'STA-999' } })
+ await act(async () => {
+ fireEvent.click(openIssueButton())
+ })
+
+ expect(fetchLinearIssue.mock.calls[0]?.slice(0, 2)).toEqual(['STA-999', 'all'])
+ expect(openUrl).toHaveBeenCalledWith('https://linear.app/other/issue/STA-999')
+ })
+
+ // The stored key belongs to the persisted identifier, so it stays authoritative
+ // for it — no lookup, no round trip.
+ it('opens a stored Linear link directly from its organization key', async () => {
+ openDialog({
+ worktree: {
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueOrganizationUrlKey: 'acme'
+ },
+ linearViewerOrganizationUrlKey: 'active-org'
+ })
+
+ await act(async () => {
+ fireEvent.click(openIssueButton())
+ })
+
+ expect(fetchLinearIssue).not.toHaveBeenCalled()
+ expect(openUrl).toHaveBeenCalledWith('https://linear.app/acme/issue/STA-335')
+ })
+
+ // Promise.race cannot cancel the lookup, and the field stays editable while it
+ // runs — a late result must not open the issue the user just replaced.
+ it('does not open a lookup result after the field moved on', async () => {
+ let resolveLookup: ((issue: LinearIssue | null) => void) | undefined
+ fetchLinearIssue.mockReturnValue(
+ new Promise((resolve) => {
+ resolveLookup = resolve
+ })
+ )
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: 'STA-999' } })
+ fireEvent.click(openIssueButton())
+ fireEvent.change(issueInput(), { target: { value: 'STA-777' } })
+ await act(async () => {
+ resolveLookup?.(makeLinearIssue('https://linear.app/acme/issue/STA-999'))
+ })
+
+ expect(openUrl).not.toHaveBeenCalled()
+ expect(
+ screen.queryByText(
+ "Couldn't open that issue. Check the identifier and your Linear connection."
+ )
+ ).toBeNull()
+ })
+
+ // Displacement is decided at save time, not at open: a link added by the CLI
+ // while the dialog sat open must not outlive the save that warned about it.
+ it('clears a Linear link added while the dialog was open', async () => {
+ openDialog({ worktree: { linkedIssue: 42 } })
+
+ act(() => {
+ useAppStore.setState({
+ worktreesByRepo: {
+ [REPO_ID]: [makeWorktree({ linkedIssue: 42, linkedLinearIssue: 'STA-999' })]
+ }
+ })
+ })
+ fireEvent.change(issueInput(), { target: { value: '99' } })
+
+ expect(
+ screen.getByText('Saving unlinks Linear STA-999 — a workspace tracks one issue.')
+ ).toBeTruthy()
+
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(updates.linkedIssue).toBe(99)
+ expect(updates.linkedLinearIssue).toBeNull()
+ })
+
+ // Same issue, different spelling: the link is unchanged, so its title and
+ // SSH/runtime source context must survive the save.
+ it('keeps the linked work item when the value is only respelled', async () => {
+ openDialog({
+ worktree: {
+ linkedLinearIssue: 'STA-335',
+ linkedWorkItem: {
+ provider: 'linear',
+ type: 'issue',
+ number: 335,
+ title: 'Fix auth',
+ url: 'https://linear.app/acme/issue/STA-335'
+ }
+ }
+ })
+
+ fireEvent.change(issueInput(), { target: { value: 'sta-335' } })
+
+ expect(screen.queryByText(/Saving unlinks/)).toBeNull()
+
+ await act(async () => {
+ fireEvent.click(saveButton())
+ })
+
+ await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
+ const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ expect(updates).not.toHaveProperty('linkedLinearIssue')
+ })
+
+ // The owner index reports a duplicated workspace ID as ambiguous rather than
+ // guessing, so the opening row has to name its own bucket.
+ it('shows the clicked row when the same workspace ID exists under two hosts', () => {
+ openDialog({
+ worktree: { linkedIssue: 42 },
+ otherRepos: [{ repoId: 'repo-2', worktree: { linkedIssue: 77 } }],
+ modalRepoId: 'repo-2'
+ })
+
+ expect(issueInput().value).toBe('77')
+ expect(providerChip().textContent).toContain('GitHub')
+ })
+
+ it('dispatches nothing when the dialog is cancelled', async () => {
+ openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
+
+ fireEvent.change(issueInput(), { target: { value: '99' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ })
+
+ expect(updateWorktreeMeta).not.toHaveBeenCalled()
+ expect(useAppStore.getState().activeModal).toBe('none')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
index 5163a839ebf..4445520fd72 100644
--- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
@@ -10,23 +10,40 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { getDisplacedLinkLabels } from './worktree-issue-displacement'
import {
buildWorktreeMetaUpdates,
parseGitHubWorkItemNumberForMetaField,
- type WorktreeMetaSavedPayload
+ type WorktreeMetaDraft,
+ type WorktreeMetaSavedPayload,
+ type WorktreeMetaSnapshot
} from './worktree-meta-updates'
import { useWorktreeIssueLink } from './use-worktree-issue-link'
+import { useWorktreeMetaWorkspace } from './use-worktree-meta-workspace'
+import { WorktreeIssueLinkField } from './WorktreeIssueLinkField'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
-import { ExternalLink, LoaderCircle } from 'lucide-react'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
+import { isWorkItemLinkQueryTooLarge } from '../../../../shared/new-workspace/work-item-link-query-bounds'
+import {
+ getIssueLinkProviderFromUrl,
+ parseIssueLinkInput,
+ type IssueLinkProvider
+} from '../../../../shared/issue-link-input'
function resizeCommentTextarea(textarea: HTMLTextAreaElement): void {
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}
+/** Only read before the first open, when nothing can be saved yet. */
+const EMPTY_SNAPSHOT: WorktreeMetaSnapshot = {
+ displayName: '',
+ comment: '',
+ issueInput: '',
+ issueProvider: 'github'
+}
+
const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
@@ -40,9 +57,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
const currentDisplayName =
typeof modalData.currentDisplayName === 'string' ? modalData.currentDisplayName : ''
- const currentIssue =
- typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : ''
- const currentPR = typeof modalData.currentPR === 'number' ? String(modalData.currentPR) : ''
const currentComment =
typeof modalData.currentComment === 'string' ? modalData.currentComment : ''
const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment'
@@ -51,15 +65,44 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
? (modalData.afterSave as (payload: WorktreeMetaSavedPayload) => void | Promise)
: null
+ // Why: the opening row names its repo bucket for workspace IDs the owner index
+ // reads as ambiguous across hosts.
+ const ownerRepoId = typeof modalData.repoId === 'string' ? modalData.repoId : null
+ const {
+ worktree,
+ linkedIssue,
+ linkedLinearIssue,
+ currentIssue,
+ currentProvider,
+ isFolderWorkspace,
+ liveLinks
+ } = useWorktreeMetaWorkspace({ worktreeId, ownerRepoId })
+ // Why: ChecksPanel seeds the PR it is looking at, which may not be linked yet.
+ const currentPR =
+ typeof modalData.currentPR === 'number'
+ ? String(modalData.currentPR)
+ : worktree?.linkedPR != null
+ ? String(worktree.linkedPR)
+ : ''
+
const [displayNameInput, setDisplayNameInput] = useState('')
const [issueInput, setIssueInput] = useState('')
+ const [issueProvider, setIssueProvider] = useState('github')
const [prInput, setPrInput] = useState('')
const [commentInput, setCommentInput] = useState('')
const [saving, setSaving] = useState(false)
- const { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({
- worktreeId,
- issueInput
- })
+ const [saveError, setSaveError] = useState(null)
+ const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT)
+ const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } =
+ useWorktreeIssueLink({
+ worktreeId,
+ ownerRepoId,
+ issueInput,
+ issueProvider,
+ linearOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null,
+ linkedLinearIssue: worktree?.linkedLinearIssue ?? null,
+ linearSourceContext: worktree?.linkedTaskSourceContext ?? null
+ })
const issueInputRef = useRef(null)
const prInputRef = useRef(null)
@@ -70,12 +113,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
if (isOpen && !prevIsOpenRef.current) {
setDisplayNameInput(currentDisplayName)
setIssueInput(currentIssue)
+ setIssueProvider(currentProvider)
setPrInput(currentPR)
setCommentInput(currentComment)
+ // Why: the baseline is frozen with the seed instead of tracking the store.
+ // A background `orca worktree set --linear-issue` while the dialog is open
+ // would otherwise move it, making the untouched field read as dirty — and
+ // the next comment-only save would write the stale seed back over the new link.
+ setSnapshot({
+ displayName: currentDisplayName,
+ comment: currentComment,
+ issueInput: currentIssue,
+ issueProvider: currentProvider,
+ linkedLinearIssueOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null
+ })
+ setSaveError(null)
resetOpeningIssue()
}
prevIsOpenRef.current = isOpen
+ const draft = useMemo(
+ () => ({ displayNameInput, issueInput, issueProvider, prInput, commentInput }),
+ [displayNameInput, issueInput, issueProvider, prInput, commentInput]
+ )
+
+ // Why: a URL names its provider unambiguously. A bare key does not — Linear
+ // and Jira identifiers are the same shape — so typing never steers the chip.
+ const handleIssueInputChange = useCallback((next: string) => {
+ setIssueInput(next)
+ // Why: the same bound the save gate applies. Detection parses on every
+ // keystroke, and the field accepts pasted URLs of any length.
+ const detected = isWorkItemLinkQueryTooLarge(next) ? null : getIssueLinkProviderFromUrl(next)
+ if (detected) {
+ setIssueProvider(detected)
+ }
+ }, [])
+
const setCommentTextareaRef = useCallback(
(textarea: HTMLTextAreaElement | null) => {
textareaRef.current = textarea
@@ -92,18 +165,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
resizeCommentTextarea(event.currentTarget)
}, [])
+ // Why: bound the parse before it runs on every keystroke — the field accepts
+ // pasted URLs and has no length cap of its own.
+ const issueInvalid = useMemo(() => {
+ const trimmed = issueInput.trim()
+ if (trimmed === '' || isFolderWorkspace) {
+ return false
+ }
+ return (
+ isWorkItemLinkQueryTooLarge(trimmed) || parseIssueLinkInput(trimmed, issueProvider) === null
+ )
+ }, [isFolderWorkspace, issueInput, issueProvider])
+
const canSave = useMemo(() => {
if (!worktreeId) {
return false
}
- const trimmedIssue = issueInput.trim()
const trimmedPR = prInput.trim()
- const issueValid =
- trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null
+ // Same quadratic-parse bound as the issue field — this runs on every keystroke.
const prValid =
- trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null
- return issueValid && prValid
- }, [worktreeId, issueInput, prInput])
+ trimmedPR === '' ||
+ (!isWorkItemLinkQueryTooLarge(trimmedPR) &&
+ parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null)
+ return !issueInvalid && prValid
+ }, [worktreeId, issueInvalid, prInput])
+
+ const displacedLinkLabels = useMemo(
+ () =>
+ getDisplacedLinkLabels({
+ draft,
+ snapshot,
+ isFolderWorkspace,
+ linkedIssue,
+ linkedLinearIssue
+ }),
+ [draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue]
+ )
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -119,16 +216,22 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
return
}
setSaving(true)
+ // Why: a stale failure from the previous attempt must not sit under the
+ // spinner for the whole in-flight save.
+ setSaveError(null)
try {
- const updates = buildWorktreeMetaUpdates({
- displayNameInput,
- currentDisplayName,
- issueInput,
- prInput,
- commentInput
- })
+ const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks)
- await updateWorktreeMeta(worktreeId, updates)
+ const result = await updateWorktreeMeta(worktreeId, updates)
+ // Why: a failed save refetches and reverts the optimistic write. Closing
+ // here would report success for an edit that silently undid itself, and
+ // would discard the name, comment and PR changes in the same payload.
+ if (!result.ok) {
+ if (mountedRef.current) {
+ setSaveError(result.error)
+ }
+ return
+ }
closeModal()
// Why: follow-up refreshes should not turn a successful metadata save
// into a failed dialog.
@@ -145,11 +248,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}, [
worktreeId,
canSave,
- displayNameInput,
- currentDisplayName,
- issueInput,
- prInput,
- commentInput,
+ draft,
+ snapshot,
+ liveLinks,
updateWorktreeMeta,
closeModal,
afterSave,
@@ -204,8 +305,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
{translate(
- 'auto.components.sidebar.WorktreeMetaDialog.65770ad0f0',
- 'Edit GitHub links and notes for this workspace.'
+ 'auto.components.sidebar.WorktreeMetaDialog.a0d191b7a7',
+ 'Edit issue links, pull request links, and notes for this workspace.'
)}
@@ -234,58 +335,21 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
-
-
- {translate('auto.components.sidebar.WorktreeMetaDialog.645fa4a0fd', 'GH Issue')}
-
-
- setIssueInput(e.target.value)}
- onKeyDown={handleIssueKeyDown}
- placeholder={translate(
- 'auto.components.sidebar.WorktreeMetaDialog.741279e7b7',
- 'Issue # or GitHub URL'
- )}
- className="h-8 pr-9 text-xs"
- />
-
-
-
- {openingIssue ? (
-
- ) : (
-
- )}
-
-
-
- {translate(
- 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57',
- 'Open GitHub issue'
- )}
-
-
-
-
- {translate(
- 'auto.components.sidebar.WorktreeMetaDialog.7c454be4c5',
- 'Paste an issue URL, or enter a number. Leave blank to remove the link.'
- )}
-
-
+
@@ -340,6 +404,12 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+): Promise {
+ let timer: ReturnType | undefined
+ try {
+ const issue = await Promise.race([
+ lookup,
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(null), OPEN_ISSUE_TIMEOUT_MS)
+ })
+ ])
+ return issue?.url ?? null
+ } catch {
+ // Why: the store's fetchers already log and normalize failures to null; a
+ // rejection here is a transport fault and reads the same to the user.
+ return null
+ } finally {
+ clearTimeout(timer)
+ }
+}
/** Resolves the "open linked issue" affordance for the worktree meta dialog:
- * explicit URLs open directly, numbers resolve via the issue cache or an
- * owner-routed fetch. */
-export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: string }): {
+ * explicit URLs open directly, GitHub numbers resolve via the issue cache or an
+ * owner-routed fetch, Linear identifiers via the stored org key or a lookup. */
+export function useWorktreeIssueLink(args: {
+ worktreeId: string
+ /** The repo bucket the opening row belongs to, for IDs the owner index reports
+ * as ambiguous across hosts. Falls back to the index when absent. */
+ ownerRepoId?: string | null
+ issueInput: string
+ issueProvider: IssueLinkProvider
+ linearOrganizationUrlKey?: string | null
+ /** The persisted identifier the stored org key belongs to. */
+ linkedLinearIssue?: string | null
+ linearSourceContext?: TaskSourceContext | null
+}): {
canOpenIssue: boolean
openingIssue: boolean
+ /** A lookup came back empty: wrong identifier, no access, or provider offline. */
+ openIssueFailed: boolean
handleOpenIssue: () => Promise
resetOpeningIssue: () => void
} {
- const { worktreeId, issueInput } = args
+ const {
+ worktreeId,
+ ownerRepoId,
+ issueInput,
+ issueProvider,
+ linearOrganizationUrlKey,
+ linkedLinearIssue,
+ linearSourceContext
+ } = args
+ const isLinear = issueProvider === 'linear'
const fetchIssue = useAppStore((s) => s.fetchIssue)
+ const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const [openingIssue, setOpeningIssue] = useState(false)
+ const [failedIssueInput, setFailedIssueInput] = useState(null)
const mountedRef = useMountedRef()
+ // Why: the field stays editable while a lookup is in flight, and Promise.race
+ // cannot cancel the losing promise. A result that lands after the value moved
+ // on — or after a reset — must not open the issue the user just replaced.
+ const openRequestRef = useRef(0)
+ const latestRequestKeyRef = useRef('')
+ latestRequestKeyRef.current = `${issueProvider}\u0000${issueInput}`
- const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput])
- const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput])
- const issueInputLooksLikeUrl = useMemo(
- () => /^https?:\/\//i.test(issueInput.trim()),
+ // Why: `matchGitHubItemPath` strips trailing slashes with an unanchored `/\/+$/`,
+ // which is quadratic — a 160 KB paste of slashes freezes the renderer for ~19s.
+ // These parses run on every keystroke, so they get the same bound the save gate
+ // applies. The check short-circuits on length, so it stays O(1) on a huge paste.
+ const boundedInput = useMemo(
+ () => (isWorkItemLinkQueryTooLarge(issueInput) ? '' : issueInput),
[issueInput]
)
- const issueRepo = useAppStore((s) => {
- const worktree = Object.values(s.worktreesByRepo)
- .flat()
- .find((item) => item.id === worktreeId)
- if (!worktree) {
- return undefined
+
+ const issueNumber = useMemo(
+ () => (isLinear ? null : parseGitHubIssueOrPRNumber(boundedInput)),
+ [isLinear, boundedInput]
+ )
+ const issueUrlFromInput = useMemo(
+ () => (isLinear ? null : parseExplicitGitHubIssueUrl(boundedInput)),
+ [isLinear, boundedInput]
+ )
+ const issueInputLooksLikeUrl = useMemo(
+ () => /^https?:\/\//i.test(boundedInput.trim()),
+ [boundedInput]
+ )
+ const parsedLinearIssue = useMemo(
+ () => (isLinear ? parseLinearIssueInput(boundedInput) : null),
+ [isLinear, boundedInput]
+ )
+ // Why: only an org key that is authoritative *for this identifier* may build a
+ // URL directly — the input's own, or the workspace's stored one while the typed
+ // identifier still is that issue. The connected viewer's key is not: a bare key
+ // for another of the user's Linear workspaces would open a not-found page, or a
+ // different issue on a team-prefix collision. Anything else falls through to the
+ // all-workspace lookup below, which answers with the issue's canonical URL.
+ const linearIssueUrl = useMemo(() => {
+ if (!parsedLinearIssue) {
+ return null
}
- return s.repos.find((repo) => repo.id === worktree.repoId)
+ const storedKeyApplies =
+ typeof linkedLinearIssue === 'string' &&
+ linkedLinearIssue.toUpperCase() === parsedLinearIssue.identifier.toUpperCase()
+ return buildLinearIssueUrl({
+ identifier: parsedLinearIssue.identifier,
+ organizationUrlKey:
+ parsedLinearIssue.organizationUrlKey ?? (storedKeyApplies ? linearOrganizationUrlKey : null)
+ })
+ }, [parsedLinearIssue, linkedLinearIssue, linearOrganizationUrlKey])
+
+ const issueRepo = useAppStore((s) => {
+ const repoId = ownerRepoId ?? findIndexedWorktreeOwner(s.worktreesByRepo, worktreeId)?.repoId
+ return repoId ? s.repos.find((repo) => repo.id === repoId) : undefined
})
const cachedIssueUrl = useAppStore((s) => {
if (!issueRepo || issueNumber === null) {
@@ -52,14 +149,62 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
]?.data?.url ?? null
)
})
- const canOpenIssue = issueInputLooksLikeUrl
- ? Boolean(issueUrlFromInput)
- : Boolean(cachedIssueUrl || (issueRepo && issueNumber))
+ const canOpenIssue = isLinear
+ ? Boolean(parsedLinearIssue)
+ : issueInputLooksLikeUrl
+ ? Boolean(issueUrlFromInput)
+ : Boolean(cachedIssueUrl || (issueRepo && issueNumber))
const handleOpenIssue = useCallback(async () => {
if (openingIssue) {
return
}
+ setFailedIssueInput(null)
+ const generation = ++openRequestRef.current
+ const requestKey = latestRequestKeyRef.current
+ // Why: the losing side of the timeout race keeps running, so every result
+ // has to prove it still belongs to the field the user is looking at.
+ const isCurrentRequest = (): boolean =>
+ mountedRef.current &&
+ openRequestRef.current === generation &&
+ latestRequestKeyRef.current === requestKey
+
+ if (isLinear) {
+ if (!parsedLinearIssue) {
+ return
+ }
+ if (linearIssueUrl) {
+ void window.api.shell.openUrl(linearIssueUrl)
+ return
+ }
+
+ setOpeningIssue(true)
+ try {
+ // Why: 'all' — the issue may belong to a different Linear workspace than
+ // the selected one, which is the usual case for a bare or pasted identifier.
+ const url = await resolveIssueUrlWithinTimeout(
+ fetchLinearIssue(parsedLinearIssue.identifier, 'all', {
+ sourceContext: linearSourceContext ?? null
+ })
+ )
+ if (!isCurrentRequest()) {
+ return
+ }
+ if (url) {
+ void window.api.shell.openUrl(url)
+ } else {
+ // Why: the fetchers normalize every failure — no Linear connection,
+ // unknown identifier, dead runtime — to null, so without this the
+ // button just stops spinning and the click looks ignored.
+ setFailedIssueInput(issueInput)
+ }
+ } finally {
+ if (mountedRef.current) {
+ setOpeningIssue(false)
+ }
+ }
+ return
+ }
if (issueUrlFromInput) {
void window.api.shell.openUrl(issueUrlFromInput)
@@ -81,9 +226,16 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
setOpeningIssue(true)
try {
- const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id })
- if (issue?.url) {
- void window.api.shell.openUrl(issue.url)
+ const url = await resolveIssueUrlWithinTimeout(
+ fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id })
+ )
+ if (!isCurrentRequest()) {
+ return
+ }
+ if (url) {
+ void window.api.shell.openUrl(url)
+ } else {
+ setFailedIssueInput(issueInput)
}
} finally {
if (mountedRef.current) {
@@ -93,15 +245,36 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
}, [
cachedIssueUrl,
fetchIssue,
+ fetchLinearIssue,
+ isLinear,
+ issueInput,
issueInputLooksLikeUrl,
issueNumber,
issueRepo,
issueUrlFromInput,
+ linearIssueUrl,
+ linearSourceContext,
mountedRef,
- openingIssue
+ openingIssue,
+ parsedLinearIssue
])
- const resetOpeningIssue = useCallback(() => setOpeningIssue(false), [])
+ const resetOpeningIssue = useCallback(() => {
+ // Bumping the generation retires any in-flight lookup: a reset means this is
+ // a fresh dialog session, and the old result must not open or report here.
+ openRequestRef.current += 1
+ setOpeningIssue(false)
+ setFailedIssueInput(null)
+ }, [])
- return { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue }
+ return {
+ canOpenIssue,
+ openingIssue,
+ // Why: the failure belongs to the value that produced it. Editing the field
+ // is the user's answer to it, so comparing rather than clearing on change
+ // retires the notice without an Effect that would lag a keystroke behind.
+ openIssueFailed: failedIssueInput !== null && failedIssueInput === issueInput,
+ handleOpenIssue,
+ resetOpeningIssue
+ }
}
diff --git a/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts
new file mode 100644
index 00000000000..336725efc1e
--- /dev/null
+++ b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts
@@ -0,0 +1,102 @@
+import { useMemo } from 'react'
+import { useAppStore } from '@/store'
+import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index'
+import type { Worktree } from '../../../../shared/types'
+import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
+import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
+import type { IssueLinkProvider } from '../../../../shared/issue-link-input'
+import type { WorktreeMetaLiveLinks } from './worktree-meta-updates'
+
+/** Resolves the workspace the meta dialog edits and the issue-link state it
+ * seeds from. Link state is read from the store rather than threaded through
+ * the untyped modalData bag — a call site that forgot a key would otherwise
+ * seed an empty field and silently destroy the link on save. */
+export function useWorktreeMetaWorkspace(args: {
+ worktreeId: string
+ /** The repo bucket the opening row belongs to, when it knows. */
+ ownerRepoId: string | null
+}): {
+ worktree: Worktree | undefined
+ linkedIssue: number | null
+ linkedLinearIssue: string | null
+ /** The persisted value and its provider, from one source so they cannot drift. */
+ currentIssue: string
+ currentProvider: IssueLinkProvider
+ isFolderWorkspace: boolean
+ /** Link state as it stands now, for save-time displacement decisions. */
+ liveLinks: WorktreeMetaLiveLinks
+} {
+ const { worktreeId, ownerRepoId } = args
+ const workspaceScope = useMemo(() => parseWorkspaceKey(worktreeId), [worktreeId])
+ const indexedWorktree = useAppStore((s) => {
+ // Why: the same workspace ID can exist under two hosts, which the owner index
+ // reports as ambiguous rather than guessing. The row that opened the dialog
+ // knows which one it is; the index stays the fallback for callers that cannot.
+ const scoped = ownerRepoId
+ ? s.worktreesByRepo[ownerRepoId]?.find((item) => item.id === worktreeId)
+ : undefined
+ if (scoped) {
+ return scoped
+ }
+ const owner = findIndexedWorktreeOwner(s.worktreesByRepo, worktreeId)
+ return owner
+ ? s.worktreesByRepo[owner.repoId]?.find((item) => item.id === worktreeId)
+ : undefined
+ })
+ // Why: folder workspaces are absent from worktreesByRepo, so the lookup above
+ // returns undefined and the row renders blank for them. The selector returns
+ // the stored record — a stable reference — and the projection happens outside
+ // it, because a selector that built the object would return a fresh identity
+ // on every store write and re-render the dialog continuously.
+ const folderWorkspace = useAppStore((s) =>
+ workspaceScope?.type === 'folder'
+ ? (s.folderWorkspaces.find((item) => item.id === workspaceScope.folderWorkspaceId) ?? null)
+ : null
+ )
+ const worktree = useMemo(
+ () => (folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : indexedWorktree),
+ [folderWorkspace, indexedWorktree]
+ )
+ const linkedIssue = worktree?.linkedIssue ?? null
+ const linkedLinearIssue = worktree?.linkedLinearIssue ?? null
+ // Why: `typeof` rather than a null check — an unhydrated projection can leave
+ // linkedIssue undefined, which `!== null` would read as a GitHub link.
+ const currentProvider: IssueLinkProvider =
+ typeof linkedIssue === 'number' ? 'github' : linkedLinearIssue ? 'linear' : 'github'
+ const currentIssue =
+ currentProvider === 'linear'
+ ? (linkedLinearIssue ?? '')
+ : typeof linkedIssue === 'number'
+ ? String(linkedIssue)
+ : ''
+ // Why: displacement is decided against live state, not the frozen snapshot —
+ // the dialog's warning reads the same values, so a link added by the CLI while
+ // the dialog was open cannot outlive a save that promised to displace it.
+ const liveLinks = useMemo(
+ () => ({
+ linkedIssue,
+ linkedLinearIssue,
+ linkedLinearIssueOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null,
+ linkedWorkItemProvider: worktree?.linkedWorkItem?.provider ?? null,
+ linkedWorkItemType: worktree?.linkedWorkItem?.type ?? null
+ }),
+ [
+ linkedIssue,
+ linkedLinearIssue,
+ worktree?.linkedLinearIssueOrganizationUrlKey,
+ worktree?.linkedWorkItem
+ ]
+ )
+
+ return {
+ worktree,
+ linkedIssue,
+ linkedLinearIssue,
+ currentIssue,
+ currentProvider,
+ // Why: folder workspaces persist links only through their creation-time
+ // linkedTask; updateWorktreeMeta drops link keys for them entirely.
+ isFolderWorkspace: workspaceScope?.type === 'folder',
+ liveLinks
+ }
+}
diff --git a/src/renderer/src/components/sidebar/worktree-issue-displacement.ts b/src/renderer/src/components/sidebar/worktree-issue-displacement.ts
new file mode 100644
index 00000000000..bfc74e7162e
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-issue-displacement.ts
@@ -0,0 +1,48 @@
+import { translate } from '@/i18n/i18n'
+import type { IssueLinkProvider } from '../../../../shared/issue-link-input'
+import {
+ isIssueFieldDirty,
+ type WorktreeMetaDraft,
+ type WorktreeMetaSnapshot
+} from './worktree-meta-updates'
+
+function formatLinkLabel(provider: IssueLinkProvider, value: string): string {
+ return provider === 'linear'
+ ? translate(
+ 'auto.components.sidebar.worktreeIssueDisplacement.3f61c0a8d2',
+ 'Linear {{value}}',
+ { value }
+ )
+ : translate(
+ 'auto.components.sidebar.worktreeIssueDisplacement.9c4b7e1f60',
+ 'GitHub #{{value}}',
+ { value }
+ )
+}
+
+/** Names the persisted links a save would drop. A workspace tracks one issue, so
+ * a changed field displaces the other provider's slot and clearing drops both.
+ * Only a dirty field displaces anything — the dialog opens focused on Comment,
+ * and an untouched row must leave every link alone. */
+export function getDisplacedLinkLabels(args: {
+ draft: WorktreeMetaDraft
+ snapshot: WorktreeMetaSnapshot
+ isFolderWorkspace: boolean
+ linkedIssue: number | null
+ linkedLinearIssue: string | null
+}): string[] | null {
+ const { draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue } = args
+ if (isFolderWorkspace || !isIssueFieldDirty(draft, snapshot)) {
+ return null
+ }
+
+ const keeping = draft.issueInput.trim() === '' ? null : draft.issueProvider
+ const displaced: string[] = []
+ if (keeping !== 'linear' && linkedLinearIssue) {
+ displaced.push(formatLinkLabel('linear', linkedLinearIssue))
+ }
+ if (keeping !== 'github' && typeof linkedIssue === 'number') {
+ displaced.push(formatLinkLabel('github', String(linkedIssue)))
+ }
+ return displaced.length > 0 ? displaced : null
+}
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
index e7e81727b07..3ce6ffb74b4 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts
@@ -1,79 +1,366 @@
import { describe, expect, it } from 'vitest'
-import { buildWorktreeMetaUpdates } from './worktree-meta-updates'
+import type { WorktreeMeta } from '../../../../shared/types'
+import {
+ buildWorktreeMetaUpdates,
+ type WorktreeMetaDraft,
+ type WorktreeMetaLiveLinks,
+ type WorktreeMetaSnapshot
+} from './worktree-meta-updates'
+
+function makeDraft(overrides: Partial = {}): WorktreeMetaDraft {
+ return {
+ displayNameInput: 'Workspace',
+ issueInput: '',
+ issueProvider: 'github',
+ prInput: '',
+ commentInput: '',
+ ...overrides
+ }
+}
+
+function makeSnapshot(overrides: Partial = {}): WorktreeMetaSnapshot {
+ return {
+ displayName: 'Workspace',
+ comment: '',
+ issueInput: '',
+ issueProvider: 'github',
+ ...overrides
+ }
+}
+
+/** Persistence raw-spreads updates, so a present-but-undefined key erases the
+ * stored value — the invariant is asserted on every build in this suite. */
+function buildUpdates(
+ draft: Partial,
+ snapshot: Partial = {},
+ live: WorktreeMetaLiveLinks = {}
+): Partial {
+ const updates = buildWorktreeMetaUpdates(makeDraft(draft), makeSnapshot(snapshot), live)
+ const undefinedKeys = Object.keys(updates).filter(
+ (key) => updates[key as keyof WorktreeMeta] === undefined
+ )
+ expect(undefinedKeys).toEqual([])
+ return updates
+}
+
+const LINEAR_LINK_KEYS = [
+ 'linkedLinearIssue',
+ 'linkedLinearIssueWorkspaceId',
+ 'linkedLinearIssueOrganizationUrlKey'
+] as const
describe('buildWorktreeMetaUpdates', () => {
- it('rejects issue URLs in the PR input', () => {
- expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: '',
- prInput: 'https://github.com/stablyai/orca/issues/6933',
- commentInput: ''
- })
- ).toEqual({
- comment: '',
- linkedIssue: null
+ // The dialog opens focused on Comment, so this is the common save path; a
+ // regression here silently destroys the user's existing link.
+ it('emits no link keys when the issue field is untouched', () => {
+ const updates = buildUpdates(
+ { issueInput: 'STA-335', issueProvider: 'linear', commentInput: 'shipping today' },
+ { issueInput: 'STA-335', issueProvider: 'linear' }
+ )
+
+ expect(updates.comment).toBe('shipping today')
+ expect(updates).toHaveProperty('linkedPR', null)
+ expect(updates).not.toHaveProperty('linkedIssue')
+ for (const key of LINEAR_LINK_KEYS) {
+ expect(updates).not.toHaveProperty(key)
+ }
+ })
+
+ it('writes a GitHub issue number and clears the Linear slots', () => {
+ expect(buildUpdates({ issueInput: '12' }, {}, { linkedLinearIssue: 'STA-335' })).toEqual({
+ linkedIssue: 12,
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
+ linkedPR: null
})
})
- it('accepts PR URLs in the PR input', () => {
+ // Persistence gates the remote Linear capability on key presence, so a clear
+ // for a link that never existed fails a GitHub-only save on an older runtime.
+ it('emits no Linear clear when the workspace holds no Linear link', () => {
+ const updates = buildUpdates({ issueInput: '12' })
+
+ expect(updates).toEqual({ linkedIssue: 12, linkedPR: null })
+ for (const key of LINEAR_LINK_KEYS) {
+ expect(updates).not.toHaveProperty(key)
+ }
+ })
+
+ it('writes a bare Linear identifier and clears the stored organization key', () => {
+ expect(buildUpdates({ issueInput: 'sta-335', issueProvider: 'linear' })).toEqual({
+ linkedIssue: null,
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
+ linkedPR: null
+ })
+ })
+
+ it('takes the organization key from a Linear issue URL', () => {
expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: '',
- prInput: 'https://github.com/stablyai/orca/pull/6934',
- commentInput: ''
+ buildUpdates({
+ issueInput: 'https://linear.app/acme/issue/STA-335/fix-auth',
+ issueProvider: 'linear'
})
).toEqual({
- comment: '',
linkedIssue: null,
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: 'acme',
+ linkedPR: null
+ })
+ })
+
+ it('clears every provider slot when the issue field is emptied', () => {
+ expect(
+ buildUpdates(
+ { issueInput: ' ' },
+ { issueInput: '42' },
+ { linkedIssue: 42, linkedLinearIssue: 'STA-335' }
+ )
+ ).toEqual({
+ linkedIssue: null,
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
+ linkedPR: null
+ })
+ })
+
+ it('treats a provider switch with unchanged text as dirty', () => {
+ const updates = buildUpdates(
+ { issueInput: 'STA-335', issueProvider: 'linear' },
+ { issueInput: 'STA-335', issueProvider: 'github' }
+ )
+
+ expect(updates.linkedLinearIssue).toBe('STA-335')
+ expect(updates.linkedIssue).toBeNull()
+ })
+
+ it('displaces a Linear linked work item when the issue field changes', () => {
+ const updates = buildUpdates(
+ { issueInput: '12' },
+ {},
+ {
+ linkedLinearIssue: 'STA-335',
+ linkedWorkItemProvider: 'linear',
+ linkedWorkItemType: 'issue'
+ }
+ )
+
+ expect(updates).toHaveProperty('linkedWorkItem', null)
+ expect(updates).toHaveProperty('linkedTaskSourceContext', null)
+ })
+
+ // linkedWorkItem also records the PR or MR a workspace was created from, which
+ // the Issue row does not own and must not drop.
+ it('leaves a PR-typed work item alone', () => {
+ const updates = buildUpdates(
+ { issueInput: '12' },
+ {},
+ { linkedWorkItemProvider: 'github', linkedWorkItemType: 'pr' }
+ )
+
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ })
+
+ // The row cannot render a GitLab or Jira issue, so clearing one would destroy a
+ // link the user was never shown — and neither has another editor to restore it.
+ it('leaves work items owned by other providers alone', () => {
+ for (const provider of ['jira', 'gitlab'] as const) {
+ const updates = buildUpdates(
+ { issueInput: '12' },
+ {},
+ { linkedWorkItemProvider: provider, linkedWorkItemType: 'issue' }
+ )
+
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ }
+ })
+
+ // Only the spelling changed, so the field names the same issue it already
+ // holds — treating that as an edit would clear the title and source context.
+ it('emits nothing when a GitHub number is respelled with a hash', () => {
+ const updates = buildUpdates(
+ { issueInput: '#42' },
+ { issueInput: '42' },
+ { linkedIssue: 42, linkedWorkItemProvider: 'github', linkedWorkItemType: 'issue' }
+ )
+
+ expect(updates).toEqual({ linkedPR: null })
+ })
+
+ it('emits nothing when a Linear identifier is respelled in lower case', () => {
+ const updates = buildUpdates(
+ { issueInput: 'sta-335', issueProvider: 'linear' },
+ { issueInput: 'STA-335', issueProvider: 'linear' },
+ { linkedLinearIssue: 'STA-335' }
+ )
+
+ expect(updates).toEqual({ linkedPR: null })
+ })
+
+ it('emits nothing when a Linear identifier is respelled as its stored URL', () => {
+ const updates = buildUpdates(
+ { issueInput: 'https://linear.app/acme/issue/STA-335/fix-auth', issueProvider: 'linear' },
+ {
+ issueInput: 'STA-335',
+ issueProvider: 'linear',
+ linkedLinearIssueOrganizationUrlKey: 'acme'
+ },
+ { linkedLinearIssue: 'STA-335' }
+ )
+
+ expect(updates).toEqual({ linkedPR: null })
+ })
+
+ // The URL adds the org key the stored link lacked, which is worth persisting —
+ // but it still names the same issue, so its title and routing context stay.
+ it('records an organization key for a stored bare identifier without displacing it', () => {
+ const updates = buildUpdates(
+ { issueInput: 'https://linear.app/acme/issue/STA-335', issueProvider: 'linear' },
+ { issueInput: 'STA-335', issueProvider: 'linear' },
+ {
+ linkedLinearIssue: 'STA-335',
+ linkedWorkItemProvider: 'linear',
+ linkedWorkItemType: 'issue'
+ }
+ )
+
+ expect(updates.linkedLinearIssueOrganizationUrlKey).toBe('acme')
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ })
+
+ // Same identifier, different organization: a team-prefix collision across two
+ // Linear workspaces is a different issue, so the stale title has to go.
+ it('displaces the work item when a URL names another organization', () => {
+ const updates = buildUpdates(
+ { issueInput: 'https://linear.app/other/issue/STA-335', issueProvider: 'linear' },
+ {
+ issueInput: 'STA-335',
+ issueProvider: 'linear',
+ linkedLinearIssueOrganizationUrlKey: 'acme'
+ },
+ {
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueOrganizationUrlKey: 'acme',
+ linkedWorkItemProvider: 'linear',
+ linkedWorkItemType: 'issue'
+ }
+ )
+
+ expect(updates.linkedLinearIssueOrganizationUrlKey).toBe('other')
+ expect(updates).toHaveProperty('linkedWorkItem', null)
+ expect(updates).toHaveProperty('linkedTaskSourceContext', null)
+ })
+
+ // A CLI or background write can land while the dialog is open. Displacement
+ // reads live state, so the save cannot leave both provider slots populated.
+ it('clears a Linear link added after the snapshot was taken', () => {
+ const updates = buildUpdates({ issueInput: '12' }, {}, { linkedLinearIssue: 'STA-999' })
+
+ expect(updates.linkedIssue).toBe(12)
+ expect(updates.linkedLinearIssue).toBeNull()
+ })
+
+ it('clears a Linear link added after the snapshot when the field is emptied', () => {
+ const updates = buildUpdates(
+ { issueInput: '' },
+ { issueInput: '42' },
+ { linkedLinearIssue: 'STA-999' }
+ )
+
+ expect(updates.linkedIssue).toBeNull()
+ expect(updates.linkedLinearIssue).toBeNull()
+ })
+
+ it('ignores a provider switch on an empty field', () => {
+ const updates = buildUpdates(
+ { issueInput: '', issueProvider: 'linear' },
+ { issueInput: '', issueProvider: 'github' }
+ )
+
+ expect(updates).not.toHaveProperty('linkedIssue')
+ for (const key of LINEAR_LINK_KEYS) {
+ expect(updates).not.toHaveProperty(key)
+ }
+ })
+
+ it('does not displace a Linear work item when the issue field is clean', () => {
+ const updates = buildUpdates({ commentInput: 'note' }, {}, { linkedWorkItemProvider: 'linear' })
+
+ expect(updates).not.toHaveProperty('linkedWorkItem')
+ expect(updates).not.toHaveProperty('linkedTaskSourceContext')
+ })
+
+ it('leaves links untouched for unparseable issue input', () => {
+ const updates = buildUpdates(
+ { issueInput: 'not an issue', displayNameInput: 'Renamed', commentInput: 'note' },
+ { displayName: 'Workspace' }
+ )
+
+ expect(updates).toEqual({
+ comment: 'note',
+ displayName: 'Renamed',
+ linkedPR: null
+ })
+ })
+
+ it('clears a display name with empty string, never a present-undefined key', () => {
+ const updates = buildUpdates({ displayNameInput: ' ' }, { displayName: 'Custom Name' })
+
+ expect(updates.displayName).toBe('')
+ })
+
+ it('rejects issue URLs in the PR input', () => {
+ expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/issues/6933' })).toEqual({})
+ })
+
+ it('accepts PR URLs in the PR input', () => {
+ expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({
linkedPR: 6934
})
})
it('accepts issue URLs in the issue input', () => {
expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: 'https://github.com/stablyai/orca/issues/6933',
- prInput: '',
- commentInput: ''
- })
+ buildUpdates(
+ { issueInput: 'https://github.com/stablyai/orca/issues/6933' },
+ {},
+ { linkedLinearIssue: 'STA-335' }
+ )
).toEqual({
- comment: '',
linkedIssue: 6933,
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null,
linkedPR: null
})
})
- it('clears a display name with empty string, never a present-undefined key', () => {
- const updates = buildWorktreeMetaUpdates({
- displayNameInput: ' ',
- currentDisplayName: 'Custom Name',
- issueInput: '',
- prInput: '',
- commentInput: ''
- })
-
- expect(updates.displayName).toBe('')
- expect(Object.values(updates).every((value) => value !== undefined)).toBe(true)
- })
-
it('rejects PR URLs in the issue input', () => {
- expect(
- buildWorktreeMetaUpdates({
- displayNameInput: 'Workspace',
- currentDisplayName: 'Workspace',
- issueInput: 'https://github.com/stablyai/orca/pull/6934',
- prInput: '',
- commentInput: ''
- })
- ).toEqual({
- comment: '',
+ expect(buildUpdates({ issueInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({
linkedPR: null
})
})
+
+ // Persistence stamps lastActivityAt on any comment write, so re-emitting an
+ // unchanged note reorders the workspace under the time-decay sidebar sort.
+ it('emits no comment when the note is unchanged', () => {
+ const updates = buildUpdates(
+ { issueInput: '12', commentInput: 'shipping today' },
+ { comment: 'shipping today' }
+ )
+
+ expect(updates).not.toHaveProperty('comment')
+ expect(updates.linkedIssue).toBe(12)
+ })
+
+ it('clears a comment with empty string, never a present-undefined key', () => {
+ expect(buildUpdates({ commentInput: ' ' }, { comment: 'old note' }).comment).toBe('')
+ })
})
diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
index 538b8de6b3a..944da175d77 100644
--- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts
+++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts
@@ -1,11 +1,53 @@
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links'
-import type { WorktreeMeta } from '../../../../shared/types'
+import {
+ buildLinearIssueLinkUpdates,
+ LINEAR_ISSUE_LINK_CLEARED
+} from '../../../../shared/linear-links'
+import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input'
+import type { WorkspaceSourceProvider } from '../../../../shared/new-workspace/workspace-source'
+import type { WorkspaceLinkedItem, WorktreeMeta } from '../../../../shared/types'
export type WorktreeMetaSavedPayload = {
worktreeId: string
updates: Partial
}
+/** What the user currently has typed in the dialog. */
+export type WorktreeMetaDraft = {
+ displayNameInput: string
+ issueInput: string
+ issueProvider: IssueLinkProvider
+ prInput: string
+ commentInput: string
+}
+
+/** The persisted state the dialog was seeded from. Captured once when the
+ * dialog opens: comparing a frozen draft against a live store would let a
+ * background write move the baseline and make an untouched field "dirty". */
+export type WorktreeMetaSnapshot = {
+ displayName: string
+ comment: string
+ issueInput: string
+ issueProvider: IssueLinkProvider
+ /** Stands in for an org key the typed value omits, so re-saving a stored bare
+ * identifier does not read as a change. */
+ linkedLinearIssueOrganizationUrlKey?: string | null
+}
+
+/** The link state as it stands now, read at save time rather than at open.
+ * Displacement is decided against this: a CLI or background write that landed
+ * while the dialog was open must not survive a save the dialog warned would
+ * displace it, and a clear must not be emitted for a slot that is already empty
+ * — persistence gates the remote Linear capability on key presence, not value. */
+export type WorktreeMetaLiveLinks = {
+ linkedIssue?: number | null
+ linkedLinearIssue?: string | null
+ linkedLinearIssueOrganizationUrlKey?: string | null
+ linkedWorkItemProvider?: WorkspaceSourceProvider | null
+ /** `linkedWorkItem` also describes PRs and MRs, which this row does not own. */
+ linkedWorkItemType?: WorkspaceLinkedItem['type'] | null
+}
+
export function parseExplicitGitHubIssueUrl(input: string): string | null {
const trimmed = input.trim()
const link = parseGitHubIssueOrPRLink(trimmed)
@@ -30,38 +72,193 @@ export function parseGitHubWorkItemNumberForMetaField(
return parseGitHubIssueOrPRNumber(input)
}
+// Why: blanking the field means "fall back to the branch/folder name", and the
+// empty string is how that intent is persisted. Emitting `undefined` instead
+// put a present-but-undefined key into the store spread, wiping the live name
+// and crashing the worktree palette (crash a1f81ea1).
+function buildDisplayNameUpdate(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot
+): Partial {
+ const trimmed = draft.displayNameInput.trim()
+ return trimmed === current.displayName ? {} : { displayName: trimmed }
+}
+
+// Why: persistence bumps lastActivityAt whenever a `comment` key is present, so
+// re-emitting an unchanged note reorders the workspace under the time-decay
+// sidebar sort on a save that only touched the issue link.
+function buildCommentUpdate(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot
+): Partial {
+ const trimmed = draft.commentInput.trim()
+ return trimmed === current.comment ? {} : { comment: trimmed }
+}
+
+/** Which issue a value names, ignoring spelling: `42` and `#42` are one GitHub
+ * link, `sta-335`, `STA-335` and its linear.app URL are one Linear link. A
+ * Linear URL still refines a
+ * stored org key, so the key belongs to the identity — with the stored one
+ * standing in when the input omits it. Unparseable text compares as raw text:
+ * there is nothing to normalize, and the builder writes nothing for it anyway. */
+function issueLinkIdentity(
+ input: string,
+ provider: IssueLinkProvider,
+ storedLinearOrganizationUrlKey: string | null
+): string {
+ const trimmed = input.trim()
+ if (trimmed === '') {
+ return ''
+ }
+ const parsed = parseIssueLinkInput(trimmed, provider)
+ if (!parsed) {
+ return `raw:${provider}:${trimmed}`
+ }
+ if (parsed.provider === 'github') {
+ return `github:${parsed.number}`
+ }
+ const organizationUrlKey = parsed.organizationUrlKey ?? storedLinearOrganizationUrlKey ?? ''
+ return `linear:${parsed.identifier}:${organizationUrlKey.trim().toLowerCase()}`
+}
+
+// Why: normalized identity rather than trimmed text. Retyping the same issue in
+// another spelling — `42` to `#42`, `STA-335` to its URL — would otherwise enter
+// the displacement path and clear the title and source context of the very link
+// it re-states. A provider switch is only visible through the identity when
+// there is a value to reinterpret, which is the intent an empty field lacks.
+export function isIssueFieldDirty(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot
+): boolean {
+ const storedOrganizationUrlKey = current.linkedLinearIssueOrganizationUrlKey ?? null
+ return (
+ issueLinkIdentity(draft.issueInput, draft.issueProvider, storedOrganizationUrlKey) !==
+ issueLinkIdentity(current.issueInput, current.issueProvider, storedOrganizationUrlKey)
+ )
+}
+
+/** Whether the value being saved names the very issue `linkedWorkItem` already
+ * describes. Org keys only disagree when both are known: a stored link without
+ * one is not evidence of a different organization, so a URL that supplies it
+ * refines the link rather than replacing it. */
+function keepsLinkedWorkItem(
+ input: string,
+ provider: IssueLinkProvider,
+ live: WorktreeMetaLiveLinks
+): boolean {
+ const parsed = parseIssueLinkInput(input.trim(), provider)
+ if (!parsed || live.linkedWorkItemType !== 'issue') {
+ return false
+ }
+ if (parsed.provider === 'github') {
+ return live.linkedWorkItemProvider === 'github' && parsed.number === live.linkedIssue
+ }
+ if (
+ live.linkedWorkItemProvider !== 'linear' ||
+ parsed.identifier.toUpperCase() !== live.linkedLinearIssue?.trim().toUpperCase()
+ ) {
+ return false
+ }
+ const storedOrganizationUrlKey = live.linkedLinearIssueOrganizationUrlKey?.trim()
+ const nextOrganizationUrlKey = parsed.organizationUrlKey?.trim()
+ return (
+ !storedOrganizationUrlKey ||
+ !nextOrganizationUrlKey ||
+ storedOrganizationUrlKey.toLowerCase() === nextOrganizationUrlKey.toLowerCase()
+ )
+}
+
+/** Owns both provider slot families. One issue per workspace: writing one
+ * provider clears the other. Emits nothing at all unless the field changed —
+ * the dialog opens focused on Comment, so an untouched field must never
+ * destroy a link the user came here to keep. */
+function buildIssueLinkUpdates(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot,
+ live: WorktreeMetaLiveLinks
+): Partial {
+ if (!isIssueFieldDirty(draft, current)) {
+ return {}
+ }
+
+ const trimmed = draft.issueInput.trim()
+ // Why: the linked work item and its source context describe the issue being
+ // replaced. Leaving them would keep a stale title badge and mis-scope Linear
+ // reads — but only when the save names a *different* issue: a value that
+ // re-states the same one, such as a URL adding an org key, must keep its own
+ // title and SSH/runtime routing context. Narrow on purpose: `type` because the
+ // field also records the PR or MR a workspace was created from, and provider
+ // because GitLab and Jira issues have no slot in this row — displacing what it
+ // cannot display would destroy a link the user was never shown and has no
+ // other editor to restore it from.
+ const displacedWorkItem: Partial =
+ !keepsLinkedWorkItem(trimmed, draft.issueProvider, live) &&
+ (live.linkedWorkItemProvider === 'github' || live.linkedWorkItemProvider === 'linear') &&
+ live.linkedWorkItemType === 'issue'
+ ? { linkedWorkItem: null, linkedTaskSourceContext: null }
+ : {}
+
+ // Why: persistence gates the remote Linear capability on key presence, not
+ // value. A synthetic clear on a workspace that never held a Linear link would
+ // fail a GitHub-only save against an older runtime, citing Linear. Read live,
+ // not from the snapshot: a link added since the dialog opened would otherwise
+ // outlive a save that just promised to displace it.
+ const displacedLinear: Partial = live.linkedLinearIssue
+ ? LINEAR_ISSUE_LINK_CLEARED
+ : {}
+
+ if (trimmed === '') {
+ return {
+ linkedIssue: null,
+ ...displacedLinear,
+ ...displacedWorkItem
+ }
+ }
+
+ const parsed = parseIssueLinkInput(trimmed, draft.issueProvider)
+ if (!parsed) {
+ // Why: unparseable input leaves every link untouched. `canSave` already
+ // blocks this path, but the builder stays pure rather than relying on it.
+ return {}
+ }
+
+ if (parsed.provider === 'github') {
+ return {
+ linkedIssue: parsed.number,
+ ...displacedLinear,
+ ...displacedWorkItem
+ }
+ }
+
+ const linearUpdates = buildLinearIssueLinkUpdates(trimmed)
+ return linearUpdates ? { linkedIssue: null, ...linearUpdates, ...displacedWorkItem } : {}
+}
+
+// Requires the dialog to seed `prInput` from the persisted `linkedPR`: the blank
+// input is written through as a clear, so an unseeded field drops the link on an
+// untouched save.
+function buildPrLinkUpdate(draft: WorktreeMetaDraft): Partial {
+ const trimmed = draft.prInput.trim()
+ if (trimmed === '') {
+ return { linkedPR: null }
+ }
+ const number = parseGitHubWorkItemNumberForMetaField(trimmed, 'pr')
+ return number === null ? {} : { linkedPR: number }
+}
+
/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
- * the link (null), unparseable inputs leave it untouched (omitted). */
-export function buildWorktreeMetaUpdates(args: {
- displayNameInput: string
- currentDisplayName: string
- issueInput: string
- prInput: string
- commentInput: string
-}): Partial {
- const trimmedIssue = args.issueInput.trim()
- const linkedIssueNumber = parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue')
- const finalLinkedIssue =
- trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined
- const trimmedPR = args.prInput.trim()
- const linkedPRNumber = parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr')
- const finalLinkedPR =
- trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined
-
- // Why: blanking the field means "fall back to the branch/folder name", and the
- // empty string is how that intent is persisted. Emitting `undefined` instead
- // put a present-but-undefined key into the store spread, wiping the live name
- // and crashing the worktree palette (crash a1f81ea1).
- const trimmedDisplayName = args.displayNameInput.trim()
- const updates: Partial = {
- comment: args.commentInput.trim(),
- ...(trimmedDisplayName !== args.currentDisplayName && { displayName: trimmedDisplayName })
- }
- if (finalLinkedIssue !== undefined) {
- updates.linkedIssue = finalLinkedIssue
- }
- if (finalLinkedPR !== undefined) {
- updates.linkedPR = finalLinkedPR
- }
- return updates
+ * the link (null), unparseable inputs leave it untouched (omitted). No key is
+ * ever emitted holding `undefined` — persistence raw-spreads updates, so a
+ * present-but-undefined key erases the stored value. */
+export function buildWorktreeMetaUpdates(
+ draft: WorktreeMetaDraft,
+ current: WorktreeMetaSnapshot,
+ live: WorktreeMetaLiveLinks
+): Partial {
+ return {
+ ...buildCommentUpdate(draft, current),
+ ...buildDisplayNameUpdate(draft, current),
+ ...buildIssueLinkUpdates(draft, current, live),
+ ...buildPrLinkUpdate(draft)
+ }
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 78658fcebce..a6b8d016fcf 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -270,6 +270,7 @@
"4a18052018": "Local {{value0}} is behind {{value1}}",
"903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}",
"0216895fb5": "Failed to delete branch",
+ "c6cf133786": "This workspace is no longer available.",
"19db0085fb": "Local branch deleted",
"2b0afc7f14": "Could not keep local {{value0}} up to date",
"670864ab52": "Keeping local {{value0}} up to date",
@@ -279,7 +280,8 @@
"3b57982bf6": "Git could not safely delete branch \"{{value0}}\" after deleting workspace \"{{value1}}\", so Orca kept it to avoid losing local commits.",
"81f13f48d2": "Git could not safely delete branch \"{{value0}}\" after deleting worktree \"{{value1}}\", so Orca kept it to avoid losing local commits.",
"runtimeScopeForbiddenTitle": "This connection has limited (mobile) access",
- "runtimeScopeForbiddenDescription": "Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server."
+ "runtimeScopeForbiddenDescription": "Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.",
+ "a17f4d2e93": "Could not update this workspace."
},
"repos": {
"2975400634": "Update Orca server to open non-Git folders on this runtime.",
@@ -4680,7 +4682,8 @@
"65770ad0f0": "Edit GitHub links and notes for this workspace.",
"382fd11a3e": "Edit Worktree Details",
"2174f17011": "Save",
- "61d6f612cf": "Saving..."
+ "61d6f612cf": "Saving...",
+ "a0d191b7a7": "Edit issue links, pull request links, and notes for this workspace."
},
"WorktreeOpenInMenu": {
"localOnly": "Local only",
@@ -5143,6 +5146,26 @@
"7f1c2e94a5": "Search text is too long — the board is unfiltered",
"9a4d0f6b21": "Too long"
},
+ "WorktreeIssueLinkField": {
+ "25852bfc59": "Linear",
+ "5b440069e6": "GitHub",
+ "161b2d053a": "Open linked issue",
+ "d4785f9954": "Issue links are set when a folder workspace is created and can't be changed here yet.",
+ "964d9bc00a": "Not a Linear issue key or linear.app issue URL.",
+ "0a7a2c6efd": "Not a GitHub issue number or issue URL.",
+ "72486800ff": "Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.",
+ "2c245ac134": "Saving unlinks {{link}} — a workspace tracks one issue.",
+ "d8c8a30d1f": "Couldn't open that issue. Check the identifier and your Linear connection.",
+ "269198eeda": "Couldn't open that issue. Check the number and your GitHub connection.",
+ "f047887705": "Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.",
+ "ad78f9bee2": "Issue",
+ "662ae142f8": "Issue #, or a GitHub or Linear URL",
+ "929c98d05a": "Issue provider"
+ },
+ "worktreeIssueDisplacement": {
+ "3f61c0a8d2": "Linear {{value}}",
+ "9c4b7e1f60": "GitHub #{{value}}"
+ },
"WorktreeCardSshHostControl": {
"connectFailed": "SSH connection failed",
"removedTooltip": "SSH host removed — reconnect unavailable",
diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts
index 6f9965cbe10..40281b4a244 100644
--- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts
+++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts
@@ -339,9 +339,7 @@ describe('wakeSleepingAgentsForWorktreeInBackground', () => {
// Why: canonicalization deletes same-claim duplicates, so a lazy record must be filtered
// out before it can win the claim and strand the hibernated pane it deletes.
expect(rec.mountDetails).toEqual([{ worktreeId: 'wt-1', tabIds: ['tab-hibernated'] }])
- expect(clearSleepingAgentSessionsByPaneKey).not.toHaveBeenCalledWith([
- 'tab-hibernated:leaf-1'
- ])
+ expect(clearSleepingAgentSessionsByPaneKey).not.toHaveBeenCalledWith(['tab-hibernated:leaf-1'])
expect(sleepingRecords).toHaveProperty('tab-hibernated:leaf-1')
expect(sleepingRecords).toHaveProperty('tab-slept:leaf-1')
})
diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts
index 75ad5985153..16a58fd985f 100644
--- a/src/renderer/src/store/slices/worktree-helpers.ts
+++ b/src/renderer/src/store/slices/worktree-helpers.ts
@@ -243,11 +243,13 @@ export type WorktreeSlice = {
expectedHead: string
) => Promise<({ ok: true } & ForceDeleteWorktreeBranchResult) | { ok: false; error: string }>
clearWorktreeDeleteState: (worktreeId: string) => void
+ /** Never rejects — most callers fire-and-forget. Callers that own a surface
+ * the user is waiting on should read the result and say what went wrong. */
updateWorktreeMeta: (
worktreeId: string,
updates: Partial,
options?: WorktreeMetaUpdateOptions
- ) => Promise
+ ) => Promise<{ ok: true } | { ok: false; error: string }>
ensureHostedReviewPushTarget: (worktreeId: string) => Promise
updateWorktreesMeta: (
updatesByWorktreeId: ReadonlyMap>
diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts
index d4d944db201..1d2b822dffd 100644
--- a/src/renderer/src/store/slices/worktrees.test.ts
+++ b/src/renderer/src/store/slices/worktrees.test.ts
@@ -7024,6 +7024,37 @@ describe('worktree remote runtime mutations', () => {
})
})
+ // A rejected folder update reconciles the optimistic write away, so reporting
+ // ok would show the dialog a save that silently undid itself.
+ it('reports a rejected folder workspace update to the caller', async () => {
+ const store = createTestStore()
+ const folderWorkspace = makeFolderWorkspace({ id: 'folder-rejected' })
+ store.setState({ folderWorkspaces: [folderWorkspace] } as Partial)
+ const updateFolderWorkspace = vi.fn().mockResolvedValue(false)
+ store.setState({ updateFolderWorkspace } as Partial)
+
+ const result = await store
+ .getState()
+ .updateWorktreeMeta(folderWorkspaceKey(folderWorkspace.id), { comment: 'note' })
+
+ expect(updateFolderWorkspace).toHaveBeenCalledTimes(1)
+ expect(result).toEqual({ ok: false, error: expect.any(String) })
+ })
+
+ it('reports a thrown folder workspace update instead of rejecting', async () => {
+ const store = createTestStore()
+ const folderWorkspace = makeFolderWorkspace({ id: 'folder-throwing' })
+ store.setState({ folderWorkspaces: [folderWorkspace] } as Partial)
+ const updateFolderWorkspace = vi.fn().mockRejectedValue(new Error('Runtime is offline'))
+ store.setState({ updateFolderWorkspace } as Partial)
+
+ const result = await store
+ .getState()
+ .updateWorktreeMeta(folderWorkspaceKey(folderWorkspace.id), { comment: 'note' })
+
+ expect(result).toEqual({ ok: false, error: 'Runtime is offline' })
+ })
+
it('persists activity for hidden detected worktrees', async () => {
const store = createTestStore()
const hidden = makeWorktree({
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index 310e3e82d8d..6cd073abbe1 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -51,7 +51,10 @@ import {
isRuntimeScopeForbiddenError,
RuntimeRpcCallError
} from '../../runtime/runtime-rpc-client'
-import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
+import {
+ TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
+ WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY
+} from '../../../../shared/protocol-version'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review'
import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch'
@@ -1711,16 +1714,26 @@ async function persistWorktreeMeta(
await window.api.worktrees.updateMeta({ worktreeId, updates })
return
}
- // Why: same gate as worktree.create — an older paired runtime would drop the Jira link silently.
+ // Why: `worktree.set` parses in strip mode, so an older runtime drops the key
+ // and applies the rest. Both gates key off presence, not value — a dropped
+ // *clear* strands a stale link that the Issue row then hides.
if (
target.kind === 'environment' &&
- (updates.linkedWorkItem?.provider === 'jira' ||
- updates.linkedTaskSourceContext?.provider === 'jira')
+ ('linkedWorkItem' in updates || 'linkedTaskSourceContext' in updates)
) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
- 'Update the remote runtime to link Jira'
+ 'Update the remote runtime to change this workspace’s linked issue'
+ )
+ }
+ // task-source-context.v1 is a sound proxy for the Linear keys: #5322 added them
+ // to the schema and is an ancestor of the commit introducing that capability.
+ if (target.kind === 'environment' && 'linkedLinearIssue' in updates) {
+ await assertRuntimeEnvironmentCapability(
+ target.environmentId,
+ TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
+ 'Update the remote runtime to link Linear issues'
)
}
await callRuntimeRpc(
@@ -4498,15 +4511,34 @@ export const createWorktreeSlice: StateCreator
const shouldApplyUpdate = options?.shouldApply
const existingWorktree = get().getKnownWorktreeById(worktreeId)
if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) {
- return
+ return { ok: true }
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
const folderUpdates = getFolderWorkspaceMetaUpdates(updates)
- if (Object.keys(folderUpdates).length > 0) {
- await get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, folderUpdates)
+ if (Object.keys(folderUpdates).length === 0) {
+ return { ok: true }
+ }
+ try {
+ // Why: a rejected folder update reconciles the optimistic write away, so
+ // reporting ok would show the dialog a save that silently undid itself.
+ const updated = await get().updateFolderWorkspace(
+ workspaceScope.folderWorkspaceId,
+ folderUpdates
+ )
+ return updated
+ ? { ok: true }
+ : {
+ ok: false,
+ error: translate(
+ 'auto.store.slices.worktrees.a17f4d2e93',
+ 'Could not update this workspace.'
+ )
+ }
+ } catch (err) {
+ console.error('Failed to update folder workspace meta:', err)
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
- return
}
const normalizedUpdates = existingWorktree
? clearOlderHostedReviewLinksForReplacement(updates, existingWorktree)
@@ -4541,7 +4573,7 @@ export const createWorktreeSlice: StateCreator
existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key
const worktreeForUpdate = get().getKnownWorktreeById(worktreeId)
if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) {
- return
+ return { ok: true }
}
const shouldRefreshHostedReview =
(normalizedUpdates.linkedPR === null && worktreeForUpdate?.linkedPR !== null) ||
@@ -4662,7 +4694,7 @@ export const createWorktreeSlice: StateCreator
}
})
if (shouldApplyUpdate && !didApply) {
- return
+ return { ok: true }
}
if (hasHostedReviewLinkUpdates(enriched)) {
bumpHostedReviewLinkMutationGeneration(worktreeId)
@@ -4710,11 +4742,21 @@ export const createWorktreeSlice: StateCreator
} catch (err) {
if (isRuntimeSelectorNotFoundError(err)) {
void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId))
- return
+ return {
+ ok: false,
+ error: translate(
+ 'auto.store.slices.worktrees.c6cf133786',
+ 'This workspace is no longer available.'
+ )
+ }
}
console.error('Failed to update worktree meta:', err)
void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId))
+ // Why: the refetch above reverts the optimistic write, so a caller that
+ // closes its surface on this path shows the user a save that undid itself.
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
+ return { ok: true }
},
ensureHostedReviewPushTarget: async (worktreeId) => {
diff --git a/src/shared/issue-link-input.test.ts b/src/shared/issue-link-input.test.ts
new file mode 100644
index 00000000000..bf026edc43f
--- /dev/null
+++ b/src/shared/issue-link-input.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest'
+
+import { getIssueLinkProviderFromUrl, parseIssueLinkInput } from './issue-link-input'
+
+describe('getIssueLinkProviderFromUrl', () => {
+ it('detects GitHub issue URLs', () => {
+ expect(getIssueLinkProviderFromUrl('https://github.com/o/r/issues/12')).toBe('github')
+ })
+
+ it('does not flip the provider for a GitHub pull URL', () => {
+ expect(getIssueLinkProviderFromUrl('https://github.com/o/r/pull/12')).toBeNull()
+ })
+
+ it('detects Linear issue URLs with and without a slug', () => {
+ expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335')).toBe('linear')
+ expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335/some-slug')).toBe(
+ 'linear'
+ )
+ })
+
+ it('ignores non-issue paths on the Linear host', () => {
+ expect(getIssueLinkProviderFromUrl('https://linear.app/acme/team/ENG/all')).toBeNull()
+ })
+
+ it('rejects hosts that merely contain linear.app', () => {
+ expect(getIssueLinkProviderFromUrl('https://linear.app.evil.com/acme/issue/STA-335')).toBeNull()
+ })
+
+ // Linear and Jira issue-key shapes are byte-identical, so a bare key must
+ // never override the user's explicit provider choice.
+ it('is not decisive for bare issue keys', () => {
+ expect(getIssueLinkProviderFromUrl('STA-335')).toBeNull()
+ expect(getIssueLinkProviderFromUrl(' STA-335 ')).toBeNull()
+ })
+
+ it('is not decisive for bare numbers', () => {
+ expect(getIssueLinkProviderFromUrl('1234')).toBeNull()
+ expect(getIssueLinkProviderFromUrl('#1234')).toBeNull()
+ })
+
+ it('returns null for empty input', () => {
+ expect(getIssueLinkProviderFromUrl('')).toBeNull()
+ expect(getIssueLinkProviderFromUrl(' ')).toBeNull()
+ })
+})
+
+describe('parseIssueLinkInput', () => {
+ describe('github provider', () => {
+ it('accepts bare and hash-prefixed numbers', () => {
+ expect(parseIssueLinkInput('12', 'github')).toEqual({ provider: 'github', number: 12 })
+ expect(parseIssueLinkInput('#12', 'github')).toEqual({ provider: 'github', number: 12 })
+ })
+
+ it('accepts issue URLs', () => {
+ expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'github')).toEqual({
+ provider: 'github',
+ number: 12
+ })
+ })
+
+ it('rejects pull URLs', () => {
+ expect(parseIssueLinkInput('https://github.com/o/r/pull/12', 'github')).toBeNull()
+ })
+
+ it('rejects non-positive numbers and junk', () => {
+ expect(parseIssueLinkInput('0', 'github')).toBeNull()
+ expect(parseIssueLinkInput('-1', 'github')).toBeNull()
+ expect(parseIssueLinkInput('not an issue', 'github')).toBeNull()
+ expect(parseIssueLinkInput(' ', 'github')).toBeNull()
+ })
+
+ it('rejects Linear identifiers', () => {
+ expect(parseIssueLinkInput('STA-335', 'github')).toBeNull()
+ })
+
+ // Past the safe-integer range every digit string parses to the same float,
+ // so an unbounded parse would link an arbitrary issue number.
+ it('rejects numbers beyond the safe-integer range', () => {
+ expect(parseIssueLinkInput('9'.repeat(400), 'github')).toBeNull()
+ })
+ })
+
+ describe('linear provider', () => {
+ it('accepts bare identifiers and normalizes case', () => {
+ expect(parseIssueLinkInput('STA-335', 'linear')).toEqual({
+ provider: 'linear',
+ identifier: 'STA-335'
+ })
+ expect(parseIssueLinkInput('sta-335', 'linear')).toEqual({
+ provider: 'linear',
+ identifier: 'STA-335'
+ })
+ })
+
+ it('omits the organization key for bare identifiers', () => {
+ expect(parseIssueLinkInput('STA-335', 'linear')).not.toHaveProperty('organizationUrlKey')
+ })
+
+ it('accepts issue URLs and returns the organization key', () => {
+ expect(
+ parseIssueLinkInput('https://linear.app/acme/issue/STA-335/some-slug', 'linear')
+ ).toEqual({
+ provider: 'linear',
+ identifier: 'STA-335',
+ organizationUrlKey: 'acme'
+ })
+ })
+
+ it('rejects GitHub URLs', () => {
+ expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'linear')).toBeNull()
+ })
+
+ it('rejects junk and empty input', () => {
+ expect(parseIssueLinkInput('not an issue', 'linear')).toBeNull()
+ expect(parseIssueLinkInput(' ', 'linear')).toBeNull()
+ })
+ })
+})
diff --git a/src/shared/issue-link-input.ts b/src/shared/issue-link-input.ts
new file mode 100644
index 00000000000..37d98f55072
--- /dev/null
+++ b/src/shared/issue-link-input.ts
@@ -0,0 +1,75 @@
+import { parseGitHubIssueOrPRLink } from './github-links'
+import { parseLinearIssueInput } from './linear-links'
+import type { WorkspaceSourceProvider } from './new-workspace/workspace-source'
+
+// Why: narrows the canonical provider union instead of minting a parallel one,
+// so adding Jira here is a one-entry change rather than a new axis.
+export const ISSUE_LINK_PROVIDERS = [
+ 'github',
+ 'linear'
+] as const satisfies readonly WorkspaceSourceProvider[]
+
+export type IssueLinkProvider = (typeof ISSUE_LINK_PROVIDERS)[number]
+
+export function isIssueLinkProvider(value: unknown): value is IssueLinkProvider {
+ return ISSUE_LINK_PROVIDERS.includes(value as IssueLinkProvider)
+}
+
+/** URL input only. Linear and Jira issue keys are byte-identical in shape, so a
+ * bare `STA-335` can never decide a provider — it would override the chip. */
+export function getIssueLinkProviderFromUrl(input: string): IssueLinkProvider | null {
+ const trimmed = input.trim()
+ if (!/^https?:\/\//i.test(trimmed)) {
+ return null
+ }
+
+ // Why: a URL is decisive only if it names that provider's *issue*. A GitHub
+ // pull URL is a valid GitHub link but not an issue, and must not flip the
+ // provider into a state where the field then refuses to save it.
+ if (parseGitHubIssueOrPRLink(trimmed)?.type === 'issue') {
+ return 'github'
+ }
+ if (parseLinearIssueInput(trimmed)) {
+ return 'linear'
+ }
+ return null
+}
+
+export type ParsedIssueLinkInput =
+ | { provider: 'github'; number: number }
+ | { provider: 'linear'; identifier: string; organizationUrlKey?: string }
+
+/**
+ * Single parse shared by the dialog's save gate and its payload builder, so a
+ * value that enables Save is always the value that gets written.
+ */
+export function parseIssueLinkInput(
+ input: string,
+ provider: IssueLinkProvider
+): ParsedIssueLinkInput | null {
+ const trimmed = input.trim()
+ if (!trimmed) {
+ return null
+ }
+
+ if (provider === 'linear') {
+ const parsed = parseLinearIssueInput(trimmed)
+ return parsed ? { provider: 'linear', ...parsed } : null
+ }
+
+ const link = parseGitHubIssueOrPRLink(trimmed)
+ if (link) {
+ // Why: issue and PR numbers live in separate GitHub namespaces for refs; a
+ // pull URL pasted here must not silently become an issue link.
+ return link.type === 'issue' ? { provider: 'github', number: link.number } : null
+ }
+
+ const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
+ if (!/^\d+$/.test(numeric)) {
+ return null
+ }
+ const parsed = Number.parseInt(numeric, 10)
+ // Why: `/^\d+$/` happily accepts 400 digits, which parseInt turns into
+ // Infinity — Save would enable and JSON.stringify would persist `null`.
+ return Number.isSafeInteger(parsed) && parsed > 0 ? { provider: 'github', number: parsed } : null
+}
diff --git a/src/shared/linear-links.test.ts b/src/shared/linear-links.test.ts
index de7993714e0..24ac962df6e 100644
--- a/src/shared/linear-links.test.ts
+++ b/src/shared/linear-links.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
+ buildLinearIssueLinkUpdates,
+ buildLinearIssueUrl,
buildLinearPersonalApiKeySettingsUrl,
buildLinearTeamUrl,
buildLinearWorkspaceApiSettingsUrl,
@@ -62,4 +64,83 @@ describe('linear links', () => {
expect(parseLinearIssueInput('https://example.com/acme/issue/ENG-123')).toBeNull()
expect(parseLinearIssueInput('not an issue')).toBeNull()
})
+
+ // A hostname-only check accepts these, and the parsed link is later handed to
+ // shell.openUrl — so the protocol gate is load-bearing, not cosmetic.
+ it('rejects linear.app URLs on non-web protocols', () => {
+ for (const input of [
+ 'file://linear.app/acme/issue/ENG-123',
+ 'javascript://linear.app/acme/issue/ENG-123',
+ 'ftp://linear.app/acme/issue/ENG-123'
+ ]) {
+ expect(parseLinearIssueInput(input)).toBeNull()
+ }
+ })
+})
+
+describe('buildLinearIssueUrl', () => {
+ it('builds issue URLs from an identifier and organization key', () => {
+ expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: 'acme' })).toBe(
+ 'https://linear.app/acme/issue/STA-335'
+ )
+ })
+
+ it('returns null when either part is missing or blank', () => {
+ expect(buildLinearIssueUrl({ identifier: 'STA-335' })).toBeNull()
+ expect(buildLinearIssueUrl({ organizationUrlKey: 'acme' })).toBeNull()
+ expect(buildLinearIssueUrl({ identifier: ' ', organizationUrlKey: 'acme' })).toBeNull()
+ expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: null })).toBeNull()
+ })
+
+ it('encodes URL-unsafe path segments', () => {
+ expect(buildLinearIssueUrl({ identifier: 'A/B-1', organizationUrlKey: 'acme inc' })).toBe(
+ 'https://linear.app/acme%20inc/issue/A%2FB-1'
+ )
+ })
+})
+
+describe('buildLinearIssueLinkUpdates', () => {
+ function expectNoUndefinedValues(updates: Record): void {
+ expect(Object.keys(updates).filter((key) => updates[key] === undefined)).toEqual([])
+ }
+
+ it('clears every link field for empty input', () => {
+ for (const input of ['', ' ']) {
+ const result = buildLinearIssueLinkUpdates(input)
+ expect(result).toEqual({
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null
+ })
+ }
+ })
+
+ // Inheriting the previous issue's org key would let resolveLegacyLinearLinkWorkspace
+ // match it and persist a workspace id the new issue does not belong to.
+ it('clears the organization key for bare identifiers', () => {
+ const result = buildLinearIssueLinkUpdates('STA-335')
+
+ expect(result).toEqual({
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null
+ })
+ expectNoUndefinedValues(result as Record)
+ })
+
+ it('takes the organization key from a Linear issue URL', () => {
+ const result = buildLinearIssueLinkUpdates('https://linear.app/acme/issue/sta-335/fix-auth')
+
+ expect(result).toEqual({
+ linkedLinearIssue: 'STA-335',
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: 'acme'
+ })
+ expectNoUndefinedValues(result as Record)
+ })
+
+ it('returns null for unparseable input', () => {
+ expect(buildLinearIssueLinkUpdates('not an issue')).toBeNull()
+ expect(buildLinearIssueLinkUpdates('https://github.com/o/r/issues/12')).toBeNull()
+ })
})
diff --git a/src/shared/linear-links.ts b/src/shared/linear-links.ts
index 462ac853061..0f1db592736 100644
--- a/src/shared/linear-links.ts
+++ b/src/shared/linear-links.ts
@@ -24,6 +24,18 @@ export function buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string |
: 'https://linear.app/settings/api'
}
+export function buildLinearIssueUrl(args: {
+ identifier?: string | null
+ organizationUrlKey?: string | null
+}): string | null {
+ const identifier = args.identifier?.trim()
+ const organizationUrlKey = args.organizationUrlKey?.trim()
+ if (!identifier || !organizationUrlKey) {
+ return null
+ }
+ return `https://linear.app/${encodeURIComponent(organizationUrlKey)}/issue/${encodeURIComponent(identifier)}`
+}
+
export function getLinearOrganizationUrlKeyFromIssueUrl(issueUrl?: string | null): string | null {
if (!issueUrl) {
return null
@@ -44,6 +56,12 @@ export type ParsedLinearIssueInput = {
organizationUrlKey?: string
}
+export type LinearIssueLinkUpdates = {
+ linkedLinearIssue: string | null
+ linkedLinearIssueWorkspaceId: string | null
+ linkedLinearIssueOrganizationUrlKey: string | null
+}
+
const LINEAR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | null {
@@ -58,6 +76,12 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n
try {
const parsed = new URL(trimmed)
+ // Why: hostname alone accepts file://linear.app/..., which then passes
+ // validation and persists as a link. The GitHub parser already gates on
+ // protocol; this keeps the two in step.
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
+ return null
+ }
if (parsed.hostname !== 'linear.app') {
return null
}
@@ -80,3 +104,31 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n
return null
}
}
+
+export const LINEAR_ISSUE_LINK_CLEARED: LinearIssueLinkUpdates = {
+ linkedLinearIssue: null,
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: null
+}
+
+/** Shared by the CLI and the meta dialog so both persist identical state for
+ * identical input. Returns null when the input is neither empty nor parseable. */
+export function buildLinearIssueLinkUpdates(input: string): LinearIssueLinkUpdates | null {
+ if (input.trim() === '') {
+ return { ...LINEAR_ISSUE_LINK_CLEARED }
+ }
+
+ const parsed = parseLinearIssueInput(input)
+ if (!parsed) {
+ return null
+ }
+
+ return {
+ linkedLinearIssue: parsed.identifier,
+ // Both scoping fields describe the issue being REPLACED. A stale org key
+ // pins resolveLegacyLinearLinkWorkspace to the wrong org — not-found, or a
+ // silently wrong issue on a prefix collision. Nulling only costs a re-read.
+ linkedLinearIssueWorkspaceId: null,
+ linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
+ }
+}
From eca3ed72b6929fcefaa446257f513f8b3972b481 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:44:34 +0000
Subject: [PATCH 09/42] release: v1.4.169-rc.0
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 3a86ecfaf71..e8ec89daae0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.168-rc.1",
+ "version": "1.4.169-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From 2073f7eeb265684e0c1cef0306acfd6df105afab Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:45:50 -0700
Subject: [PATCH 10/42] fix(startup): omit Linux-only package mgrs from PATH on
non-Linux (#12566)
Snap and Linuxbrew don't ship installers for Darwin or BSD, so seeding
their PATH entries on those platforms adds phantom directories every
spawn must stat. Keep them on Linux only, while preserving Nix and
Homebrew across all platforms as they have multi-platform support.
---
src/main/startup/configure-process.test.ts | 60 ++++++++++++++++++++++
src/main/startup/configure-process.ts | 17 +++---
2 files changed, 68 insertions(+), 9 deletions(-)
diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts
index 512467eb724..eaf9f42aeec 100644
--- a/src/main/startup/configure-process.test.ts
+++ b/src/main/startup/configure-process.test.ts
@@ -77,6 +77,66 @@ describe('patchPackagedProcessPath', () => {
expect(segments).toContain(join('/Users/tester', 'bin'))
})
+ it('omits Linux-only snap/Linuxbrew dirs but keeps Nix on packaged darwin runs', async () => {
+ const { app } = await import('electron')
+ const { patchPackagedProcessPath } = await import('./configure-process')
+
+ setPlatform('darwin')
+ Object.defineProperty(app, 'isPackaged', { configurable: true, value: true })
+ process.env.HOME = '/Users/tester'
+ process.env.PATH = '/usr/bin:/bin'
+
+ patchPackagedProcessPath()
+
+ const segments = (process.env.PATH ?? '').split(':')
+ // Why: neither has a macOS installer, so both are phantom PATH entries.
+ expect(segments).not.toContain('/snap/bin')
+ expect(segments).not.toContain('/home/linuxbrew/.linuxbrew/bin')
+ // Why: Nix does ship a macOS default profile, so it stays seeded.
+ expect(segments).toContain('/nix/var/nix/profiles/default/bin')
+ expect(segments).toContain('/opt/homebrew/bin')
+ expect(segments).toContain('/usr/local/bin')
+ })
+
+ it('keeps snap, Linuxbrew, and Nix dirs for packaged linux runs', async () => {
+ const { app } = await import('electron')
+ const { patchPackagedProcessPath } = await import('./configure-process')
+
+ setPlatform('linux')
+ Object.defineProperty(app, 'isPackaged', { configurable: true, value: true })
+ process.env.HOME = '/home/tester'
+ process.env.PATH = '/usr/bin:/bin'
+
+ patchPackagedProcessPath()
+
+ const segments = (process.env.PATH ?? '').split(':')
+ expect(segments).toContain('/snap/bin')
+ expect(segments).toContain('/home/linuxbrew/.linuxbrew/bin')
+ expect(segments).toContain('/nix/var/nix/profiles/default/bin')
+ expect(segments.indexOf('/usr/local/sbin')).toBeLessThan(segments.indexOf('/snap/bin'))
+ expect(segments.indexOf('/home/linuxbrew/.linuxbrew/bin')).toBeLessThan(
+ segments.indexOf('/nix/var/nix/profiles/default/bin')
+ )
+ })
+
+ it('omits snap/Linuxbrew but keeps Nix on non-Linux POSIX platforms', async () => {
+ const { app } = await import('electron')
+ const { patchPackagedProcessPath } = await import('./configure-process')
+
+ setPlatform('freebsd')
+ Object.defineProperty(app, 'isPackaged', { configurable: true, value: true })
+ process.env.HOME = '/home/tester'
+ process.env.PATH = '/usr/bin:/bin'
+
+ patchPackagedProcessPath()
+
+ const segments = (process.env.PATH ?? '').split(':')
+ expect(segments).not.toContain('/snap/bin')
+ expect(segments).not.toContain('/home/linuxbrew/.linuxbrew/bin')
+ expect(segments).toContain('/nix/var/nix/profiles/default/bin')
+ expect(segments).toContain('/usr/local/bin')
+ })
+
it('leaves PATH untouched when the app is not packaged', async () => {
const { app } = await import('electron')
const { patchPackagedProcessPath } = await import('./configure-process')
diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts
index 292f6195258..e545d89da9c 100644
--- a/src/main/startup/configure-process.ts
+++ b/src/main/startup/configure-process.ts
@@ -99,15 +99,14 @@ export function patchPackagedProcessPath(): void {
const extraPaths: string[] = []
if (process.platform !== 'win32') {
- extraPaths.push(
- '/opt/homebrew/bin',
- '/opt/homebrew/sbin',
- '/usr/local/bin',
- '/usr/local/sbin',
- '/snap/bin',
- '/home/linuxbrew/.linuxbrew/bin',
- '/nix/var/nix/profiles/default/bin'
- )
+ extraPaths.push('/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/local/sbin')
+
+ if (process.platform === 'linux') {
+ // Why: snap and Linuxbrew ship on Linux only, so seeding them elsewhere adds phantom PATH entries every spawn must stat.
+ extraPaths.push('/snap/bin', '/home/linuxbrew/.linuxbrew/bin')
+ }
+
+ extraPaths.push('/nix/var/nix/profiles/default/bin')
if (home) {
extraPaths.push(
From d52df52eeaa7b3c88715d18987f8f59fe4dbc0f9 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 14:45:11 -0700
Subject: [PATCH 11/42] fix(mobile): open editor for disconnected hosts
(#12575)
---
mobile/app/index.tsx | 5 +-
.../home-host-edit-navigation-source.test.ts | 5 +-
.../transport/host-edit-navigation.test.ts | 104 ++++++++++++++----
mobile/src/transport/host-edit-navigation.ts | 74 ++++++++++++-
.../transport/use-open-mobile-host-edit.ts | 29 +++++
5 files changed, 187 insertions(+), 30 deletions(-)
create mode 100644 mobile/src/transport/use-open-mobile-host-edit.ts
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 57532a4a8ae..52f0647c872 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -16,7 +16,7 @@ import {
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
-import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
+import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
@@ -206,6 +206,7 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
+ const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
@@ -906,7 +907,7 @@ export default function HomeScreen() {
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
- onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
+ onEdit: openMobileHostEdit,
onRemove: setConfirmRemove
})}
onClose={() => setActionTarget(null)}
diff --git a/mobile/src/home-host-edit-navigation-source.test.ts b/mobile/src/home-host-edit-navigation-source.test.ts
index 2f6242f7ec1..4597a138e24 100644
--- a/mobile/src/home-host-edit-navigation-source.test.ts
+++ b/mobile/src/home-host-edit-navigation-source.test.ts
@@ -5,8 +5,7 @@ const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'u
describe('Home host edit navigation wiring', () => {
it('uses the cold-navigator-safe edit transition', () => {
- expect(homeSource).toMatch(
- /onEdit:\s*\(hostId\)\s*=>\s*navigateToMobileHostEdit\(router,\s*hostId\)/
- )
+ expect(homeSource).toMatch(/const openMobileHostEdit = useOpenMobileHostEdit\(\)/)
+ expect(homeSource).toMatch(/onEdit:\s*openMobileHostEdit/)
})
})
diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts
index 66ca63c5efa..2e3e8a9f5b2 100644
--- a/mobile/src/transport/host-edit-navigation.test.ts
+++ b/mobile/src/transport/host-edit-navigation.test.ts
@@ -1,29 +1,95 @@
import { describe, expect, it, vi } from 'vitest'
-import { mobileHostEditRoute, navigateToMobileHostEdit } from './host-edit-navigation'
+import {
+ mobileHostEditHostRoute,
+ mobileHostEditRoute,
+ navigateToMobileHostEdit,
+ type MobileHostEditNavigationState
+} from './host-edit-navigation'
-describe('mobileHostEditRoute', () => {
- it('keeps the dynamic host segment explicit for a cold host navigator', () => {
- expect(mobileHostEditRoute('host-1')).toEqual({
- pathname: '/h/[hostId]/edit',
- params: { hostId: 'host-1' }
- })
- })
+function navigationHarness(initialState: MobileHostEditNavigationState) {
+ let stateListener = () => {}
+ let state = initialState
+ const unsubscribeState = vi.fn()
+ const navigation = {
+ addListener: vi.fn((_event: 'state', listener: () => void) => {
+ stateListener = listener
+ return unsubscribeState
+ }),
+ getState: () => state
+ }
+ return {
+ navigation,
+ setState(nextState: MobileHostEditNavigationState) {
+ state = nextState
+ stateListener()
+ },
+ unsubscribeState
+ }
+}
- it('mounts a cold host navigator before replacing its index with edit', () => {
- let nextFrame: FrameRequestCallback | null = null
+describe('mobile host edit navigation', () => {
+ it('waits for the expected host route to commit before replacing it with Edit', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const replace = vi.fn()
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
- nextFrame = callback
- return 1
- })
- navigateToMobileHostEdit({ push, replace }, 'host-1')
- expect(push).toHaveBeenCalledWith('/h/host-1')
+ navigateToMobileHostEdit(harness.navigation, { push, replace }, 'host/1')
+
+ expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1'))
expect(replace).not.toHaveBeenCalled()
- nextFrame?.(0)
- expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host-1'))
- vi.unstubAllGlobals()
+ harness.setState({
+ index: 1,
+ routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
+ })
+
+ expect(harness.unsubscribeState).toHaveBeenCalledOnce()
+ expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host/1'))
+ })
+
+ it('does not replace an unrelated host route', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+ const replace = vi.fn()
+
+ navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace }, 'host-1')
+ harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
+
+ expect(replace).not.toHaveBeenCalled()
+ })
+
+ it('cancels a pending replacement when navigation leaves the host flow', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+ const replace = vi.fn()
+ const controller = navigateToMobileHostEdit(
+ harness.navigation,
+ { push: vi.fn(), replace },
+ 'host-1'
+ )
+
+ harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
+ controller.cancel()
+ harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
+
+ expect(harness.unsubscribeState).toHaveBeenCalledOnce()
+ expect(replace).not.toHaveBeenCalled()
+ })
+
+ it('unsubscribes when mounting the host throws synchronously', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+ const error = new Error('navigation failed')
+
+ expect(() =>
+ navigateToMobileHostEdit(
+ harness.navigation,
+ {
+ push: () => {
+ throw error
+ },
+ replace: vi.fn()
+ },
+ 'host-1'
+ )
+ ).toThrow(error)
+ expect(harness.unsubscribeState).toHaveBeenCalledOnce()
})
})
diff --git a/mobile/src/transport/host-edit-navigation.ts b/mobile/src/transport/host-edit-navigation.ts
index 19dbf703dec..765e36d7b5a 100644
--- a/mobile/src/transport/host-edit-navigation.ts
+++ b/mobile/src/transport/host-edit-navigation.ts
@@ -1,8 +1,31 @@
-type HostEditRouter = {
+export type MobileHostEditNavigationState = Readonly<{
+ index: number
+ routes: readonly MobileHostEditNavigationRoute[]
+}>
+
+export type MobileHostEditNavigationRoute = Readonly<{
+ name: string
+ params?: Readonly<{ hostId?: unknown }>
+}>
+
+export type MobileHostEditRootNavigation = {
+ addListener: (event: 'state', listener: () => void) => () => void
+ getState: () => MobileHostEditNavigationState
+}
+
+export type MobileHostEditRouter = {
push: (href: `/h/${string}`) => void
replace: (href: ReturnType) => void
}
+export type MobileHostEditNavigationController = Readonly<{
+ cancel: () => void
+}>
+
+export function mobileHostEditHostRoute(hostId: string): `/h/${string}` {
+ return `/h/${encodeURIComponent(hostId)}`
+}
+
export function mobileHostEditRoute(hostId: string) {
return {
pathname: '/h/[hostId]/edit' as const,
@@ -10,10 +33,49 @@ export function mobileHostEditRoute(hostId: string) {
}
}
-export function navigateToMobileHostEdit(router: HostEditRouter, hostId: string): void {
- // Why: a cold nested host navigator resolves a deep push to its index route.
- router.push(`/h/${hostId}`)
- requestAnimationFrame(() => {
+export function navigateToMobileHostEdit(
+ navigation: MobileHostEditRootNavigation,
+ router: MobileHostEditRouter,
+ hostId: string
+): MobileHostEditNavigationController {
+ let active = true
+ let hostRouteSeen = false
+ let unsubscribeState = () => {}
+ const dispose = () => {
+ if (!active) {
+ return
+ }
+ active = false
+ unsubscribeState()
+ }
+
+ // Why: cold Expo deep links resolve to index; target Edit after the host route commits.
+ const onState = () => {
+ if (!active) {
+ return
+ }
+ const state = navigation.getState()
+ const currentRoute = state.routes[state.index]
+ if (currentRoute?.name !== 'h') {
+ if (hostRouteSeen) {
+ dispose()
+ }
+ return
+ }
+ hostRouteSeen = true
+ if (currentRoute.params?.hostId !== hostId) {
+ return
+ }
+ dispose()
router.replace(mobileHostEditRoute(hostId))
- })
+ }
+
+ try {
+ unsubscribeState = navigation.addListener('state', onState)
+ router.push(mobileHostEditHostRoute(hostId))
+ } catch (error) {
+ dispose()
+ throw error
+ }
+ return { cancel: dispose }
}
diff --git a/mobile/src/transport/use-open-mobile-host-edit.ts b/mobile/src/transport/use-open-mobile-host-edit.ts
new file mode 100644
index 00000000000..68c20c5ae3b
--- /dev/null
+++ b/mobile/src/transport/use-open-mobile-host-edit.ts
@@ -0,0 +1,29 @@
+import { useCallback, useEffect, useRef } from 'react'
+import { useNavigation, useRouter } from 'expo-router'
+import {
+ navigateToMobileHostEdit,
+ type MobileHostEditNavigationController,
+ type MobileHostEditRootNavigation
+} from './host-edit-navigation'
+
+export function useOpenMobileHostEdit(): (hostId: string) => void {
+ const navigation = useNavigation()
+ const router = useRouter()
+ const pendingRef = useRef(null)
+
+ useEffect(
+ () => () => {
+ pendingRef.current?.cancel()
+ pendingRef.current = null
+ },
+ []
+ )
+
+ return useCallback(
+ (hostId) => {
+ pendingRef.current?.cancel()
+ pendingRef.current = navigateToMobileHostEdit(navigation, router, hostId)
+ },
+ [navigation, router]
+ )
+}
From 847c8c852d0af6cfd8da0a2cfbb8e6a38042dabf Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:03:57 -0700
Subject: [PATCH 12/42] fix(agent-status): correlate manual Claude compact
hooks (#12332)
Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
config/reliability-gates.jsonc | 114 ++++++
.../manual-compact-hook-stream.test.ts | 367 ++++++++++++++++++
.../manual-compact-status-cleanup.test.ts | 48 +++
src/main/agent-hooks/server.ts | 77 +++-
...ay-session-agent-hooks.integration.test.ts | 64 +++
src/main/ssh/ssh-relay-session.ts | 6 +
src/relay/agent-hook-server.ts | 12 +-
src/shared/agent-hook-listener.test.ts | 154 +++++++-
src/shared/agent-hook-listener.ts | 187 ++++++++-
src/shared/agent-hook-relay.test.ts | 11 +
src/shared/agent-hook-relay.ts | 49 ++-
.../harness-injected-user-turns.test.ts | 15 +-
src/shared/harness-injected-user-turns.ts | 34 +-
.../manual-compact-prompt-identity.test.ts | 235 +++++++++++
14 files changed, 1324 insertions(+), 49 deletions(-)
create mode 100644 src/main/agent-hooks/manual-compact-hook-stream.test.ts
create mode 100644 src/main/agent-hooks/manual-compact-status-cleanup.test.ts
create mode 100644 src/shared/manual-compact-prompt-identity.test.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index ad6fdd1550c..0a010442e1f 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -2338,6 +2338,120 @@
],
"demotionRule": "Demote or quarantine if the gate flakes without a product or harness bug, if a Pi handler can again await receiver I/O, or if coalescing allows an older status to overwrite a newer one."
},
+ {
+ "id": "agent-status.manual-compact-identity",
+ "title": "Manual Claude compact completion retires only its exact status generation",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "agent-session",
+ "layer": "shared-main-relay-contract",
+ "surfaces": [
+ "Claude status hooks",
+ "manual compact lifecycle",
+ "last-status persistence",
+ "SSH relay ingest"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "ssh"],
+ "coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Claude Code 2.1.220 captures prove manual PreCompact starts a new prompt UUID and exact PreCompact/PostCompact pairs share it. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/issues/11352",
+ "https://github.com/stablyai/orca/pull/11353"
+ ],
+ "invariant": "A Claude manual PreCompact may replace only a current Claude row from the same receiving connection and provider session; a manual PostCompact may mark done only when the current authoritative row is its exact manual PreCompact generation, including provider prompt UUID. Missing identity is never a wildcard; later cross-provider work, replay, duplication, pane or tab retirement, and server stop invalidate completion. Automatic Claude and Kimi completion remain fail-closed until their hooks expose independently proven generation identity.",
+ "oracle": "Run one byte-identical state-machine oracle plus local HTTP, relay-restart, SSH, and persisted-restore streams using Claude's observed different prior-turn and compact prompt UUIDs. Require matching manual completion to emit done while wrong or absent prompt UUID, session-presence mismatch, wrong source or connection, stale replay and duplicate delivery, later Codex work, automatic completion, and lifecycle cleanup cannot retire the current row. Assert both emitted event order and the authoritative lastStatusByPaneKey snapshot.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
+ "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id"
+ ],
+ "testFiles": [
+ "src/shared/manual-compact-prompt-identity.test.ts",
+ "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
+ "src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
+ "src/shared/agent-hook-listener.test.ts",
+ "src/shared/agent-hook-relay.test.ts",
+ "src/relay/agent-hook-server.test.ts",
+ "src/main/agent-hooks/server.test.ts",
+ "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/shared/manual-compact-prompt-identity.test.ts",
+ "assertions": [
+ "matching manual identity settles once",
+ "stale, duplicate, session-mismatched, malformed, automatic, and unproven provider completion fail closed",
+ "4,097 rejected pane keys allocate no ownership state"
+ ]
+ },
+ {
+ "file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
+ "assertions": [
+ "local HTTP and relay-restart streams preserve the distinct prior-turn and compact prompt identities",
+ "later provider work and wrong transport identity remain authoritative",
+ "persisted PreCompact restores its prompt"
+ ]
+ },
+ {
+ "file": "src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
+ "assertions": ["pane, tab, and server cleanup revoke compact completion authority"]
+ },
+ {
+ "file": "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts",
+ "assertions": [
+ "SSH stamps the receiving connection and forwards source, prompt UUID, session, and manual trigger"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-02",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
+ "result": "passed",
+ "durationSeconds": 2.66,
+ "summary": "Eight focused files and 435 tests passed with local HTTP, relay, SSH, persistence, adversarial identity, cleanup, and existing provider-regression coverage."
+ },
+ {
+ "date": "2026-08-02",
+ "runner": "manual",
+ "platform": "macos",
+ "command": "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id",
+ "result": "passed",
+ "durationSeconds": 120,
+ "summary": "A fresh capture proved /compact emits no UserPromptSubmit and its PreCompact uses a new UUID distinct from the preceding user turn; the preserved authenticated capture proved the matching manual PreCompact and PostCompact share that UUID and session. Automatic capture reused one prompt UUID across multiple compact generations, so automatic completion remains disabled."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 20,
+ "scope": "shared, main-process, relay, persistence, and fake-SSH contract tests"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New deterministic gate with one local run; CI soak history is not yet available."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The byte-identical oracle (SHA-256 24e80c15b467ffa9c47a81099f11bf26dea71293e47b5d950b2fad1497094764) failed 5 of 6 on origin/main@a20165a43d, passed 6 of 6 on the candidate, failed 3 of 6 when only the exact-transition guard was disabled, and passed 6 of 6 after restoration."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Each compact event performs one O(1) current-status lookup plus bounded UUID, source, connection, session, and event comparisons. Identity lives on the existing status row and is removed by its existing lifecycle; the change adds no owner map, global scan, polling, timer, subprocess, network request, or listener. The 4,097-key adversarial arm retains zero status, prompt, or ownership entries."
+ },
+ "promotionCriteria": [
+ "Collect 100 consecutive focused CI passes or 14 days of soak history.",
+ "Run live manual compact journeys on Linux and Windows and through a real SSH target.",
+ "Keep automatic compact completion disabled until a generation-unique provider identity is proven."
+ ],
+ "knownGaps": [
+ "No live Linux, Windows, WSL, paired-runtime, or real-SSH manual compact journey was run.",
+ "Claude versions before prompt_id support intentionally cannot complete a manual compact row through PostCompact.",
+ "Automatic Claude and Kimi completion remain intentionally fail-closed because their hooks do not prove an exact compact generation."
+ ],
+ "demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug."
+ },
{
"id": "agent-session.provider-ownership",
"title": "Provider sessions are resumed once per workspace ownership claim",
diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts
new file mode 100644
index 00000000000..22b1b0605c2
--- /dev/null
+++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts
@@ -0,0 +1,367 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { RelayAgentHookServer } from '../../relay/agent-hook-server'
+import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay'
+import { makePaneKey } from '../../shared/stable-pane-id'
+import { AgentHookServer } from './server'
+
+vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
+vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
+
+const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111')
+const PROMPT_ID_1 = '22222222-2222-4222-8222-222222222222'
+const PROMPT_ID_2 = '33333333-3333-4333-8333-333333333333'
+const SESSION = { key: 'session_id' as const, id: 'session-a' }
+
+type ClaudeHook = {
+ hook_event_name: string
+ prompt?: string
+ prompt_id: string
+ session_id: string
+ trigger?: 'manual' | 'auto'
+ agent_id?: string
+ agent_type?: string
+}
+
+function claudeHook(
+ hookEventName: string,
+ promptId: string,
+ extra: Partial = {}
+): ClaudeHook {
+ return {
+ hook_event_name: hookEventName,
+ prompt_id: promptId,
+ session_id: 'session-a',
+ ...extra
+ }
+}
+
+function manualEnvelope(hookEventName: string, state: 'working' | 'done') {
+ return {
+ source: 'claude' as const,
+ paneKey: PANE_KEY,
+ hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
+ hookEventName,
+ providerPromptId: hookEventName === 'UserPromptSubmit' ? PROMPT_ID_2 : PROMPT_ID_1,
+ compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const),
+ providerSession: SESSION,
+ payload: { state, prompt: 'work before compact', agentType: 'claude' as const }
+ }
+}
+
+function postHook(port: number, token: string, payload: Record) {
+ return fetch(`http://127.0.0.1:${port}/hook/claude`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Orca-Agent-Hook-Token': token
+ },
+ body: JSON.stringify({ paneKey: PANE_KEY, payload })
+ })
+}
+
+describe('manual Claude compact hook stream', () => {
+ const servers: { stop: () => void }[] = []
+ const temporaryPaths: string[] = []
+
+ afterEach(() => {
+ for (const server of servers.splice(0)) {
+ server.stop()
+ }
+ for (const path of temporaryPaths.splice(0)) {
+ rmSync(path, { recursive: true, force: true })
+ }
+ })
+
+ it('settles an exact local HTTP lifecycle and rejects a duplicate completion', async () => {
+ const server = new AgentHookServer()
+ servers.push(server)
+ await server.start({ env: 'production' })
+ const env = server.buildPtyEnv()
+ const events: string[] = []
+ const unsubscribe = server.subscribeEnrichedStatus((event) => {
+ events.push(`${event.hookEventName}:${event.payload.state}`)
+ })
+
+ await postHook(
+ Number(env.ORCA_AGENT_HOOK_PORT),
+ env.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
+ )
+ await postHook(
+ Number(env.ORCA_AGENT_HOOK_PORT),
+ env.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
+ )
+ await postHook(
+ Number(env.ORCA_AGENT_HOOK_PORT),
+ env.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('SubagentStart', PROMPT_ID_1, {
+ agent_id: 'compact-agent',
+ agent_type: 'general-purpose'
+ })
+ )
+ await postHook(
+ Number(env.ORCA_AGENT_HOOK_PORT),
+ env.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('SubagentStop', PROMPT_ID_1, {
+ agent_id: 'compact-agent',
+ agent_type: 'general-purpose'
+ })
+ )
+ await postHook(
+ Number(env.ORCA_AGENT_HOOK_PORT),
+ env.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
+ )
+ await postHook(
+ Number(env.ORCA_AGENT_HOOK_PORT),
+ env.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
+ )
+
+ expect(events).toEqual([
+ 'UserPromptSubmit:working',
+ 'PreCompact:working',
+ 'SubagentStart:working',
+ 'SubagentStop:working',
+ 'PostCompact:done'
+ ])
+ expect(server.getStatusSnapshot()).toEqual([
+ expect.objectContaining({
+ state: 'done',
+ prompt: 'work before compact',
+ agentType: 'claude'
+ })
+ ])
+ unsubscribe()
+ })
+
+ it('preserves the manual identity over relay and rejects stale transport identities', async () => {
+ const main = new AgentHookServer()
+ const forwarded: AgentHookRelayEnvelope[] = []
+ const emitted: string[] = []
+ const connectionId = 'conn-a'
+ const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-relay-'))
+ temporaryPaths.push(endpointDir)
+ const relay = new RelayAgentHookServer({
+ endpointDir,
+ token: 'manual-compact-token',
+ forward: (envelope) => {
+ forwarded.push(envelope)
+ main.ingestRemote(envelope, connectionId)
+ }
+ })
+ servers.push(main, relay)
+ const unsubscribe = main.subscribeEnrichedStatus((event) => {
+ emitted.push(`${event.hookEventName}:${event.payload.state}`)
+ })
+ await relay.start({ publishEndpoint: false })
+ const coordinates = relay.getCoordinates()
+
+ await postHook(
+ coordinates.port,
+ coordinates.token,
+ claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
+ )
+ // A restarted relay has no prior listener cache; main still owns the status boundary.
+ relay.clearPaneState(PANE_KEY)
+ await postHook(
+ coordinates.port,
+ coordinates.token,
+ claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
+ )
+ const validPost = {
+ ...forwarded.at(-1)!,
+ hookEventName: 'PostCompact',
+ payload: {
+ state: 'done' as const,
+ prompt: 'work before compact',
+ agentType: 'claude' as const
+ }
+ }
+
+ main.ingestRemote({ ...validPost, providerPromptId: PROMPT_ID_2 }, 'conn-a')
+ main.ingestRemote({ ...validPost, providerPromptId: undefined }, 'conn-a')
+ main.ingestRemote({ ...validPost, providerSession: undefined }, 'conn-a')
+ main.ingestRemote({ ...validPost, source: 'codex' }, 'conn-a')
+ main.ingestRemote(validPost, 'conn-b')
+
+ expect(emitted).toEqual(['UserPromptSubmit:working', 'PreCompact:working'])
+ expect(main.getStatusSnapshot()[0]).toMatchObject({
+ state: 'working',
+ prompt: 'work before compact'
+ })
+
+ await postHook(
+ coordinates.port,
+ coordinates.token,
+ claudeHook('SubagentStart', PROMPT_ID_1, {
+ agent_id: 'compact-agent',
+ agent_type: 'general-purpose'
+ })
+ )
+ await postHook(
+ coordinates.port,
+ coordinates.token,
+ claudeHook('SubagentStop', PROMPT_ID_1, {
+ agent_id: 'compact-agent',
+ agent_type: 'general-purpose'
+ })
+ )
+ await postHook(
+ coordinates.port,
+ coordinates.token,
+ claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
+ )
+
+ expect(forwarded.at(-1)).toMatchObject({
+ source: 'claude',
+ providerPromptId: PROMPT_ID_1,
+ compactTrigger: 'manual',
+ providerSession: SESSION,
+ hookEventName: 'PostCompact'
+ })
+ expect(emitted.at(-1)).toBe('PostCompact:done')
+ expect(emitted.slice(-3)).toEqual([
+ 'SubagentStart:working',
+ 'SubagentStop:working',
+ 'PostCompact:done'
+ ])
+ expect(main.getStatusSnapshot()[0]).toMatchObject({
+ state: 'done',
+ prompt: 'work before compact'
+ })
+ unsubscribe()
+ })
+
+ it('invalidates a compact completion when later provider work owns the pane', () => {
+ const server = new AgentHookServer()
+ servers.push(server)
+ server.ingestRemote(manualEnvelope('UserPromptSubmit', 'working'), 'conn-a')
+ server.ingestRemote(manualEnvelope('PreCompact', 'working'), 'conn-a')
+ server.ingestRemote(
+ {
+ source: 'codex',
+ paneKey: PANE_KEY,
+ hookEventName: 'UserPromptSubmit',
+ hasExplicitPrompt: true,
+ payload: { state: 'working', prompt: 'new work', agentType: 'codex' }
+ },
+ 'conn-a'
+ )
+ server.ingestRemote({ ...manualEnvelope('PreCompact', 'working'), isReplay: true }, 'conn-a')
+ server.ingestRemote(manualEnvelope('PostCompact', 'done'), 'conn-a')
+
+ expect(server.getStatusSnapshot()[0]).toMatchObject({
+ state: 'working',
+ prompt: 'new work',
+ agentType: 'claude'
+ })
+ expect(server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
+ source: 'codex',
+ hookEventName: 'UserPromptSubmit'
+ })
+ })
+
+ it('keeps automatic compact hooks working without settling the turn', async () => {
+ const main = new AgentHookServer()
+ const forwarded: AgentHookRelayEnvelope[] = []
+ const endpointDir = mkdtempSync(join(tmpdir(), 'orca-auto-compact-relay-'))
+ temporaryPaths.push(endpointDir)
+ const relay = new RelayAgentHookServer({
+ endpointDir,
+ forward: (envelope) => {
+ forwarded.push(envelope)
+ main.ingestRemote(envelope, 'conn-auto')
+ }
+ })
+ servers.push(main, relay)
+ await relay.start({ publishEndpoint: false })
+ const { port, token } = relay.getCoordinates()
+
+ await postHook(
+ port,
+ token,
+ claudeHook('UserPromptSubmit', PROMPT_ID_1, { prompt: 'continue the task' })
+ )
+ await postHook(port, token, claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'auto' }))
+ await postHook(port, token, claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'auto' }))
+
+ expect(forwarded.map((event) => event.hookEventName)).toEqual([
+ 'UserPromptSubmit',
+ 'PreCompact',
+ 'PostCompact'
+ ])
+ expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'working', agentType: 'claude' })
+ expect(main._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
+ hookEventName: 'PostCompact',
+ compactTrigger: undefined
+ })
+ })
+
+ it('rejects unproven compact sources at the main relay boundary', () => {
+ const server = new AgentHookServer()
+ servers.push(server)
+ server.ingestRemote(
+ {
+ ...manualEnvelope('PreCompact', 'working'),
+ source: 'kimi',
+ payload: { state: 'working', prompt: '', agentType: 'kimi' }
+ },
+ 'conn-kimi'
+ )
+ server.ingestRemote(
+ { ...manualEnvelope('PreCompact', 'working'), compactTrigger: undefined },
+ 'conn-auto'
+ )
+
+ expect(server.getStatusSnapshot()).toEqual([])
+ expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
+ })
+
+ it('hydrates a manual PreCompact identity and accepts only its exact local completion', async () => {
+ const userDataPath = mkdtempSync(join(tmpdir(), 'orca-compact-restore-'))
+ temporaryPaths.push(userDataPath)
+ const first = new AgentHookServer()
+ servers.push(first)
+ await first.start({ env: 'production', userDataPath })
+ const firstEnv = first.buildPtyEnv()
+ await postHook(
+ Number(firstEnv.ORCA_AGENT_HOOK_PORT),
+ firstEnv.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
+ )
+ await postHook(
+ Number(firstEnv.ORCA_AGENT_HOOK_PORT),
+ firstEnv.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
+ )
+ first.stop()
+
+ const restored = new AgentHookServer()
+ servers.push(restored)
+ await restored.start({ env: 'production', userDataPath })
+ const restoredEnv = restored.buildPtyEnv()
+ await postHook(
+ Number(restoredEnv.ORCA_AGENT_HOOK_PORT),
+ restoredEnv.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('PostCompact', PROMPT_ID_2, { trigger: 'manual' })
+ )
+ expect(restored.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
+
+ await postHook(
+ Number(restoredEnv.ORCA_AGENT_HOOK_PORT),
+ restoredEnv.ORCA_AGENT_HOOK_TOKEN,
+ claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
+ )
+ expect(restored.getStatusSnapshot()[0]).toMatchObject({
+ state: 'done',
+ prompt: 'work before compact',
+ agentType: 'claude'
+ })
+ })
+})
diff --git a/src/main/agent-hooks/manual-compact-status-cleanup.test.ts b/src/main/agent-hooks/manual-compact-status-cleanup.test.ts
new file mode 100644
index 00000000000..71df7f2dfb3
--- /dev/null
+++ b/src/main/agent-hooks/manual-compact-status-cleanup.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { makePaneKey } from '../../shared/stable-pane-id'
+import { AgentHookServer } from './server'
+
+vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
+vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
+
+const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111')
+
+function compactEvent(hookEventName: string, state: 'working' | 'done') {
+ return {
+ source: 'claude' as const,
+ paneKey: PANE_KEY,
+ hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
+ hookEventName,
+ providerPromptId:
+ hookEventName === 'UserPromptSubmit'
+ ? '22222222-2222-4222-8222-222222222222'
+ : '33333333-3333-4333-8333-333333333333',
+ compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const),
+ providerSession: { key: 'session_id' as const, id: 'session-a' },
+ payload: { state, prompt: 'work before compact', agentType: 'claude' as const }
+ }
+}
+
+describe('manual compact status cleanup', () => {
+ it('retires authority with pane, tab, and server cleanup', () => {
+ const server = new AgentHookServer()
+ const begin = (): void => {
+ server.ingestRemote(compactEvent('UserPromptSubmit', 'working'), 'conn-a')
+ server.ingestRemote(compactEvent('PreCompact', 'working'), 'conn-a')
+ }
+
+ begin()
+ server.clearPaneState(PANE_KEY)
+ server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a')
+ expect(server.getStatusSnapshot()).toEqual([])
+
+ begin()
+ server.dropStatusEntriesByTabPrefix('manual-compact')
+ server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a')
+ expect(server.getStatusSnapshot()).toEqual([])
+
+ server.stop()
+ expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
+ })
+})
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index 9bf8fc43f29..0f9e0a7812d 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -22,11 +22,14 @@ import {
markCodexLeadTurnInterrupted,
MAX_PANE_KEY_LEN,
movePaneCacheState,
+ canAcceptClaudeCompactTransition,
+ normalizeClaudePromptId,
normalizeHookPayload,
parseFormEncodedBody,
readRequestBody,
reapRestoredClaudeSubagentsForDeadPane,
reconcileRemoteCodexState,
+ resolveCachedClaudeCompactOwnership,
resolveHookSource,
preparePendingGrokResultDiscovery,
seedClaudeSubagentRosterFromSnapshots,
@@ -41,7 +44,11 @@ import {
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../shared/claude-subagent-roster'
-import { restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay'
+import {
+ isAgentHookSource,
+ restoreShedStatusFields,
+ type AgentHookSource
+} from '../../shared/agent-hook-relay'
import {
CLAUDE_STATUSLINE_PATHNAME,
parseClaudeStatusLineBody,
@@ -293,13 +300,23 @@ function sanitizeHydratedEntry(
if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) {
return null
}
+ const source = isAgentHookSource(record.source) ? record.source : undefined
+ const providerPromptId =
+ source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined
+ const compactTrigger =
+ source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto')
+ ? record.compactTrigger
+ : undefined
return {
paneKey,
+ source,
tabId: typeof tabId === 'string' ? tabId : undefined,
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
connectionId,
hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined,
hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined,
+ providerPromptId,
+ compactTrigger,
toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined,
toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined,
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
@@ -1236,7 +1253,8 @@ export class AgentHookServer {
if (!identity.inheritedFromActivePane) {
this.maybeTrackAgentPromptSent(effectivePayload, previous)
}
- const enriched = this.attachStatusTiming(effectivePayload, now)
+ const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload)
+ const enriched = this.attachStatusTiming(cachedPayload, now)
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
@@ -1834,6 +1852,9 @@ export class AgentHookServer {
hasExplicitPrompt?: boolean
promptInteractionKey?: string
hookEventName?: string
+ source?: unknown
+ providerPromptId?: unknown
+ compactTrigger?: unknown
toolUseId?: string
toolAgentId?: string
toolAgentType?: string
@@ -1895,6 +1916,14 @@ export class AgentHookServer {
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
+ const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
+ const providerPromptId =
+ source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined
+ const compactTrigger =
+ source === 'claude' &&
+ (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto')
+ ? envelope.compactTrigger
+ : undefined
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
hookEventName,
isReplay: envelope.isReplay === true
@@ -1930,11 +1959,50 @@ export class AgentHookServer {
return
}
// Why: restore a shed roster only when its digest and turn identity still match the cache.
- const normalizedPayload = restoreShedStatusFields(
+ let normalizedPayload = restoreShedStatusFields(
validatedPayload,
envelope.shedFields,
this.state.lastStatusByPaneKey.get(paneKey)?.payload
)
+ const previousStatus = this.state.lastStatusByPaneKey.get(paneKey)
+ if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') {
+ if (
+ source !== 'claude' ||
+ compactTrigger === undefined ||
+ normalizedPayload.agentType !== source
+ ) {
+ return
+ }
+ if (
+ hookEventName === 'PreCompact' &&
+ envelope.isReplay === true &&
+ (previousStatus?.hookEventName !== 'PreCompact' ||
+ previousStatus.compactTrigger !== compactTrigger ||
+ previousStatus.providerPromptId !== providerPromptId)
+ ) {
+ return
+ }
+ if (
+ !canAcceptClaudeCompactTransition(previousStatus, {
+ source,
+ connectionId: trimmedConnectionId,
+ hookEventName,
+ providerPromptId,
+ compactTrigger,
+ providerSession
+ })
+ ) {
+ return
+ }
+ }
+ if (
+ source === 'claude' &&
+ compactTrigger !== undefined &&
+ normalizedPayload.prompt.length === 0 &&
+ previousStatus?.payload.prompt
+ ) {
+ normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt }
+ }
if (
envelope.providerSessionOnly === true &&
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
@@ -1954,6 +2022,7 @@ export class AgentHookServer {
})
const event: AgentHookEventPayload = {
paneKey,
+ source,
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
tabId,
worktreeId,
@@ -1961,6 +2030,8 @@ export class AgentHookServer {
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
promptInteractionKey,
hookEventName,
+ providerPromptId,
+ compactTrigger,
toolUseId,
toolAgentId,
toolAgentType,
diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts
index f92b72b09a8..1a384a32f72 100644
--- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts
+++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts
@@ -40,6 +40,8 @@ const { SshRelaySession } = await import('./ssh-relay-session')
const SSH_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const REPLAY_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const BAD_LEAF_ID = '33333333-3333-4333-8333-333333333333'
+const COMPACT_PROMPT_ID = '44444444-4444-4444-8444-444444444444'
+const PREVIOUS_PROMPT_ID = '55555555-5555-4555-8555-555555555555'
type CapturedStatus = {
paneKey: string
@@ -295,6 +297,68 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
})
})
+ it('stamps SSH ownership and settles only the exact manual compact identity', async () => {
+ relay = createFakeRelay()
+ vi.mocked(deployAndLaunchRelay).mockResolvedValue({
+ transport: relay.transport,
+ serverBuildId: 'test-relay-build',
+ platform: 'linux-x64'
+ })
+ const events: CapturedStatus[] = []
+ captureAgentStatuses(events)
+ session = createSession('conn-compact')
+ await session.establish({} as SshConnection)
+
+ const compactEnvelope = (
+ hookEventName: 'UserPromptSubmit' | 'PreCompact' | 'PostCompact',
+ state: 'working' | 'done'
+ ): AgentHookRelayEnvelope =>
+ makeEnvelope({
+ source: 'claude',
+ hookEventName,
+ providerPromptId:
+ hookEventName === 'UserPromptSubmit' ? PREVIOUS_PROMPT_ID : COMPACT_PROMPT_ID,
+ compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : 'manual',
+ providerSession: { key: 'session_id', id: 'claude-session' },
+ hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
+ payload: {
+ state,
+ prompt: 'work before compact',
+ agentType: 'claude'
+ }
+ })
+
+ relay.notifyAgentHook(compactEnvelope('UserPromptSubmit', 'working'))
+ relay.notifyAgentHook(compactEnvelope('PreCompact', 'working'))
+ await waitForStatusCount(events, 2)
+ relay.notifyAgentHook({
+ ...compactEnvelope('PostCompact', 'done'),
+ providerPromptId: undefined
+ })
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(events).toHaveLength(2)
+
+ relay.notifyAgentHook(compactEnvelope('PostCompact', 'done'))
+ await waitForStatusCount(events, 3)
+
+ expect(events.at(-1)).toMatchObject({
+ connectionId: 'conn-compact',
+ payload: { state: 'done', prompt: 'work before compact', agentType: 'claude' }
+ })
+ expect(
+ agentHookServer._getStateForTests().lastStatusByPaneKey.values().next().value
+ ).toMatchObject({
+ source: 'claude',
+ providerPromptId: COMPACT_PROMPT_ID,
+ compactTrigger: undefined,
+ connectionId: 'conn-compact'
+ })
+
+ relay.notifyAgentHook(compactEnvelope('PostCompact', 'done'))
+ await new Promise((resolve) => setImmediate(resolve))
+ expect(events).toHaveLength(3)
+ })
+
it('clears stamped status on reconnect loss but not final shutdown', async () => {
const initialRelay = createFakeRelay()
relay = createFakeRelay()
diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts
index 3f1c5105d29..34bdf498843 100644
--- a/src/main/ssh/ssh-relay-session.ts
+++ b/src/main/ssh/ssh-relay-session.ts
@@ -1342,6 +1342,9 @@ export class SshRelaySession {
hasExplicitPrompt?: unknown
promptInteractionKey?: unknown
hookEventName?: unknown
+ source?: unknown
+ providerPromptId?: unknown
+ compactTrigger?: unknown
toolUseId?: unknown
toolAgentId?: unknown
toolAgentType?: unknown
@@ -1371,6 +1374,9 @@ export class SshRelaySession {
: undefined,
hookEventName:
typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined,
+ source: envelope.source,
+ providerPromptId: envelope.providerPromptId,
+ compactTrigger: envelope.compactTrigger,
toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined,
toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined,
toolAgentType:
diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts
index a89b9d60c2b..3166c70a2f5 100644
--- a/src/relay/agent-hook-server.ts
+++ b/src/relay/agent-hook-server.ts
@@ -20,6 +20,7 @@ import {
normalizeHookPayload,
preparePendingGrokResultDiscovery,
readRequestBody,
+ resolveCachedClaudeCompactOwnership,
resolveHookSource,
writeEndpointFile,
type AgentHookEventPayload,
@@ -275,7 +276,10 @@ export class RelayAgentHookServer {
res.end()
return
}
- const event = normalizeHookPayload(this.state, source, body, this.env)
+ const event = normalizeHookPayload(this.state, source, body, this.env, {
+ allowUnanchoredPreCompact: true,
+ allowUnanchoredPostCompact: true
+ })
if (event) {
// TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result.
const env = this.bodyEnv(body)
@@ -313,6 +317,8 @@ export class RelayAgentHookServer {
hasExplicitPrompt: event.hasExplicitPrompt,
promptInteractionKey: event.promptInteractionKey,
hookEventName: event.hookEventName,
+ providerPromptId: event.providerPromptId,
+ compactTrigger: event.compactTrigger,
toolUseId: event.toolUseId,
toolAgentId: event.toolAgentId,
toolAgentType: event.toolAgentType,
@@ -336,9 +342,11 @@ export class RelayAgentHookServer {
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(event.paneKey)
}
+ const previous = this.state.lastStatusByPaneKey.get(event.paneKey)
+ const cachedEvent = resolveCachedClaudeCompactOwnership(previous, event)
// Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane.
this.state.lastStatusByPaneKey.delete(event.paneKey)
- this.state.lastStatusByPaneKey.set(event.paneKey, event)
+ this.state.lastStatusByPaneKey.set(event.paneKey, cachedEvent)
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts
index 098ceeff9a2..73c1aae3a6d 100644
--- a/src/shared/agent-hook-listener.test.ts
+++ b/src/shared/agent-hook-listener.test.ts
@@ -32,6 +32,20 @@ import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
+const CLAUDE_PROMPT_ID = '22222222-2222-4222-8222-222222222222'
+const CLAUDE_PREVIOUS_PROMPT_ID = '33333333-3333-4333-8333-333333333333'
+
+function normalizeAndAccept(
+ state: HookListenerState,
+ source: Parameters[1],
+ payload: Record
+): ReturnType {
+ const event = normalizeHookPayload(state, source, { paneKey: PANE_KEY, payload }, 'production')
+ if (event) {
+ state.lastStatusByPaneKey.set(PANE_KEY, event)
+ }
+ return event
+}
type FakeIncomingMessage = EventEmitter & {
headers: IncomingHttpHeaders
@@ -1452,6 +1466,69 @@ describe('shared agent-hook-listener', () => {
expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' })
})
+ // Why: Kimi shares Claude-compatible compact/harness hooks; cover the same sticky-working
+ // guards so a Kimi-only regression cannot slip past the Claude-only tests (issue #11352).
+ it('ignores harness-injected UserPromptSubmit for Kimi', () => {
+ normalizeHookPayload(
+ state,
+ 'kimi',
+ {
+ paneKey: PANE_KEY,
+ payload: {
+ hook_event_name: 'UserPromptSubmit',
+ prompt: [{ type: 'text', text: 'list the files here' }]
+ }
+ },
+ 'production'
+ )
+ const harness = normalizeHookPayload(
+ state,
+ 'kimi',
+ {
+ paneKey: PANE_KEY,
+ payload: {
+ hook_event_name: 'UserPromptSubmit',
+ prompt:
+ 'This session is being continued from a previous conversation that ran out of context.'
+ }
+ },
+ 'production'
+ )
+ expect(harness).toBeNull()
+ const tool = normalizeHookPayload(
+ state,
+ 'kimi',
+ {
+ paneKey: PANE_KEY,
+ payload: {
+ hook_event_name: 'PreToolUse',
+ tool_name: 'Bash',
+ tool_input: { command: 'ls' }
+ }
+ },
+ 'production'
+ )
+ expect(tool).not.toBeNull()
+ expect(tool!.payload.state).toBe('working')
+ expect(tool!.payload.prompt).toBe('list the files here')
+ expect(tool!.payload.agentType).toBe('kimi')
+ })
+
+ it('ignores unproven Kimi compact lifecycle events', () => {
+ const pre = normalizeAndAccept(state, 'kimi', {
+ hook_event_name: 'PreCompact',
+ trigger: 'manual'
+ })
+ const post = normalizeAndAccept(state, 'kimi', {
+ hook_event_name: 'PostCompact',
+ trigger: 'manual'
+ })
+
+ expect(pre).toBeNull()
+ expect(post).toBeNull()
+ expect(state.lastStatusByPaneKey.has(PANE_KEY)).toBe(false)
+ })
+
it('normalizes MiMo Code OpenCode-compatible lifecycle events as mimo-code status', () => {
const message = normalizeHookPayload(
state,
@@ -1563,15 +1640,13 @@ describe('shared agent-hook-listener', () => {
expect(event).toBeNull()
})
- it('keeps the cached prompt when a harness-injected turn fires UserPromptSubmit', () => {
+ it('resumes work for task notifications without replacing the cached prompt', () => {
normalizeHookPayload(
state,
'claude',
{ paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'fix login' } },
'production'
)
- // Why: the harness injects background task notifications as user turns;
- // they must not replace the user's real prompt in status labels.
const event = normalizeHookPayload(
state,
'claude',
@@ -1590,7 +1665,7 @@ describe('shared agent-hook-listener', () => {
expect(event!.hasExplicitPrompt).toBe(false)
})
- it('resolves an empty prompt for a harness-injected turn with nothing cached', () => {
+ it('emits a harness-injected UserPromptSubmit with an empty uncached prompt', () => {
const event = normalizeHookPayload(
state,
'claude',
@@ -1604,10 +1679,81 @@ describe('shared agent-hook-listener', () => {
'production'
)
expect(event).not.toBeNull()
+ expect(event!.payload.state).toBe('working')
expect(event!.payload.prompt).toBe('')
expect(event!.hasExplicitPrompt).toBe(false)
})
+ it('does not leave working after a compact-summary UserPromptSubmit (issue #11352)', () => {
+ // Live repro: after /compact Claude injects "This session is being continued…" with no Stop.
+ const event = normalizeHookPayload(
+ state,
+ 'claude',
+ {
+ paneKey: PANE_KEY,
+ payload: {
+ hook_event_name: 'UserPromptSubmit',
+ prompt:
+ 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.'
+ }
+ },
+ 'production'
+ )
+ expect(event).toBeNull()
+ })
+
+ it('maps an identity-matched Claude manual compact lifecycle', () => {
+ normalizeAndAccept(state, 'claude', {
+ hook_event_name: 'UserPromptSubmit',
+ prompt: 'work before compact',
+ prompt_id: CLAUDE_PREVIOUS_PROMPT_ID,
+ session_id: 'session-a'
+ })
+ const pre = normalizeAndAccept(state, 'claude', {
+ hook_event_name: 'PreCompact',
+ trigger: 'manual',
+ prompt_id: CLAUDE_PROMPT_ID,
+ session_id: 'session-a'
+ })
+ expect(pre).not.toBeNull()
+ expect(pre!.payload.state).toBe('working')
+ expect(pre!.payload.agentType).toBe('claude')
+
+ const post = normalizeAndAccept(state, 'claude', {
+ hook_event_name: 'PostCompact',
+ trigger: 'manual',
+ prompt_id: CLAUDE_PROMPT_ID,
+ session_id: 'session-a'
+ })
+ expect(post).not.toBeNull()
+ expect(post!.payload.state).toBe('done')
+ expect(post!.payload.agentType).toBe('claude')
+ })
+
+ it('keeps the preceding user prompt on the completed compact row', () => {
+ normalizeAndAccept(state, 'claude', {
+ hook_event_name: 'UserPromptSubmit',
+ prompt: 'work before compact',
+ prompt_id: CLAUDE_PREVIOUS_PROMPT_ID,
+ session_id: 'session-a'
+ })
+ normalizeAndAccept(state, 'claude', {
+ hook_event_name: 'PreCompact',
+ trigger: 'manual',
+ prompt_id: CLAUDE_PROMPT_ID,
+ session_id: 'session-a'
+ })
+ const post = normalizeAndAccept(state, 'claude', {
+ hook_event_name: 'PostCompact',
+ trigger: 'manual',
+ prompt_id: CLAUDE_PROMPT_ID,
+ session_id: 'session-a'
+ })
+ expect(post).not.toBeNull()
+ expect(post!.payload.state).toBe('done')
+ expect(post!.payload.prompt).toBe('work before compact')
+ })
+
it('treats a custom-element paste as an explicit user turn, not machinery', () => {
normalizeHookPayload(
state,
diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts
index a9404299901..985f95b1876 100644
--- a/src/shared/agent-hook-listener.ts
+++ b/src/shared/agent-hook-listener.ts
@@ -55,11 +55,15 @@ import {
import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types'
import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay'
import {
+ agentProviderSessionsEqual,
extractAgentProviderSession,
type AgentProviderSessionMetadata
} from './agent-session-resume'
import { parsePaneKey } from './stable-pane-id'
-import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns'
+import {
+ isCompactContinuationUserTurnText,
+ isKnownHarnessInjectedUserTurnText
+} from './harness-injected-user-turns'
import {
buildGrokChatHistoryPathCandidates,
findGrokChatHistoryBySessionId,
@@ -102,6 +106,15 @@ function capOpenCodeHookText(text: string): string {
/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */
export const MAX_PANE_KEY_LEN = 200
+const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
+
+export function normalizeClaudePromptId(value: unknown): string | undefined {
+ if (typeof value !== 'string') {
+ return undefined
+ }
+ const normalized = value.trim().toLowerCase()
+ return CLAUDE_PROMPT_ID_RE.test(normalized) ? normalized : undefined
+}
/** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */
export type HookListenerState = {
@@ -298,6 +311,8 @@ export function warnOnHookEnvOrVersionMismatch(
export type AgentHookEventPayload = {
paneKey: string
+ /** Authenticated hook route that produced this event. */
+ source?: AgentHookSource
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
launchToken?: string
tabId?: string
@@ -310,6 +325,10 @@ export type AgentHookEventPayload = {
promptInteractionKey?: string
/** Raw agent hook event name, used by main-process transition guards. */
hookEventName?: string
+ /** Claude's provider-owned user-prompt UUID. */
+ providerPromptId?: string
+ /** Active Claude compact generation, keyed by provider prompt identity. */
+ compactTrigger?: 'manual' | 'auto'
/** Claude tool-use identifier when the hook source exposes one. */
toolUseId?: string
/** Claude agent/subagent identifier when the hook source exposes one. */
@@ -327,6 +346,92 @@ export type AgentHookEventPayload = {
payload: ParsedAgentStatusPayload
}
+type ClaudeCompactIdentity = Pick<
+ AgentHookEventPayload,
+ | 'source'
+ | 'connectionId'
+ | 'hookEventName'
+ | 'providerPromptId'
+ | 'compactTrigger'
+ | 'providerSession'
+>
+
+export function canAcceptClaudeCompactTransition(
+ previous: AgentHookEventPayload | undefined,
+ incoming: ClaudeCompactIdentity,
+ options: { allowUnanchoredPreCompact?: boolean; allowUnanchoredPostCompact?: boolean } = {}
+): boolean {
+ if (
+ incoming.source !== 'claude' ||
+ incoming.compactTrigger === undefined ||
+ incoming.providerPromptId === undefined ||
+ (incoming.hookEventName !== 'PreCompact' && incoming.hookEventName !== 'PostCompact')
+ ) {
+ return false
+ }
+ if (incoming.hookEventName === 'PreCompact' && options.allowUnanchoredPreCompact) {
+ return true
+ }
+ if (incoming.hookEventName === 'PostCompact' && options.allowUnanchoredPostCompact) {
+ return true
+ }
+ if (
+ previous?.source !== 'claude' ||
+ previous.payload.agentType !== 'claude' ||
+ previous.connectionId !== incoming.connectionId ||
+ !agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession)
+ ) {
+ return false
+ }
+ if (incoming.hookEventName === 'PostCompact') {
+ return (
+ previous.compactTrigger === incoming.compactTrigger &&
+ previous.providerPromptId === incoming.providerPromptId
+ )
+ }
+ return incoming.compactTrigger === 'manual'
+ ? previous.providerPromptId !== undefined
+ : previous.providerPromptId === incoming.providerPromptId
+}
+
+export function resolveCachedClaudeCompactOwnership(
+ previous: AgentHookEventPayload | undefined,
+ incoming: AgentHookEventPayload
+): AgentHookEventPayload {
+ const sameClaudeOwner =
+ previous?.source === 'claude' &&
+ previous.payload.agentType === 'claude' &&
+ incoming.source === 'claude' &&
+ incoming.payload.agentType === 'claude' &&
+ incoming.connectionId === previous.connectionId &&
+ agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession)
+ ? previous
+ : undefined
+ if (incoming.hookEventName === 'PreCompact' && incoming.compactTrigger) {
+ return sameClaudeOwner?.payload.prompt && incoming.payload.prompt.length === 0
+ ? { ...incoming, payload: { ...incoming.payload, prompt: sameClaudeOwner.payload.prompt } }
+ : incoming
+ }
+ if (incoming.hookEventName === 'PostCompact') {
+ return incoming.compactTrigger ? { ...incoming, compactTrigger: undefined } : incoming
+ }
+ const ownsCompact =
+ sameClaudeOwner?.compactTrigger !== undefined &&
+ sameClaudeOwner.providerPromptId !== undefined &&
+ incoming.providerPromptId === sameClaudeOwner.providerPromptId
+ if (ownsCompact) {
+ return {
+ ...incoming,
+ compactTrigger: sameClaudeOwner.compactTrigger,
+ payload:
+ incoming.payload.prompt.length === 0 && sameClaudeOwner.payload.prompt
+ ? { ...incoming.payload, prompt: sameClaudeOwner.payload.prompt }
+ : incoming.payload
+ }
+ }
+ return incoming.compactTrigger ? { ...incoming, compactTrigger: undefined } : incoming
+}
+
// ─── Body parsing ───────────────────────────────────────────────────
export function parseFormEncodedBody(body: string): Record {
@@ -504,6 +609,14 @@ function stripGrokUserQueryWrapper(promptText: string): string {
return text.trim()
}
+// Why: the post-compact continuation prompt has no matching Stop and would resurrect working.
+function shouldIgnoreCompactContinuationUserPromptSubmit(
+ eventName: unknown,
+ promptText: string
+): boolean {
+ return eventName === 'UserPromptSubmit' && isCompactContinuationUserTurnText(promptText)
+}
+
function resolvePrompt(
state: HookListenerState,
paneKey: string,
@@ -2640,19 +2753,27 @@ function normalizeClaudeEvent(
const sessionCronInventoryPresent = Array.isArray(sessionCrons)
const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0
+ if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) {
+ return null
+ }
+
// Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer.
// Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent.
const isAskUserQuestion =
eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name'))
+ // Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy;
+ // PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352).
const reportedStateName =
eventName === 'UserPromptSubmit' ||
eventName === 'PostToolUse' ||
eventName === 'PostToolUseFailure' ||
+ eventName === 'PreCompact' ||
+ (eventName === 'PostCompact' && hookPayload.trigger === 'auto') ||
(eventName === 'PreToolUse' && !isAskUserQuestion)
? 'working'
: eventName === 'PermissionRequest' || isAskUserQuestion
? 'waiting'
- : isTurnBoundary
+ : isTurnBoundary || (eventName === 'PostCompact' && hookPayload.trigger === 'manual')
? 'done'
: null
@@ -2863,6 +2984,10 @@ function normalizeKimiEvent(
paneKey: string,
hookPayload: Record
): ParsedAgentStatusPayload | null {
+ if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) {
+ return null
+ }
+
const toolName = readString(hookPayload, 'tool_name')
const isUserInputTool = isKimiUserInputTool(toolName)
@@ -3938,7 +4063,8 @@ export function normalizeHookPayload(
state: HookListenerState,
source: AgentHookSource,
body: unknown,
- expectedEnv: string
+ expectedEnv: string,
+ options: { allowUnanchoredPreCompact?: boolean; allowUnanchoredPostCompact?: boolean } = {}
): AgentHookEventPayload | null {
if (typeof body !== 'object' || body === null) {
return null
@@ -3987,6 +4113,52 @@ export function normalizeHookPayload(
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
hookPayloadRecord.hook_event_name ??
hookPayloadRecord.hookEventName
+ // Why: Codex child hooks expose the child's session_id on the parent's pane.
+ const providerSession =
+ source === 'codex' && readString(hookPayloadRecord, 'agent_id')
+ ? null
+ : extractAgentProviderSession(source, hookPayloadRecord)
+ const providerPromptId =
+ source === 'claude' ? normalizeClaudePromptId(hookPayloadRecord.prompt_id) : undefined
+ const compactTrigger =
+ source === 'claude' &&
+ (eventName === 'PreCompact' || eventName === 'PostCompact') &&
+ (hookPayloadRecord.trigger === 'manual' || hookPayloadRecord.trigger === 'auto')
+ ? hookPayloadRecord.trigger
+ : undefined
+ const isCompactEvent = eventName === 'PreCompact' || eventName === 'PostCompact'
+ if (isCompactEvent && compactTrigger === undefined) {
+ return null
+ }
+ const previousStatus = state.lastStatusByPaneKey.get(paneKey)
+ if (
+ compactTrigger !== undefined &&
+ !canAcceptClaudeCompactTransition(
+ previousStatus,
+ {
+ source,
+ connectionId: null,
+ hookEventName: typeof eventName === 'string' ? eventName : undefined,
+ providerPromptId,
+ compactTrigger,
+ providerSession: providerSession ?? undefined
+ },
+ {
+ allowUnanchoredPreCompact: options.allowUnanchoredPreCompact,
+ allowUnanchoredPostCompact: options.allowUnanchoredPostCompact
+ }
+ )
+ ) {
+ return null
+ }
+ if (
+ eventName === 'PostCompact' &&
+ compactTrigger !== undefined &&
+ previousStatus?.payload.prompt &&
+ !state.lastPromptByPaneKey.has(paneKey)
+ ) {
+ state.lastPromptByPaneKey.set(paneKey, previousStatus.payload.prompt)
+ }
const extractedPrompt = extractPromptText(hookPayload as Record)
const promptText = extractedPrompt.text
let resolvedPromptText = promptText
@@ -4108,12 +4280,6 @@ export function normalizeHookPayload(
}
// Why: connectionId is null here; ingestRemote stamps it from mux identity on receive. See docs/design/agent-status-over-ssh.md §5.
- // Why: Codex child hooks expose the child's session_id on the parent's pane;
- // treating it as the root resume id would replace the terminal's real session.
- const providerSession =
- source === 'codex' && readString(hookPayloadRecord, 'agent_id')
- ? null
- : extractAgentProviderSession(source, hookPayloadRecord)
const providerSessionOnly =
source === 'pi' && eventName === 'session_start' && providerSession !== null
// Why: Pi session_start carries resume identity while idle; providerSessionOnly makes receivers discard the placeholder row.
@@ -4125,6 +4291,7 @@ export function normalizeHookPayload(
return transportPayload
? {
paneKey,
+ source,
launchToken,
tabId,
worktreeId,
@@ -4143,6 +4310,8 @@ export function normalizeHookPayload(
),
promptInteractionKey,
hookEventName: typeof eventName === 'string' ? eventName : undefined,
+ providerPromptId,
+ compactTrigger,
toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']),
toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']),
toolAgentType: readString(hookPayloadRecord, 'agent_type'),
diff --git a/src/shared/agent-hook-relay.test.ts b/src/shared/agent-hook-relay.test.ts
index 338581c2859..dd738a618bc 100644
--- a/src/shared/agent-hook-relay.test.ts
+++ b/src/shared/agent-hook-relay.test.ts
@@ -6,6 +6,7 @@ import {
AGENT_HOOK_SHED_FIELDS_KEY,
ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV,
createShedSubagentsField,
+ isAgentHookSource,
isRemoteAgentHooksEnabled,
restoreShedStatusFields,
type AgentHookRelayEnvelope
@@ -22,6 +23,8 @@ describe('agent-hook-relay wire shape', () => {
connectionId: null,
env: 'production',
version: '1',
+ providerPromptId: '11111111-1111-4111-8111-111111111111',
+ compactTrigger: 'manual',
payload: {
state: 'working',
prompt: 'roundtrip',
@@ -40,6 +43,14 @@ describe('agent-hook-relay wire shape', () => {
expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay')
expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins')
})
+
+ it('validates hook sources crossing persisted and relay trust boundaries', () => {
+ expect(isAgentHookSource('claude')).toBe(true)
+ expect(isAgentHookSource('kimi')).toBe(true)
+ expect(isAgentHookSource('claude\0codex')).toBe(false)
+ expect(isAgentHookSource('unknown')).toBe(false)
+ expect(isAgentHookSource({ source: 'claude' })).toBe(false)
+ })
})
describe('isRemoteAgentHooksEnabled', () => {
diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts
index ad578fddae2..128397551bc 100644
--- a/src/shared/agent-hook-relay.ts
+++ b/src/shared/agent-hook-relay.ts
@@ -34,24 +34,33 @@ import type { AgentHookTarget } from './agent-hook-types'
// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it
// without dragging Electron in (the shared listener module is the only place
// that consumes it from the relay side).
-export type AgentHookSource =
- | 'claude'
- | 'codex'
- | 'gemini'
- | 'antigravity'
- | 'amp'
- | 'opencode'
- | 'mimo-code'
- | 'cursor'
- | 'pi'
- | 'omp'
- | 'droid'
- | 'command-code'
- | 'grok'
- | 'copilot'
- | 'hermes'
- | 'devin'
- | 'kimi'
+const AGENT_HOOK_SOURCES = [
+ 'claude',
+ 'codex',
+ 'gemini',
+ 'antigravity',
+ 'amp',
+ 'opencode',
+ 'mimo-code',
+ 'cursor',
+ 'pi',
+ 'omp',
+ 'droid',
+ 'command-code',
+ 'grok',
+ 'copilot',
+ 'hermes',
+ 'devin',
+ 'kimi'
+] as const
+
+export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number]
+
+const AGENT_HOOK_SOURCE_SET: ReadonlySet = new Set(AGENT_HOOK_SOURCES)
+
+export function isAgentHookSource(value: unknown): value is AgentHookSource {
+ return typeof value === 'string' && AGENT_HOOK_SOURCE_SET.has(value)
+}
/** Env marker used by the remote relay. It is a transport/location marker, not
* a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */
@@ -75,6 +84,10 @@ export type AgentHookRelayEnvelope = {
promptInteractionKey?: string
/** Hook discriminator preserved for main-process transition rules. */
hookEventName?: string
+ /** Claude's provider-owned user-prompt UUID. */
+ providerPromptId?: string
+ /** Active Claude compact generation, keyed by provider prompt identity. */
+ compactTrigger?: 'manual' | 'auto'
/** Claude tool execution id, when the source hook provides one. */
toolUseId?: string
/** Claude subagent identity, when the source hook provides one. */
diff --git a/src/shared/harness-injected-user-turns.test.ts b/src/shared/harness-injected-user-turns.test.ts
index 41f139059c6..b18eb451316 100644
--- a/src/shared/harness-injected-user-turns.test.ts
+++ b/src/shared/harness-injected-user-turns.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns'
+import {
+ isCompactContinuationUserTurnText,
+ isKnownHarnessInjectedUserTurnText
+} from './harness-injected-user-turns'
describe('isKnownHarnessInjectedUserTurnText', () => {
it('matches every known harness tag, including attribute-carrying forms', () => {
@@ -59,6 +62,16 @@ describe('isKnownHarnessInjectedUserTurnText', () => {
).toBe(true)
})
+ it('distinguishes compact continuation from other harness turns', () => {
+ expect(
+ isCompactContinuationUserTurnText(
+ 'This session is being continued from a previous conversation. Summary follows.'
+ )
+ ).toBe(true)
+ expect(isCompactContinuationUserTurnText('done')).toBe(false)
+ expect(isCompactContinuationUserTurnText('/review ')).toBe(false)
+ })
+
it('is case-insensitive and ignores surrounding whitespace', () => {
expect(isKnownHarnessInjectedUserTurnText(' done')).toBe(true)
expect(isKnownHarnessInjectedUserTurnText('\n hi')).toBe(true)
diff --git a/src/shared/harness-injected-user-turns.ts b/src/shared/harness-injected-user-turns.ts
index c9275732fa3..016a0839d10 100644
--- a/src/shared/harness-injected-user-turns.ts
+++ b/src/shared/harness-injected-user-turns.ts
@@ -39,6 +39,7 @@ const KNOWN_HARNESS_TAG_NAMES = new Set([
// Injected turns identified by a leading string rather than a known tag name:
// the harness only emits in its attributed `` form
// (a bare is a real RSS/XML paste), plus prose deliveries and notices.
+const COMPACT_CONTINUATION_PREFIX = 'this session is being continued from a previous conversation'
const HARNESS_INJECTED_TURN_PREFIXES = [
'= text.length) {
- return false
- }
- const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT)
- const normalized = text.slice(start, headEnd).toLowerCase()
- if (normalized.length === 0) {
+ const normalized = normalizedHarnessTurnHead(text)
+ if (!normalized) {
return false
}
const tagName = LEADING_TAG_NAME.exec(normalized)?.[1]
@@ -77,6 +69,24 @@ export function isKnownHarnessInjectedUserTurnText(text: string): boolean {
return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix))
}
+/** True only for the observed post-compaction continuation prompt. */
+export function isCompactContinuationUserTurnText(text: string): boolean {
+ return normalizedHarnessTurnHead(text).startsWith(COMPACT_CONTINUATION_PREFIX)
+}
+
+function normalizedHarnessTurnHead(text: string): string {
+ let start = 0
+ const wsScanEnd = Math.min(text.length, HARNESS_CLASSIFY_LEADING_WS_LIMIT)
+ while (start < wsScanEnd && isAsciiWhitespace(text.charCodeAt(start))) {
+ start += 1
+ }
+ if (start >= text.length) {
+ return ''
+ }
+ const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT)
+ return text.slice(start, headEnd).toLowerCase()
+}
+
function isAsciiWhitespace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13 || code === 12
}
diff --git a/src/shared/manual-compact-prompt-identity.test.ts b/src/shared/manual-compact-prompt-identity.test.ts
new file mode 100644
index 00000000000..9205e8b7a80
--- /dev/null
+++ b/src/shared/manual-compact-prompt-identity.test.ts
@@ -0,0 +1,235 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ createHookListenerState,
+ normalizeHookPayload,
+ resolveCachedClaudeCompactOwnership,
+ type AgentHookEventPayload,
+ type HookListenerState
+} from './agent-hook-listener'
+import type { AgentHookSource } from './agent-hook-relay'
+import { makePaneKey } from './stable-pane-id'
+
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const PANE_KEY = makePaneKey('manual-compact-prompt', LEAF_ID)
+const PROMPT_ID_1 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'
+const PROMPT_ID_2 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'
+
+function accept(
+ state: HookListenerState,
+ source: AgentHookSource,
+ payload: Record,
+ paneKey = PANE_KEY
+): AgentHookEventPayload | null {
+ const event = normalizeHookPayload(state, source, { paneKey, payload }, 'production')
+ if (event) {
+ state.lastStatusByPaneKey.set(
+ paneKey,
+ resolveCachedClaudeCompactOwnership(state.lastStatusByPaneKey.get(paneKey), event)
+ )
+ }
+ return event
+}
+
+function beginManualCompact(
+ state: HookListenerState,
+ promptId: string,
+ sessionId: string | undefined = 'session-a'
+): AgentHookEventPayload | null {
+ const identity = {
+ prompt_id: promptId,
+ ...(sessionId ? { session_id: sessionId } : {})
+ }
+ accept(state, 'claude', {
+ hook_event_name: 'UserPromptSubmit',
+ prompt: '/compact',
+ ...identity
+ })
+ return accept(state, 'claude', {
+ hook_event_name: 'PreCompact',
+ trigger: 'manual',
+ ...identity
+ })
+}
+
+function finishManualCompact(
+ state: HookListenerState,
+ promptId: string | undefined,
+ sessionId: string | null = 'session-a'
+): AgentHookEventPayload | null {
+ return accept(state, 'claude', {
+ hook_event_name: 'PostCompact',
+ trigger: 'manual',
+ ...(promptId ? { prompt_id: promptId } : {}),
+ ...(sessionId ? { session_id: sessionId } : {})
+ })
+}
+
+describe('manual compact prompt identity', () => {
+ it('settles only the matching Claude manual compact', () => {
+ const state = createHookListenerState()
+
+ const pre = beginManualCompact(state, PROMPT_ID_1)
+ const post = finishManualCompact(state, PROMPT_ID_1)
+
+ expect(pre).toMatchObject({
+ source: 'claude',
+ providerPromptId: PROMPT_ID_1,
+ compactTrigger: 'manual',
+ hookEventName: 'PreCompact',
+ payload: { state: 'working', prompt: '/compact', agentType: 'claude' }
+ })
+ expect(post).toMatchObject({
+ source: 'claude',
+ providerPromptId: PROMPT_ID_1,
+ compactTrigger: 'manual',
+ hookEventName: 'PostCompact',
+ payload: { state: 'done', prompt: '/compact', agentType: 'claude' }
+ })
+ })
+
+ it('rejects stale, duplicate, and session-mismatched completion', () => {
+ const state = createHookListenerState()
+ beginManualCompact(state, PROMPT_ID_1)
+ expect(finishManualCompact(state, PROMPT_ID_1, 'session-b')).toBeNull()
+ expect(finishManualCompact(state, PROMPT_ID_1)).not.toBeNull()
+ expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
+
+ beginManualCompact(state, PROMPT_ID_2)
+ expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
+ expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
+ providerPromptId: PROMPT_ID_2,
+ hookEventName: 'PreCompact',
+ payload: { state: 'working' }
+ })
+ })
+
+ it('does not let stale compact events replace intervening provider work', () => {
+ const state = createHookListenerState()
+ beginManualCompact(state, PROMPT_ID_1)
+ expect(finishManualCompact(state, PROMPT_ID_1)).not.toBeNull()
+ accept(state, 'codex', {
+ hook_event_name: 'UserPromptSubmit',
+ prompt: 'new Codex work',
+ session_id: 'codex-session'
+ })
+
+ expect(
+ accept(state, 'claude', {
+ hook_event_name: 'PreCompact',
+ trigger: 'manual',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ })
+ ).toBeNull()
+ expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
+ expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
+ source: 'codex',
+ payload: { state: 'working', prompt: 'new Codex work', agentType: 'codex' }
+ })
+ })
+
+ it('fails closed without exact prompt identity and session presence', () => {
+ const state = createHookListenerState()
+ expect(beginManualCompact(state, 'not-a-uuid')).toBeNull()
+ expect(beginManualCompact(state, PROMPT_ID_1)).not.toBeNull()
+ expect(finishManualCompact(state, undefined)).toBeNull()
+ expect(finishManualCompact(state, PROMPT_ID_1, null)).toBeNull()
+ expect(state.lastStatusByPaneKey.get(PANE_KEY)?.hookEventName).toBe('PreCompact')
+ })
+
+ it('keeps automatic compact working and ignores unproven Kimi events', () => {
+ const state = createHookListenerState()
+ const current = accept(state, 'claude', {
+ hook_event_name: 'UserPromptSubmit',
+ prompt: 'continue the task',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ })
+ const automaticPre = accept(state, 'claude', {
+ hook_event_name: 'PreCompact',
+ trigger: 'auto',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ })
+ const automaticPost = accept(state, 'claude', {
+ hook_event_name: 'PostCompact',
+ trigger: 'auto',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ })
+ const kimiPre = accept(state, 'kimi', {
+ hook_event_name: 'PreCompact',
+ trigger: 'manual',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ })
+ const kimiPost = accept(state, 'kimi', {
+ hook_event_name: 'PostCompact',
+ trigger: 'manual',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ })
+
+ expect(automaticPre).toMatchObject({
+ compactTrigger: 'auto',
+ hookEventName: 'PreCompact',
+ payload: { state: 'working' }
+ })
+ expect(automaticPost).toMatchObject({
+ compactTrigger: 'auto',
+ hookEventName: 'PostCompact',
+ payload: { state: 'working' }
+ })
+ expect(kimiPre).toBeNull()
+ expect(kimiPost).toBeNull()
+ expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
+ hookEventName: 'PostCompact',
+ compactTrigger: undefined,
+ payload: { state: 'working', prompt: current?.payload.prompt }
+ })
+ })
+
+ it('does not let stale manual completion retire a newer automatic compact', () => {
+ const state = createHookListenerState()
+ beginManualCompact(state, PROMPT_ID_1)
+ expect(
+ accept(state, 'claude', {
+ hook_event_name: 'PreCompact',
+ trigger: 'auto',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ })
+ ).toMatchObject({ compactTrigger: 'auto', payload: { state: 'working' } })
+
+ expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
+ expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
+ compactTrigger: 'auto',
+ hookEventName: 'PreCompact',
+ payload: { state: 'working' }
+ })
+ })
+
+ it('does not allocate ownership for unaccepted untrusted pane keys', () => {
+ const state = createHookListenerState()
+ for (let index = 0; index < 4_097; index += 1) {
+ normalizeHookPayload(
+ state,
+ 'claude',
+ {
+ paneKey: makePaneKey(`untrusted-${index}`, LEAF_ID),
+ payload: {
+ hook_event_name: 'PreCompact',
+ trigger: 'manual',
+ prompt_id: PROMPT_ID_1,
+ session_id: 'session-a'
+ }
+ },
+ 'production'
+ )
+ }
+
+ expect(state.lastStatusByPaneKey.size).toBe(0)
+ expect(state.lastPromptByPaneKey.size).toBe(0)
+ })
+})
From b3a4a4f929903197a318e08a0473766140ed9ef8 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:31:08 -0700
Subject: [PATCH 13/42] fix(terminal): coordinate reveal atlas recovery
(#11864)
---
.../terminal-visibility-resume.test.ts | 4 +-
.../terminal-visibility-resume.ts | 10 +--
.../lib/pane-manager/pane-manager-registry.ts | 5 +-
.../pane-manager/pane-reveal-repaint.test.ts | 68 ++++++++++++++++++-
.../lib/pane-manager/pane-reveal-repaint.ts | 50 ++++++++++++--
5 files changed, 119 insertions(+), 18 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts
index 17271179620..e2d4aac2929 100644
--- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts
@@ -257,8 +257,8 @@ describe('resumeTerminalVisibility reveal repaint', () => {
// every refocus forces a mass re-rasterization that can hit xterm's atlas
// page-merge race (#4480) and garble streaming panes. Focus recovery must
// resume rendering and present WITHOUT the atlas-clearing reveal repaint —
- // scheduleRevealRepaint clears each pane's (shared) atlas, so the refocus
- // path must route to the atlas-preserving present instead.
+ // scheduleRevealRepaint runs shared-atlas recovery, so the refocus path
+ // must route to the atlas-preserving present instead.
const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked(
await import('@/lib/pane-manager/pane-manager-registry')
)
diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
index 5c6b8314545..70787266204 100644
--- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
@@ -94,8 +94,8 @@ export function resumeTerminalVisibility({
resetAndRefreshAllTerminalWebglAtlases()
}
// Why: the synchronous recovery above can fire before the revealed pane is
- // attached and laid out, where the WebGL renderer drops redraw requests
- // without retry. Follow up with a settled-frame, pane-scoped repaint.
+ // attached and laid out. Follow up after layout with one shared-atlas-safe
+ // recovery covering every visible terminal manager.
manager.scheduleRevealRepaint()
})
}
@@ -171,9 +171,9 @@ export function recoverVisibleTerminalWindowWake({
resetAndRefreshAllTerminalWebglAtlases()
manager.scheduleRevealRepaint()
} else {
- // Why: the reveal repaint clears each pane's texture atlas (a shared,
- // same-config wipe), so a plain refocus must use the atlas-preserving
- // present instead — otherwise it re-arms the same mid-stream garble race.
+ // Why: the reveal repaint runs a shared-atlas reset, so a plain refocus
+ // must use the atlas-preserving present instead — otherwise it re-arms the
+ // same mid-stream garble race.
manager.scheduleRevealPresent()
}
}
diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts
index d864ce0de37..832f506f54f 100644
--- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts
+++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts
@@ -47,7 +47,7 @@ export function resetAllTerminalWebglAtlases(): void {
}
}
-export function resetAndRefreshAllTerminalWebglAtlases(): void {
+export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
// Why: the atlas wipe is the heavy recovery path; recording it lets a freeze
// report show whether a post-wake repaint actually ran. Silent breadcrumb.
const recoveryManagers = Array.from(liveManagers).filter(
@@ -55,7 +55,8 @@ export function resetAndRefreshAllTerminalWebglAtlases(): void {
)
recordTerminalWebglDiagnostic('webgl-atlas-reset', {
managers: recoveryManagers.length,
- mountedManagers: liveManagers.size
+ mountedManagers: liveManagers.size,
+ ...(reason ? { reason } : {})
})
const resetManagers: RegisteredPaneManager[] = []
for (const manager of recoveryManagers) {
diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts
index 4985ffe1355..8604a0fd6dc 100644
--- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts
+++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts
@@ -1,9 +1,14 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint'
-import { resetTerminalWebglSuggestion } from './pane-webgl-renderer'
+import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry'
+import { resetTerminalWebglSuggestion, resetWebglTextureAtlas } from './pane-webgl-renderer'
type FakeWebglAddon = { clearTextureAtlas: ReturnType }
+type FakePaneManager = {
+ resetWebglTextureAtlases: Mock<() => void>
+ refreshAllPanes: Mock<() => void>
+}
function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal {
const leafId = '33333333-3333-4333-8333-333333333333' as never
@@ -46,6 +51,25 @@ function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): Manag
describe('schedulePaneRevealRepaint', () => {
let rafQueue: FrameRequestCallback[]
+ const registeredManagers: FakePaneManager[] = []
+
+ function registerPaneManager(getPanes: () => Iterable): FakePaneManager {
+ const manager: FakePaneManager = {
+ resetWebglTextureAtlases: vi.fn(() => {
+ for (const pane of getPanes()) {
+ resetWebglTextureAtlas(pane)
+ }
+ }),
+ refreshAllPanes: vi.fn(() => {
+ for (const pane of getPanes()) {
+ pane.terminal.refresh(0, pane.terminal.rows - 1)
+ }
+ })
+ }
+ registerLivePaneManager(manager)
+ registeredManagers.push(manager)
+ return manager
+ }
function flushFrame(): void {
const queue = rafQueue
@@ -66,6 +90,9 @@ describe('schedulePaneRevealRepaint', () => {
})
afterEach(() => {
+ for (const manager of registeredManagers.splice(0)) {
+ unregisterLivePaneManager(manager)
+ }
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
@@ -73,6 +100,7 @@ describe('schedulePaneRevealRepaint', () => {
it('repaints only after the post-reveal frame has settled', () => {
const webglAddon = { clearTextureAtlas: vi.fn() }
const pane = createPane({ webglAddon })
+ registerPaneManager(() => [pane])
schedulePaneRevealRepaint(() => [pane])
// First frame: reveal layout may still be in flight; redraw requests fired
@@ -86,14 +114,47 @@ describe('schedulePaneRevealRepaint', () => {
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
+ it('coordinates a settled atlas clear across recovery-eligible managers', () => {
+ const pane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
+ const siblingPane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
+ const targetManager = registerPaneManager(() => [pane])
+ const siblingManager = registerPaneManager(() => [siblingPane])
+
+ schedulePaneRevealRepaint(() => [pane])
+ flushFrame()
+ flushFrame()
+
+ expect(targetManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
+ expect(siblingManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
+ expect((siblingPane.webglAddon as never as FakeWebglAddon).clearTextureAtlas).toHaveBeenCalled()
+ expect(siblingPane.terminal.refresh).toHaveBeenCalledWith(0, 23)
+ })
+
+ it('coalesces concurrent reveal clears into one global recovery', () => {
+ const firstPane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
+ const secondPane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
+ const firstManager = registerPaneManager(() => [firstPane])
+ const secondManager = registerPaneManager(() => [secondPane])
+
+ schedulePaneRevealRepaint(() => [firstPane])
+ schedulePaneRevealRepaint(() => [secondPane])
+ flushFrame()
+ flushFrame()
+
+ expect(firstManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
+ expect(secondManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
+ })
+
it('reattaches a missing WebGL addon before repainting', () => {
const pane = createPane()
+ const manager = registerPaneManager(() => [pane])
schedulePaneRevealRepaint(() => [pane])
flushFrame()
flushFrame()
expect(pane.webglAddon).not.toBeNull()
+ expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
expect(pane.terminal.refresh).toHaveBeenCalled()
})
@@ -101,6 +162,7 @@ describe('schedulePaneRevealRepaint', () => {
const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
const panes = [stalePane]
+ registerPaneManager(() => panes)
schedulePaneRevealRepaint(() => panes)
panes.splice(0, panes.length, livePane)
@@ -123,6 +185,7 @@ describe('schedulePaneRevealRepaint', () => {
} as never as ManagedPaneInternal
const webglAddon = { clearTextureAtlas: vi.fn() }
const livePane = createPane({ webglAddon })
+ registerPaneManager(() => [explosivePane, livePane])
schedulePaneRevealRepaint(() => [explosivePane, livePane])
flushFrame()
@@ -137,6 +200,7 @@ describe('schedulePaneRevealRepaint', () => {
vi.stubGlobal('requestAnimationFrame', undefined)
const webglAddon = { clearTextureAtlas: vi.fn() }
const pane = createPane({ webglAddon })
+ registerPaneManager(() => [pane])
schedulePaneRevealRepaint(() => [pane])
vi.runAllTimers()
diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts
index 9fb283782d2..bfc4797aa52 100644
--- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts
+++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts
@@ -1,6 +1,11 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
-import { resetWebglTextureAtlas } from './pane-webgl-renderer'
+import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
+
+type PaneGetter = () => Iterable
+
+const pendingRevealRepaints = new Set()
+let revealRepaintScheduled = false
function scheduleSettledFrame(callback: () => void): void {
if (typeof globalThis.requestAnimationFrame !== 'function') {
@@ -30,6 +35,34 @@ function forEachPaneOnSettledFrame(
})
}
+function flushPaneRevealRepaints(): void {
+ revealRepaintScheduled = false
+ const paneGetters = Array.from(pendingRevealRepaints)
+ pendingRevealRepaints.clear()
+ const livePanes = new Set()
+
+ for (const getPanes of paneGetters) {
+ try {
+ for (const pane of getPanes()) {
+ livePanes.add(pane)
+ }
+ } catch {
+ /* ignore — a manager may be destroyed while its repaint is pending */
+ }
+ }
+
+ for (const pane of livePanes) {
+ try {
+ reattachWebglIfNeeded(pane)
+ } catch {
+ /* ignore — one pane's teardown must not block global recovery */
+ }
+ }
+ if (livePanes.size > 0) {
+ resetAndRefreshAllTerminalWebglAtlases('settled-reveal')
+ }
+}
+
/**
* Repaints a revealed tab's panes from their xterm buffers.
*
@@ -37,14 +70,17 @@ function forEachPaneOnSettledFrame(
* per-cell model without ever presenting a frame. At reveal the model diff
* reports those cells unchanged, so plain refreshes skip them and the canvas
* keeps compositing pre-hide pixels until a selection or resize rebuilds the
- * model. Clearing the model per pane — after (re)attach, once layout has
- * settled — forces a full rebuild from the buffer without any PTY resize.
+ * model. Once layout settles, reattach every revealed renderer before one
+ * registry-wide atlas reset so no delayed pane-local clear can invalidate a
+ * sibling terminal's rebuilt model.
*/
export function schedulePaneRevealRepaint(getPanes: () => Iterable): void {
- forEachPaneOnSettledFrame(getPanes, (pane) => {
- reattachWebglIfNeeded(pane)
- resetWebglTextureAtlas(pane)
- })
+ pendingRevealRepaints.add(getPanes)
+ if (revealRepaintScheduled) {
+ return
+ }
+ revealRepaintScheduled = true
+ scheduleSettledFrame(flushPaneRevealRepaints)
}
/**
From 9ee359550bb32911ba34657a6a613eec2c5fe874 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:46:08 -0700
Subject: [PATCH 14/42] fix(mobile): make native-chat file links and path
citations tappable (STA-3331) (#12364)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mobile): make native-chat file links and path citations tappable (STA-3331)
- Linkify POSIX absolute paths in chat prose (leading-/ regex alternative;
URL guard now keys off the char before the matched slash)
- Parse agent-style path:line(:col) citations in prose, code spans, and the
open flow; line/column ride into the mobile file preview route
- Route non-web markdown hrefs (file: URIs, relative/absolute paths) to the
file opener instead of silently dropping them; unknown schemes stay dead
- Resolve chat paths against the worktree root, not the terminal's live cwd
- Reuse the terminal tap-to-open flow for chat taps (haptic, preview route,
tab activation with retries) via a shared identity-stable hook, and toast
on misses instead of silent no-ops
- Keep snake_case paths whole (intraword underscores are literal text),
scan bold/italic/strike spans for paths, split trailing punctuation off
autolinks, and let taps land while the composer keyboard is up
* fix(mobile): harden chat file tap handling
* refactor(chat): share native chat href routing
* fix(mobile): detect files directly under path roots
* fix(mobile): keep inline tokens and dunder paths intact around emphasis
Review follow-ups on the chat file-link work:
- A rejected intraword `_` token left the scan index past its closing
underscore, so every inline token between two snake_case words was
swallowed and rendered as literal source — including markdown links,
which became untappable. Rescan from just past the opening delimiter.
- Treat a path separator as an intraword flank so `src/__init__.py` and
`a/__tests__/x.ts` stay whole; previously they rendered as bold plus a
remnant that the new absolute-root pattern turned into a tap on `/x.ts`.
- Bound the `:line(:col)` tail so `src/app.ts:1e3` and `:80%` no longer
parse a line number, while a cited range still opens its first line.
- Route chat tap failures through the composer banner (toast fallback):
chat taps happen with the keyboard up, which covers the toast.
- Drop the tap-handler mirror's dep list; the call site rebuilds its
accessors every render, so it could never skip on a route that
rerenders per keystroke.
* Revert "fix(mobile): keep inline tokens and dunder paths intact around emphasis"
This reverts commit 308bfaf22b88dafc5c43c6a2b8fb8b73c40e2972.
* fix(mobile): preserve chat file-link parsing and feedback
---
.../app/h/[hostId]/session/[worktreeId].tsx | 58 ++---
.../MobileMarkdown.file-links.test.ts | 133 +++++++++++
mobile/src/components/MobileMarkdown.tsx | 74 ++++--
.../markdown-file-path-detection.test.ts | 124 +++++++++-
.../markdown-file-path-detection.ts | 51 +++-
.../components/markdown-href-routing.test.ts | 79 +++++++
.../src/components/markdown-href-routing.ts | 18 ++
.../markdown-inline-token-rules.test.ts | 73 ++++++
.../components/markdown-inline-token-rules.ts | 55 +++++
.../src/session/MobileNativeChatOverlay.tsx | 6 +-
mobile/src/session/MobileNativeChatView.tsx | 3 +
...n.test.ts => mobile-file-tap-open.test.ts} | 196 +++++++++++++++-
...le-tap-open.ts => mobile-file-tap-open.ts} | 52 ++++-
.../mobile-native-chat-open-file.test.ts | 219 +++++++++++-------
.../session/mobile-native-chat-open-file.ts | 91 +++-----
.../use-mobile-file-tap-handlers.test.ts | 134 +++++++++++
.../session/use-mobile-file-tap-handlers.ts | 174 ++++++++++++++
.../use-mobile-native-chat-controller.ts | 26 +--
.../native-chat/native-chat-file-link.ts | 82 +------
src/shared/native-chat-href-routing.test.ts | 57 +++++
src/shared/native-chat-href-routing.ts | 74 ++++++
21 files changed, 1434 insertions(+), 345 deletions(-)
create mode 100644 mobile/src/components/MobileMarkdown.file-links.test.ts
create mode 100644 mobile/src/components/markdown-href-routing.test.ts
create mode 100644 mobile/src/components/markdown-href-routing.ts
create mode 100644 mobile/src/components/markdown-inline-token-rules.test.ts
create mode 100644 mobile/src/components/markdown-inline-token-rules.ts
rename mobile/src/session/{mobile-terminal-file-tap-open.test.ts => mobile-file-tap-open.test.ts} (72%)
rename mobile/src/session/{mobile-terminal-file-tap-open.ts => mobile-file-tap-open.ts} (78%)
create mode 100644 mobile/src/session/use-mobile-file-tap-handlers.test.ts
create mode 100644 mobile/src/session/use-mobile-file-tap-handlers.ts
create mode 100644 src/shared/native-chat-href-routing.test.ts
create mode 100644 src/shared/native-chat-href-routing.ts
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 4d1f5319636..97355ee8665 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -196,7 +196,7 @@ import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTer
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
-import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
+import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
acceptSessionSnapshot,
@@ -3192,44 +3192,23 @@ export default function SessionScreen() {
})
}, [])
- // Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
- const handleFileTapActivationSeqRef = useRef(0)
- const handleFileTap = useCallback(
- (handle: string, pathText: string, line: number | null, column: number | null) => {
- if (handle !== activeHandleRef.current || !client) {
- return
- }
- const activationSeq = ++handleFileTapActivationSeqRef.current
- openMobileTerminalFileTap({
- client,
- hostId,
- worktreeId,
- worktreeName: routeWorktreeName,
- terminalHandle: handle,
- pathText,
- cwd: terminalCwdRef.current.get(handle) ?? null,
- line,
- column,
- pushPreviewRoute: (href) => router.push(href),
- openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
- triggerOpenFeedback: triggerSelection,
- fetchSessionTabs,
- getSessionTabs: () => sessionTabsRef.current,
- getActiveSessionTabId: () => activeSessionTabIdRef.current,
- getActivationState: (activated) => ({
- activated,
- activationSeq,
- latestActivationSeq: handleFileTapActivationSeqRef.current,
- sourceTerminalHandle: handle,
- activeTerminalHandle: activeHandleRef.current,
- activeTabType: activeSessionTabTypeRef.current
- }),
- switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
- scheduleDelayedAction
- })
- },
- [client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
- )
+ // Tap a terminal or chat file path → resolve on host, open as file tab/preview.
+ const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers({
+ client,
+ hostId,
+ worktreeId,
+ worktreeName: routeWorktreeName,
+ activeHandleRef,
+ terminalCwdRef,
+ openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
+ fetchSessionTabs,
+ getSessionTabs: () => sessionTabsRef.current,
+ getActiveSessionTabId: () => activeSessionTabIdRef.current,
+ getActiveSessionTabType: () => activeSessionTabTypeRef.current,
+ switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
+ scheduleDelayedAction,
+ reportChatTapFailure: nativeChatSendError.show
+ })
const handleOpenedFileDiffActivationSeqRef = useRef(0)
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
@@ -4750,6 +4729,7 @@ export default function SessionScreen() {
))}
Promise.resolve())
+
+vi.mock('react-native', () => ({
+ Linking: { openURL: (url: string) => openURL(url) },
+ Pressable: 'Pressable',
+ ScrollView: 'ScrollView',
+ StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
+ Text: 'Text',
+ View: 'View'
+}))
+vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
+
+function flattenText(node: ReactTestInstance): string {
+ return node.children
+ .map((child) => (typeof child === 'string' ? child : flattenText(child)))
+ .join('')
+}
+
+function pressables(renderer: ReactTestRenderer): ReactTestInstance[] {
+ return renderer.root.findAll(
+ (node) => node.type === ('Text' as never) && typeof node.props.onPress === 'function'
+ )
+}
+
+function pressByText(renderer: ReactTestRenderer, text: string): void {
+ const target = pressables(renderer).find((node) => flattenText(node) === text)
+ expect(target, `no pressable text ${JSON.stringify(text)}`).toBeDefined()
+ target!.props.onPress()
+}
+
+describe('MobileMarkdown file links', () => {
+ let renderer: ReactTestRenderer | null = null
+ const onOpenFile = vi.fn()
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ onOpenFile.mockClear()
+ openURL.mockClear()
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ function render(content: string): ReactTestRenderer {
+ act(() => {
+ renderer = create(createElement(MobileMarkdown, { content, onOpenFile }))
+ })
+ return renderer!
+ }
+
+ it('opens a tapped POSIX absolute path in prose', () => {
+ pressByText(render('Edit /Users/me/wt/src/app.tsx now'), '/Users/me/wt/src/app.tsx')
+ expect(onOpenFile).toHaveBeenCalledWith('/Users/me/wt/src/app.tsx')
+ })
+
+ it('opens a tapped path:line citation in prose', () => {
+ pressByText(render('see src/foo.ts:42 for the fix'), 'src/foo.ts:42')
+ expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
+ })
+
+ it('routes a relative markdown href to the file opener with its #L line', () => {
+ pressByText(render('read [the plan](docs/plan.md#L7) first'), 'the plan')
+ expect(onOpenFile).toHaveBeenCalledWith('docs/plan.md:7')
+ expect(openURL).not.toHaveBeenCalled()
+ })
+
+ it('routes a file: href to the file opener', () => {
+ pressByText(render('[artifact](file:///tmp/out/result.json)'), 'artifact')
+ expect(onOpenFile).toHaveBeenCalledWith('/tmp/out/result.json')
+ })
+
+ it('keeps web links on the system browser', () => {
+ pressByText(render('go to [site](https://example.com/docs)'), 'site')
+ expect(openURL).toHaveBeenCalledWith('https://example.com/docs')
+ expect(onOpenFile).not.toHaveBeenCalled()
+ })
+
+ it('drops unknown-scheme hrefs without opening anything', () => {
+ pressByText(render('[ide](editor://file/x.ts)'), 'ide')
+ expect(openURL).not.toHaveBeenCalled()
+ expect(onOpenFile).not.toHaveBeenCalled()
+ })
+
+ it('keeps snake_case paths whole instead of shredding them as emphasis', () => {
+ const rendered = render('compare src/foo_bar.ts and src/baz_qux.ts now')
+ pressByText(rendered, 'src/foo_bar.ts')
+ pressByText(rendered, 'src/baz_qux.ts')
+ expect(onOpenFile).toHaveBeenNthCalledWith(1, 'src/foo_bar.ts')
+ expect(onOpenFile).toHaveBeenNthCalledWith(2, 'src/baz_qux.ts')
+ })
+
+ it('keeps markdown links between snake_case paths tappable', () => {
+ pressByText(
+ render('Updated src/foo_bar.py; see [the PR](https://example.com/x) before src/baz_qux.py'),
+ 'the PR'
+ )
+ expect(openURL).toHaveBeenCalledWith('https://example.com/x')
+ })
+
+ it('opens a dunder path as one link', () => {
+ pressByText(render('see a/__tests__/x.ts now'), 'a/__tests__/x.ts')
+ expect(onOpenFile).toHaveBeenCalledExactlyOnceWith('a/__tests__/x.ts')
+ })
+
+ it('detects paths inside bold spans', () => {
+ pressByText(render('changed **src/foo.ts** heavily'), 'src/foo.ts')
+ expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts')
+ })
+
+ it('excludes trailing sentence punctuation from autolinks', () => {
+ pressByText(render('see https://example.com/a.'), 'https://example.com/a')
+ expect(openURL).toHaveBeenCalledWith('https://example.com/a')
+ })
+
+ it('opens inline-code path:line citations', () => {
+ pressByText(render('fix `src/foo.ts:42` now'), 'src/foo.ts:42')
+ expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
+ })
+
+ it('renders paths as plain text without onOpenFile', () => {
+ act(() => {
+ renderer = create(createElement(MobileMarkdown, { content: 'Edit src/app/Main.tsx now' }))
+ })
+ expect(pressables(renderer!)).toHaveLength(0)
+ })
+})
diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx
index dc58f5e5d6a..cc88c01e564 100644
--- a/mobile/src/components/MobileMarkdown.tsx
+++ b/mobile/src/components/MobileMarkdown.tsx
@@ -7,6 +7,11 @@ import {
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
+import { routeMarkdownHref } from './markdown-href-routing'
+import {
+ isIntrawordUnderscoreToken,
+ trimAutolinkTrailingPunctuation
+} from './markdown-inline-token-rules'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { parseMobileMarkdown } from './mobile-markdown-parser'
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
@@ -17,10 +22,11 @@ type Props = {
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
* the chat view passes >1 so agent prose reads larger than the compact base. */
textScale?: number
- /** When provided, detected file-path tokens render as tappable and invoke this
- * with the worktree-relative path. Omitted on screens with no file viewer, where
+ /** When provided, detected file paths and file-target hrefs render as tappable
+ * and invoke this with the path text (worktree-relative or absolute, with an
+ * optional :line(:col) suffix). Omitted on screens with no file viewer, where
* paths render as plain text (no behavior change). */
- onOpenFile?: (relativePath: string) => void
+ onOpenFile?: (pathText: string) => void
}
const MAX_TABLE_ROWS = 40
@@ -28,10 +34,16 @@ const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
-function openMarkdownUrl(url: string): void {
- const trimmed = url.trim()
- if (/^(https?:|mailto:)/i.test(trimmed)) {
- void Linking.openURL(trimmed).catch(() => {})
+// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
+// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
+function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
+ const route = routeMarkdownHref(href)
+ if (route.kind === 'web') {
+ void Linking.openURL(route.url).catch(() => {})
+ return
+ }
+ if (route.kind === 'file' && onOpenFile) {
+ onOpenFile(route.pathText)
}
}
@@ -40,7 +52,7 @@ function openMarkdownUrl(url: string): void {
function renderTextRun(
text: string,
keyPrefix: string,
- onOpenFile?: (relativePath: string) => void
+ onOpenFile?: (pathText: string) => void
): ReactNode {
if (!onOpenFile) {
return text
@@ -65,39 +77,54 @@ function renderTextRun(
})
}
-function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] {
+function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] {
const parts: ReactNode[] = []
const pattern =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
- let lastIndex = 0
+ let pendingStart = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text))) {
- if (match.index > lastIndex) {
- parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile))
- }
const token = match[0]
+ // Intraword `_` runs (snake_case, dunder tails) are literal text per
+ // CommonMark; leaving them unflushed keeps surrounding file paths whole
+ // for detection in the eventual text run.
+ if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) {
+ // Resume after the opener so real tokens inside the rejected span are still scanned.
+ pattern.lastIndex = match.index + 1
+ continue
+ }
+ if (match.index > pendingStart) {
+ parts.push(
+ renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile)
+ )
+ }
+ pendingStart = pattern.lastIndex
const key = `${match.index}:${token}`
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (image) {
parts.push(
- openMarkdownUrl(image[2]!)}>
+ openMarkdownHref(image[2]!, onOpenFile)}>
{image[1] || 'image'}
)
} else if (link) {
parts.push(
- openMarkdownUrl(link[2]!)}>
+ openMarkdownHref(link[2]!, onOpenFile)}>
{link[1]}
)
} else if (/^https?:\/\//i.test(token)) {
+ const { url, trailing } = trimAutolinkTrailingPunctuation(token)
parts.push(
- openMarkdownUrl(token)}>
- {token}
+ openMarkdownHref(url, onOpenFile)}>
+ {url}
)
+ if (trailing) {
+ parts.push({trailing} )
+ }
} else if (token.startsWith('`')) {
const code = token.slice(1, -1)
if (onOpenFile && isFilePathCodeSpan(code)) {
@@ -120,27 +147,26 @@ function renderInline(text: string, onOpenFile?: (relativePath: string) => void)
} else if (token.startsWith('~~')) {
parts.push(
- {token.slice(2, -2)}
+ {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
)
} else if (token.startsWith('**') || token.startsWith('__')) {
parts.push(
- {token.slice(2, -2)}
+ {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
)
} else {
parts.push(
- {token.slice(1, -1)}
+ {renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
)
}
- lastIndex = pattern.lastIndex
}
- if (lastIndex < text.length) {
- parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile))
+ if (pendingStart < text.length) {
+ parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile))
}
return parts
}
@@ -206,7 +232,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
openMarkdownUrl(block.url)}
+ onPress={() => openMarkdownHref(block.url, onOpenFile)}
>
{block.alt || 'Open image'}
diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts
index f75da01efdc..19e608ec413 100644
--- a/mobile/src/components/markdown-file-path-detection.test.ts
+++ b/mobile/src/components/markdown-file-path-detection.test.ts
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
detectFilePathSegments,
isFilePathCodeSpan,
- normalizeFilePath
+ normalizeFilePath,
+ splitFilePathLineSuffix
} from './markdown-file-path-detection'
describe('detectFilePathSegments', () => {
@@ -64,6 +65,69 @@ describe('detectFilePathSegments', () => {
])
})
+ it('detects POSIX absolute paths', () => {
+ expect(detectFilePathSegments('Wrote /Users/me/wt/src/app.tsx today')).toEqual([
+ { type: 'text', value: 'Wrote ' },
+ { type: 'file', value: '/Users/me/wt/src/app.tsx', path: '/Users/me/wt/src/app.tsx' },
+ { type: 'text', value: ' today' }
+ ])
+ expect(detectFilePathSegments('/repo/src/index.ts')).toEqual([
+ { type: 'file', value: '/repo/src/index.ts', path: '/repo/src/index.ts' }
+ ])
+ expect(detectFilePathSegments('/root.ts')).toEqual([
+ { type: 'file', value: '/root.ts', path: '/root.ts' }
+ ])
+ })
+
+ it('detects files directly under explicit Windows and relative roots', () => {
+ expect(detectFilePathSegments(String.raw`C:\root.ts`)).toEqual([
+ { type: 'file', value: String.raw`C:\root.ts`, path: String.raw`C:\root.ts` }
+ ])
+ expect(detectFilePathSegments('./root.ts')).toEqual([
+ { type: 'file', value: './root.ts', path: 'root.ts' }
+ ])
+ expect(detectFilePathSegments('../root.ts')).toEqual([
+ { type: 'file', value: '../root.ts', path: '../root.ts' }
+ ])
+ })
+
+ it('detects paths with :line and :line:col suffixes', () => {
+ expect(detectFilePathSegments('see src/foo.ts:42 here')).toEqual([
+ { type: 'text', value: 'see ' },
+ { type: 'file', value: 'src/foo.ts:42', path: 'src/foo.ts:42' },
+ { type: 'text', value: ' here' }
+ ])
+ expect(
+ detectFilePathSegments('/wt/src/app.tsx:120:7 and C:\\repo\\a.ts:3').filter(
+ (s) => s.type === 'file'
+ )
+ ).toEqual([
+ { type: 'file', value: '/wt/src/app.tsx:120:7', path: '/wt/src/app.tsx:120:7' },
+ { type: 'file', value: 'C:\\repo\\a.ts:3', path: 'C:\\repo\\a.ts:3' }
+ ])
+ })
+
+ it('keeps a non-line colon tail out of the match', () => {
+ expect(detectFilePathSegments('edit src/foo.ts: then run')).toEqual([
+ { type: 'text', value: 'edit ' },
+ { type: 'file', value: 'src/foo.ts', path: 'src/foo.ts' },
+ { type: 'text', value: ': then run' }
+ ])
+ })
+
+ it('does not partially parse numeric-looking non-line tails', () => {
+ expect(detectFilePathSegments('log src/app.ts:1e3 oops')).toEqual([
+ { type: 'text', value: 'log ' },
+ { type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
+ { type: 'text', value: ':1e3 oops' }
+ ])
+ expect(detectFilePathSegments('coverage src/app.ts:80% of lines')).toEqual([
+ { type: 'text', value: 'coverage ' },
+ { type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
+ { type: 'text', value: ':80% of lines' }
+ ])
+ })
+
it('does not match bare filenames without a slash', () => {
expect(detectFilePathSegments('open Main.tsx please')).toEqual([
{ type: 'text', value: 'open Main.tsx please' }
@@ -74,6 +138,15 @@ describe('detectFilePathSegments', () => {
expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([
{ type: 'text', value: 'https://example.com/path/file.ts' }
])
+ expect(detectFilePathSegments('see https://example.com/path/file.ts:42 now')).toEqual([
+ { type: 'text', value: 'see https://example.com/path/file.ts:42 now' }
+ ])
+ })
+
+ it('does not match protocol-relative URLs', () => {
+ expect(detectFilePathSegments('load //cdn.example.com/lib/app.js')).toEqual([
+ { type: 'text', value: 'load //cdn.example.com/lib/app.js' }
+ ])
})
it('does not match version numbers', () => {
@@ -171,12 +244,61 @@ describe('isFilePathCodeSpan', () => {
expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true)
})
+ it('accepts POSIX absolute paths and :line citations', () => {
+ expect(isFilePathCodeSpan('/Users/me/wt/src/app.tsx')).toBe(true)
+ expect(isFilePathCodeSpan('src/foo.ts:42')).toBe(true)
+ expect(isFilePathCodeSpan('src/foo.ts:42:7')).toBe(true)
+ expect(isFilePathCodeSpan('MobileNativeChatComposer.tsx:23')).toBe(true)
+ expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx:12`)).toBe(true)
+ })
+
it('rejects emails and git URLs with a mid-token @', () => {
expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false)
expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false)
})
})
+describe('splitFilePathLineSuffix', () => {
+ it('splits :line and :line:col suffixes', () => {
+ expect(splitFilePathLineSuffix('src/foo.ts:42')).toEqual({
+ path: 'src/foo.ts',
+ line: 42,
+ column: null
+ })
+ expect(splitFilePathLineSuffix('src/foo.ts:42:7')).toEqual({
+ path: 'src/foo.ts',
+ line: 42,
+ column: 7
+ })
+ })
+
+ it('keeps Windows drive colons intact', () => {
+ expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts`)).toEqual({
+ path: String.raw`C:\repo\a.ts`,
+ line: null,
+ column: null
+ })
+ expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts:12`)).toEqual({
+ path: String.raw`C:\repo\a.ts`,
+ line: 12,
+ column: null
+ })
+ })
+
+ it('ignores non-numeric and zero suffixes', () => {
+ expect(splitFilePathLineSuffix('src/foo.ts')).toEqual({
+ path: 'src/foo.ts',
+ line: null,
+ column: null
+ })
+ expect(splitFilePathLineSuffix('src/foo.ts:0')).toEqual({
+ path: 'src/foo.ts:0',
+ line: null,
+ column: null
+ })
+ })
+})
+
describe('normalizeFilePath', () => {
it('strips a leading ./', () => {
expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts')
diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts
index a33c0dceae4..5e320a3f10c 100644
--- a/mobile/src/components/markdown-file-path-detection.ts
+++ b/mobile/src/components/markdown-file-path-detection.ts
@@ -83,9 +83,11 @@ const FILE_EXTENSIONS = [
const EXTENSION_SET = new Set(FILE_EXTENSIONS)
// Accept the host's native separator because transcript paths originate on the
-// connected runtime, which may be Windows even when the phone is not.
+// connected runtime, which may be Windows even when the phone is not. Leading
+// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
+// tail captures only bounded agent-style :line(:col) citations.
const CANDIDATE_PATTERN =
- /(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g
+ /(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g
// A path candidate in chat prose is short; a much longer run can't hold one worth
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
@@ -99,7 +101,31 @@ function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
-function isOpenablePath(candidate: string): boolean {
+const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
+
+/**
+ * Split an agent-style `path:line(:col)` citation into its parts. Windows drive
+ * colons are safe: only a trailing all-digit suffix is treated as a line ref.
+ */
+export function splitFilePathLineSuffix(pathText: string): {
+ path: string
+ line: number | null
+ column: number | null
+} {
+ const match = LINE_SUFFIX_PATTERN.exec(pathText)
+ if (!match) {
+ return { path: pathText, line: null, column: null }
+ }
+ return {
+ path: match[1]!,
+ line: Number.parseInt(match[2]!, 10),
+ column: match[3] ? Number.parseInt(match[3], 10) : null
+ }
+}
+
+function isOpenablePath(pathText: string): boolean {
+ // A :line(:col) tail is part of the citation, not the file name.
+ const { path: candidate } = splitFilePathLineSuffix(pathText)
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
@@ -153,10 +179,11 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
while ((match = CANDIDATE_PATTERN.exec(text))) {
const candidate = match[0]
- // Skip candidates that are part of a URL (preceded by a scheme colon or an
- // alphanumeric/host char that would make this a domain tail, not a path).
+ // Skip candidates that are part of a URL: a scheme colon, a domain tail, or
+ // a preceding slash (the leading slash of an absolute path is part of the
+ // match itself, so prev '/' means a '://' or '//' remainder, not a path).
const prev = match.index > 0 ? text[match.index - 1]! : ''
- if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) {
+ if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) {
continue
}
if (!isOpenablePath(candidate)) {
@@ -195,16 +222,18 @@ export function isFilePathCodeSpan(code: string): boolean {
if (isOpenablePath(trimmed)) {
return true
}
- // Separator-less code span: accept a clean name.ext with a known extension.
- if (/[\\/]/.test(trimmed)) {
+ // Separator-less code span: accept a clean name.ext (with an optional
+ // :line(:col) citation tail) and a known extension.
+ const { path } = splitFilePathLineSuffix(trimmed)
+ if (/[\\/]/.test(path)) {
return false
}
- const dot = trimmed.lastIndexOf('.')
+ const dot = path.lastIndexOf('.')
if (dot <= 0) {
return false
}
- const name = trimmed.slice(0, dot)
- const ext = trimmed.slice(dot + 1).toLowerCase()
+ const name = path.slice(0, dot)
+ const ext = path.slice(dot + 1).toLowerCase()
if (/[^\w.@+-]/.test(name)) {
return false
}
diff --git a/mobile/src/components/markdown-href-routing.test.ts b/mobile/src/components/markdown-href-routing.test.ts
new file mode 100644
index 00000000000..7117a2ecbc8
--- /dev/null
+++ b/mobile/src/components/markdown-href-routing.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest'
+import { routeMarkdownHref } from './markdown-href-routing'
+
+describe('routeMarkdownHref', () => {
+ it('routes web and mail links to the system handler', () => {
+ expect(routeMarkdownHref('https://example.com/docs')).toEqual({
+ kind: 'web',
+ url: 'https://example.com/docs'
+ })
+ expect(routeMarkdownHref('http://localhost:3000/')).toEqual({
+ kind: 'web',
+ url: 'http://localhost:3000/'
+ })
+ expect(routeMarkdownHref(' mailto:dev@example.com ')).toEqual({
+ kind: 'web',
+ url: 'mailto:dev@example.com'
+ })
+ })
+
+ it('routes relative hrefs to the file opener', () => {
+ expect(routeMarkdownHref('src/foo.ts')).toEqual({ kind: 'file', pathText: 'src/foo.ts' })
+ expect(routeMarkdownHref('./docs/plan.md')).toEqual({
+ kind: 'file',
+ pathText: './docs/plan.md'
+ })
+ })
+
+ it('carries a #L fragment as a :line suffix', () => {
+ expect(routeMarkdownHref('docs/plan.md#L42')).toEqual({
+ kind: 'file',
+ pathText: 'docs/plan.md:42'
+ })
+ expect(routeMarkdownHref('docs/plan.md?plain=1#line-7')).toEqual({
+ kind: 'file',
+ pathText: 'docs/plan.md:7'
+ })
+ expect(routeMarkdownHref('docs/plan.md#usage')).toEqual({
+ kind: 'file',
+ pathText: 'docs/plan.md'
+ })
+ })
+
+ it('decodes percent-encoded href paths', () => {
+ expect(routeMarkdownHref('docs/release%20notes.md')).toEqual({
+ kind: 'file',
+ pathText: 'docs/release notes.md'
+ })
+ })
+
+ it('routes file: URIs to the file opener', () => {
+ expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx')).toEqual({
+ kind: 'file',
+ pathText: '/Users/me/wt/src/app.tsx'
+ })
+ expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx#L12')).toEqual({
+ kind: 'file',
+ pathText: '/Users/me/wt/src/app.tsx:12'
+ })
+ expect(routeMarkdownHref('file:///C:/repo/src/index.ts')).toEqual({
+ kind: 'file',
+ pathText: 'C:/repo/src/index.ts'
+ })
+ })
+
+ it('keeps Windows drive paths out of the scheme filter', () => {
+ expect(routeMarkdownHref(String.raw`C:\repo\src\index.ts`)).toEqual({
+ kind: 'file',
+ pathText: String.raw`C:\repo\src\index.ts`
+ })
+ })
+
+ it('drops anchors, unknown schemes, and empty hrefs', () => {
+ expect(routeMarkdownHref('#section')).toEqual({ kind: 'none' })
+ expect(routeMarkdownHref('')).toEqual({ kind: 'none' })
+ expect(routeMarkdownHref('editor://file/x.ts')).toEqual({ kind: 'none' })
+ expect(routeMarkdownHref('javascript:alert(1)')).toEqual({ kind: 'none' })
+ expect(routeMarkdownHref('data:text/plain,hi')).toEqual({ kind: 'none' })
+ })
+})
diff --git a/mobile/src/components/markdown-href-routing.ts b/mobile/src/components/markdown-href-routing.ts
new file mode 100644
index 00000000000..61531f937a5
--- /dev/null
+++ b/mobile/src/components/markdown-href-routing.ts
@@ -0,0 +1,18 @@
+import { routeNativeChatHref } from '../../../src/shared/native-chat-href-routing'
+
+export type MarkdownHrefRoute =
+ | { kind: 'web'; url: string }
+ | { kind: 'file'; pathText: string }
+ | { kind: 'none' }
+
+function withLineSuffix(pathText: string, line: number | null): string {
+ return line === null ? pathText : `${pathText}:${line}`
+}
+
+export function routeMarkdownHref(href: string): MarkdownHrefRoute {
+ const route = routeNativeChatHref(href)
+ if (route.kind !== 'file') {
+ return route
+ }
+ return { kind: 'file', pathText: withLineSuffix(route.pathText, route.line) }
+}
diff --git a/mobile/src/components/markdown-inline-token-rules.test.ts b/mobile/src/components/markdown-inline-token-rules.test.ts
new file mode 100644
index 00000000000..27d44a36d9c
--- /dev/null
+++ b/mobile/src/components/markdown-inline-token-rules.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+import {
+ isIntrawordUnderscoreToken,
+ trimAutolinkTrailingPunctuation
+} from './markdown-inline-token-rules'
+
+describe('isIntrawordUnderscoreToken', () => {
+ it('rejects snake_case emphasis spans', () => {
+ const text = 'src/foo_bar.ts and src/baz_qux.ts'
+ const index = text.indexOf('_')
+ const token = text.slice(index, text.lastIndexOf('_') + 1)
+ expect(isIntrawordUnderscoreToken(text, index, token)).toBe(true)
+ })
+
+ it('keeps standalone emphasis', () => {
+ expect(isIntrawordUnderscoreToken('say _hello_ now', 4, '_hello_')).toBe(false)
+ expect(isIntrawordUnderscoreToken('_hello_.', 0, '_hello_')).toBe(false)
+ })
+
+ it('rejects emphasis closed against a following word', () => {
+ expect(isIntrawordUnderscoreToken('_foo_s bar', 0, '_foo_')).toBe(true)
+ })
+
+ it('rejects dunder emphasis inside a path', () => {
+ expect(isIntrawordUnderscoreToken('src/__init__.py', 4, '__init__')).toBe(true)
+ expect(isIntrawordUnderscoreToken(String.raw`src\__init__.py`, 4, '__init__')).toBe(true)
+ })
+
+ it('ignores non-underscore tokens', () => {
+ expect(isIntrawordUnderscoreToken('a*b*c', 1, '*b*')).toBe(false)
+ })
+})
+
+describe('trimAutolinkTrailingPunctuation', () => {
+ it('splits sentence punctuation off the URL', () => {
+ expect(trimAutolinkTrailingPunctuation('https://x.com/a.')).toEqual({
+ url: 'https://x.com/a',
+ trailing: '.'
+ })
+ expect(trimAutolinkTrailingPunctuation('https://x.com/a,')).toEqual({
+ url: 'https://x.com/a',
+ trailing: ','
+ })
+ expect(trimAutolinkTrailingPunctuation('https://x.com/a?!')).toEqual({
+ url: 'https://x.com/a',
+ trailing: '?!'
+ })
+ })
+
+ it('keeps balanced parens and strips unbalanced ones', () => {
+ expect(trimAutolinkTrailingPunctuation('https://x.com/a_(b)')).toEqual({
+ url: 'https://x.com/a_(b)',
+ trailing: ''
+ })
+ expect(trimAutolinkTrailingPunctuation('https://x.com/a).')).toEqual({
+ url: 'https://x.com/a',
+ trailing: ').'
+ })
+ })
+
+ it('handles long unmatched closing-parenthesis tails', () => {
+ const url = 'https://x.com/a_(b)'
+ const trailing = ')'.repeat(4096)
+ expect(trimAutolinkTrailingPunctuation(`${url}${trailing}`)).toEqual({ url, trailing })
+ })
+
+ it('leaves clean URLs untouched', () => {
+ expect(trimAutolinkTrailingPunctuation('https://x.com/a')).toEqual({
+ url: 'https://x.com/a',
+ trailing: ''
+ })
+ })
+})
diff --git a/mobile/src/components/markdown-inline-token-rules.ts b/mobile/src/components/markdown-inline-token-rules.ts
new file mode 100644
index 00000000000..1afa8477fbb
--- /dev/null
+++ b/mobile/src/components/markdown-inline-token-rules.ts
@@ -0,0 +1,55 @@
+// Post-checks for inline markdown tokens that a single-pass tokenizer regex
+// cannot express on its own.
+
+const INTRAWORD_FLANK_PATTERN = /[\w\\/]/
+
+/**
+ * True when a `_…_` / `__…__` token sits inside a word (snake_case, dunder
+ * tails). CommonMark treats intraword underscores as literal text; path
+ * separators count as flanks so dunder path segments also stay whole.
+ */
+export function isIntrawordUnderscoreToken(text: string, index: number, token: string): boolean {
+ if (!token.startsWith('_')) {
+ return false
+ }
+ const prev = index > 0 ? text[index - 1]! : ''
+ const next = text[index + token.length] ?? ''
+ return INTRAWORD_FLANK_PATTERN.test(prev) || INTRAWORD_FLANK_PATTERN.test(next)
+}
+
+/**
+ * Split sentence punctuation off an autolinked URL tail ("see https://x.com/a."),
+ * keeping a trailing ')' only when the URL itself opened a paren.
+ */
+export function trimAutolinkTrailingPunctuation(url: string): { url: string; trailing: string } {
+ let end = url.length
+ let parenthesisCountsReady = false
+ let openParentheses = 0
+ let closeParentheses = 0
+ while (end > 0) {
+ const char = url[end - 1]!
+ if ('.,;:!?'.includes(char)) {
+ end--
+ continue
+ }
+ if (char === ')') {
+ if (!parenthesisCountsReady) {
+ for (let index = 0; index < end; index++) {
+ if (url[index] === '(') {
+ openParentheses++
+ } else if (url[index] === ')') {
+ closeParentheses++
+ }
+ }
+ parenthesisCountsReady = true
+ }
+ if (closeParentheses > openParentheses) {
+ end--
+ closeParentheses--
+ continue
+ }
+ }
+ break
+ }
+ return { url: url.slice(0, end), trailing: url.slice(end) }
+}
diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx
index 77c5ab72fed..ba587bab616 100644
--- a/mobile/src/session/MobileNativeChatOverlay.tsx
+++ b/mobile/src/session/MobileNativeChatOverlay.tsx
@@ -8,6 +8,9 @@ import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-str
type Props = {
controller: MobileNativeChatController
+ /** Opens a tapped file reference (worktree-relative or absolute, optional
+ * :line(:col) suffix) through the shared tap-to-open flow. */
+ onOpenFile: (pathText: string) => void
/** Native-chat image attachments: picking adds a composer chip, and sending
* rides the pending images along with the message text (desktop parity). */
images: MobileNativeChatImageAttachments
@@ -30,6 +33,7 @@ type Props = {
* the chat list below it does not. */
export function MobileNativeChatOverlay({
controller,
+ onOpenFile,
images,
onMicPress,
micActive,
@@ -70,7 +74,7 @@ export function MobileNativeChatOverlay({
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
- onOpenFile={controller.handleNativeChatOpenFile}
+ onOpenFile={onOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx
index 1a2ae71ca66..f6394736528 100644
--- a/mobile/src/session/MobileNativeChatView.tsx
+++ b/mobile/src/session/MobileNativeChatView.tsx
@@ -274,6 +274,9 @@ export function MobileNativeChatView({
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
+ // Let link/file taps land while the composer keyboard is up
+ // instead of being swallowed by the dismiss gesture.
+ keyboardShouldPersistTaps="handled"
onScroll={onScroll}
scrollEventThrottle={32}
onContentSizeChange={() => {
diff --git a/mobile/src/session/mobile-terminal-file-tap-open.test.ts b/mobile/src/session/mobile-file-tap-open.test.ts
similarity index 72%
rename from mobile/src/session/mobile-terminal-file-tap-open.test.ts
rename to mobile/src/session/mobile-file-tap-open.test.ts
index a958b977260..d2a324d2c12 100644
--- a/mobile/src/session/mobile-terminal-file-tap-open.test.ts
+++ b/mobile/src/session/mobile-file-tap-open.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open'
+import { openMobileFileTap } from './mobile-file-tap-open'
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
@@ -22,7 +22,7 @@ function activeTerminalState(activated: boolean) {
}
}
-describe('openMobileTerminalFileTap', () => {
+describe('openMobileFileTap', () => {
it('opens absolute terminal artifacts through the grant-backed preview route', async () => {
const client = createClient([
ok({
@@ -42,7 +42,7 @@ describe('openMobileTerminalFileTap', () => {
const pushPreviewRoute = vi.fn()
const triggerOpenFeedback = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -106,7 +106,7 @@ describe('openMobileTerminalFileTap', () => {
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
const switchSessionTab = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -153,7 +153,7 @@ describe('openMobileTerminalFileTap', () => {
])
const pushPreviewRoute = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -204,7 +204,7 @@ describe('openMobileTerminalFileTap', () => {
const pushPreviewRoute = vi.fn()
const triggerOpenFeedback = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -258,7 +258,7 @@ describe('openMobileTerminalFileTap', () => {
])
const openBrowser = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -299,7 +299,7 @@ describe('openMobileTerminalFileTap', () => {
ok({ opened: true })
])
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -346,7 +346,7 @@ describe('openMobileTerminalFileTap', () => {
])
const openBrowser = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -387,7 +387,7 @@ describe('openMobileTerminalFileTap', () => {
let activeTerminalHandle: string | null = 'terminal-1'
const pushPreviewRoute = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -431,6 +431,180 @@ describe('openMobileTerminalFileTap', () => {
expect(pushPreviewRoute).not.toHaveBeenCalled()
})
+ it('reports a failed files.open through onOpenFailed', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts'
+ }
+ }),
+ { ok: false, error: { message: 'nope' } }
+ ])
+ const onOpenFailed = vi.fn()
+
+ openMobileFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'src/index.ts',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activeTerminalState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn(),
+ onOpenFailed
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(onOpenFailed).toHaveBeenCalledTimes(1)
+ })
+
+ it('reports an unsupported file when files.open declines it', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'dist/app.zip',
+ absolutePath: '/repo/dist/app.zip',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'dist/app.zip',
+ absolutePath: '/repo/dist/app.zip'
+ }
+ }),
+ ok({ worktree: 'wt-1', relativePath: 'dist/app.zip', kind: 'binary', opened: false })
+ ])
+ const onOpenFailed = vi.fn()
+ const scheduleDelayedAction = vi.fn()
+
+ openMobileFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'dist/app.zip',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activeTerminalState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction,
+ onOpenFailed
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(onOpenFailed).toHaveBeenCalledTimes(1)
+ expect(scheduleDelayedAction).not.toHaveBeenCalled()
+ })
+
+ it('does not report a stale failure after a newer tap supersedes it', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: null,
+ exists: false,
+ isDirectory: false
+ })
+ ])
+ const onOpenFailed = vi.fn()
+
+ openMobileFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'gone/missing.ts',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: (activated) => ({
+ ...activeTerminalState(activated),
+ latestActivationSeq: 2
+ }),
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn(),
+ onOpenFailed
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(onOpenFailed).not.toHaveBeenCalled()
+ })
+
+ it('does not report a failure when the user left the source tab mid-resolve', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts'
+ }
+ })
+ ])
+ const onOpenFailed = vi.fn()
+
+ openMobileFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'src/index.ts',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: (activated) => ({
+ ...activeTerminalState(activated),
+ activeTerminalHandle: 'terminal-2'
+ }),
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn(),
+ onOpenFailed
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(onOpenFailed).not.toHaveBeenCalled()
+ })
+
it('does not activate a worktree file tab after a newer tap supersedes it', async () => {
const client = createClient([
ok({
@@ -452,7 +626,7 @@ describe('openMobileTerminalFileTap', () => {
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
const switchSessionTab = vi.fn()
- openMobileTerminalFileTap({
+ openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
diff --git a/mobile/src/session/mobile-terminal-file-tap-open.ts b/mobile/src/session/mobile-file-tap-open.ts
similarity index 78%
rename from mobile/src/session/mobile-terminal-file-tap-open.ts
rename to mobile/src/session/mobile-file-tap-open.ts
index 8d6e681b0f4..00b98ce214d 100644
--- a/mobile/src/session/mobile-terminal-file-tap-open.ts
+++ b/mobile/src/session/mobile-file-tap-open.ts
@@ -1,4 +1,7 @@
-import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
+import type {
+ RuntimeFileOpenResult,
+ RuntimeTerminalPathResolution
+} from '../../../src/shared/runtime-types'
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
import { classifyMobileArtifact } from './mobile-artifact-kind'
@@ -6,12 +9,12 @@ import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
-type TerminalFileTapSessionTab = {
+export type FileTapSessionTab = {
id: string
relativePath?: string
}
-type OpenMobileTerminalFileTapOptions = {
+export type OpenMobileFileTapOptions = {
client: Pick
hostId: string
worktreeId: string
@@ -37,19 +40,34 @@ type OpenMobileTerminalFileTapOptions = {
}
switchSessionTab: (tab: T) => void
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
+ /** Invoked when the tap cannot open anything (resolve miss, directory, or a
+ * failed open). Omitted on surfaces that keep the historical silent miss. */
+ onOpenFailed?: () => void
}
-export function openMobileTerminalFileTap(
- options: OpenMobileTerminalFileTapOptions
+export function openMobileFileTap(
+ options: OpenMobileFileTapOptions
): void {
- void openMobileTerminalFileTapAsync(options).catch(() => {
- // Terminal file taps are best-effort: a failed host resolution should leave
- // terminal focus/input untouched, matching the existing silent miss behavior.
+ void openMobileFileTapAsync(options).catch(() => {
+ // File taps are best-effort: a failed host resolution should leave terminal
+ // focus/input untouched. Surfaces that want feedback pass onOpenFailed.
+ reportOpenFailure(options)
})
}
-async function openMobileTerminalFileTapAsync(
- options: OpenMobileTerminalFileTapOptions
+function reportOpenFailure(
+ options: OpenMobileFileTapOptions
+): void {
+ if (
+ options.onOpenFailed &&
+ shouldActivateOpenedMobileSessionTab(options.getActivationState(false))
+ ) {
+ options.onOpenFailed()
+ }
+}
+
+async function openMobileFileTapAsync(
+ options: OpenMobileFileTapOptions
): Promise {
const worktree = `id:${options.worktreeId}`
const response = await options.client.sendRequest(
@@ -65,12 +83,15 @@ async function openMobileTerminalFileTapAsync(
- options: OpenMobileTerminalFileTapOptions,
+function scheduleOpenedWorktreeTabActivation(
+ options: OpenMobileFileTapOptions,
openedPath: string
): void {
let activated = false
diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts
index e2bf001ffdf..c79878e92d1 100644
--- a/mobile/src/session/mobile-native-chat-open-file.test.ts
+++ b/mobile/src/session/mobile-native-chat-open-file.test.ts
@@ -1,94 +1,153 @@
import { describe, expect, it, vi } from 'vitest'
-import type { RpcClient } from '../transport/rpc-client'
-import {
- openMobileNativeChatFile,
- resolveMobileNativeChatWorktreePath
-} from './mobile-native-chat-open-file'
+import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
-describe('resolveMobileNativeChatWorktreePath', () => {
- it('resolves an absolute tool path to a worktree-relative open target', async () => {
- const sendRequest = vi.fn().mockResolvedValue({
- ok: true,
- result: {
- exists: true,
- isDirectory: false,
- openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
- }
- })
- await expect(
- resolveMobileNativeChatWorktreePath({
- client: { sendRequest } as unknown as RpcClient,
- worktreeId: 'worktree',
- pathText: '/repo/src/app.ts',
- terminal: 'terminal'
- })
- ).resolves.toBe('src/app.ts')
- expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', {
- worktree: 'id:worktree',
- pathText: '/repo/src/app.ts',
- terminal: 'terminal'
- })
+function ok(result: unknown) {
+ return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function activationState(activated: boolean) {
+ return {
+ activated,
+ activationSeq: 1,
+ latestActivationSeq: 1,
+ sourceTerminalHandle: 'terminal-1',
+ activeTerminalHandle: 'terminal-1',
+ activeTabType: 'terminal'
+ }
+}
+
+function baseOptions(client: { sendRequest: ReturnType }) {
+ return {
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activationState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn(),
+ onOpenFailed: vi.fn()
+ }
+}
+
+function worktreeFileResolution(relativePath: string) {
+ return ok({
+ worktree: 'wt-1',
+ relativePath,
+ absolutePath: `/repo/${relativePath}`,
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath,
+ absolutePath: `/repo/${relativePath}`
+ }
+ })
+}
+
+describe('openMobileNativeChatFileTap', () => {
+ it('resolves against the worktree root: no terminal handle and no cwd', async () => {
+ const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
+ const options = baseOptions({ sendRequest })
+
+ openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
+ await Promise.resolve()
+
+ expect(sendRequest).toHaveBeenCalledWith(
+ 'files.resolveTerminalPath',
+ { worktree: 'id:wt-1', pathText: 'src/app.ts' },
+ { timeoutMs: 10_000 }
+ )
})
- it('opens only the resolved worktree-relative target', async () => {
- const sendRequest = vi
- .fn()
- .mockResolvedValueOnce({
- ok: true,
- result: {
- worktree: 'sibling-worktree',
- exists: true,
- isDirectory: false,
- openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
- }
+ it('parses a :line:col citation and opens the mobile preview route', async () => {
+ const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
+ const options = baseOptions({ sendRequest })
+
+ openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts:120:7' })
+ await Promise.resolve()
+
+ expect(sendRequest).toHaveBeenCalledWith(
+ 'files.resolveTerminalPath',
+ { worktree: 'id:wt-1', pathText: 'src/app.ts' },
+ { timeoutMs: 10_000 }
+ )
+ expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1)
+ expect(options.pushPreviewRoute).toHaveBeenCalledWith({
+ pathname: '/h/[hostId]/files/preview/[worktreeId]',
+ params: expect.objectContaining({
+ source: 'worktree',
+ relativePath: 'src/app.ts',
+ line: '120',
+ column: '7'
})
- .mockResolvedValueOnce({ ok: true, result: {} })
-
- await openMobileNativeChatFile({
- client: { sendRequest } as unknown as RpcClient,
- worktreeId: 'worktree',
- pathText: '../repo/src/app.ts',
- terminal: 'terminal'
- })
-
- expect(sendRequest).toHaveBeenLastCalledWith('files.open', {
- worktree: 'id:sibling-worktree',
- relativePath: 'src/app.ts'
})
+ expect(options.onOpenFailed).not.toHaveBeenCalled()
})
- it('resolves null when the resolve request rejects', async () => {
- const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out'))
- await expect(
- resolveMobileNativeChatWorktreePath({
- client: { sendRequest } as unknown as RpcClient,
- worktreeId: 'worktree',
- pathText: 'src/app.ts',
- terminal: null
+ it('surfaces a resolve miss instead of a silent no-op', async () => {
+ const sendRequest = vi.fn(async () =>
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: null,
+ exists: false,
+ isDirectory: false
})
- ).resolves.toBeNull()
+ )
+ const options = baseOptions({ sendRequest })
+
+ openMobileNativeChatFileTap({ ...options, pathText: 'gone/missing.ts' })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
+ expect(options.pushPreviewRoute).not.toHaveBeenCalled()
+ expect(options.triggerOpenFeedback).not.toHaveBeenCalled()
})
- it('does not reject when the open request fails', async () => {
- const sendRequest = vi
- .fn()
- .mockResolvedValueOnce({
- ok: true,
- result: {
- exists: true,
- isDirectory: false,
- openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
- }
- })
- .mockRejectedValueOnce(new Error('connection interrupted'))
+ it('surfaces a rejected resolve request', async () => {
+ const sendRequest = vi.fn(async () => {
+ throw new Error('Request timed out')
+ })
+ const options = baseOptions({ sendRequest })
- await expect(
- openMobileNativeChatFile({
- client: { sendRequest } as unknown as RpcClient,
- worktreeId: 'worktree',
- pathText: 'src/app.ts',
- terminal: null
- })
- ).resolves.toBeUndefined()
+ openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
+ })
+
+ it('opens a plain path through files.open with tab activation', async () => {
+ const responses: unknown[] = [worktreeFileResolution('src/app.ts'), ok({ opened: true })]
+ const sendRequest = vi.fn(async () => responses.shift())
+ const openedTab = { id: 'tab-2', relativePath: 'src/app.ts' }
+ const switchSessionTab = vi.fn()
+ const options = {
+ ...baseOptions({ sendRequest }),
+ getSessionTabs: () => [openedTab],
+ getActiveSessionTabId: () => 'terminal-tab',
+ switchSessionTab,
+ scheduleDelayedAction: vi.fn((callback: () => void) => callback())
+ }
+
+ openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
+ await Promise.resolve()
+ await Promise.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(sendRequest).toHaveBeenCalledWith(
+ 'files.open',
+ { worktree: 'id:wt-1', relativePath: 'src/app.ts' },
+ { timeoutMs: 15_000 }
+ )
+ expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
+ expect(options.onOpenFailed).not.toHaveBeenCalled()
})
})
diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts
index 6460c82849c..3de6755b4b3 100644
--- a/mobile/src/session/mobile-native-chat-open-file.ts
+++ b/mobile/src/session/mobile-native-chat-open-file.ts
@@ -1,67 +1,30 @@
-import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
-import type { RpcClient } from '../transport/rpc-client'
+import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection'
+import {
+ openMobileFileTap,
+ type FileTapSessionTab,
+ type OpenMobileFileTapOptions
+} from './mobile-file-tap-open'
-type MobileNativeChatWorktreeTarget = {
- worktreeId: string
- relativePath: string
-}
+export type OpenMobileNativeChatFileTapOptions = Omit<
+ OpenMobileFileTapOptions,
+ 'terminalHandle' | 'cwd' | 'line' | 'column'
+>
-async function resolveMobileNativeChatWorktreeTarget(args: {
- client: RpcClient
- worktreeId: string
- pathText: string
- terminal: string | null
-}): Promise {
- try {
- const response = await args.client.sendRequest('files.resolveTerminalPath', {
- worktree: `id:${args.worktreeId}`,
- pathText: args.pathText,
- ...(args.terminal ? { terminal: args.terminal } : {})
- })
- if (!response.ok) {
- return null
- }
- const resolved = response.result as RuntimeTerminalPathResolution
- if (!resolved.exists || resolved.isDirectory) {
- return null
- }
- const relativePath =
- resolved.openTarget?.kind === 'worktree-file'
- ? resolved.openTarget.relativePath
- : (resolved.relativePath ?? null)
- return relativePath
- ? { worktreeId: resolved.worktree?.trim() || args.worktreeId, relativePath }
- : null
- } catch {
- return null
- }
-}
-
-export async function resolveMobileNativeChatWorktreePath(args: {
- client: RpcClient
- worktreeId: string
- pathText: string
- terminal: string | null
-}): Promise {
- return (await resolveMobileNativeChatWorktreeTarget(args))?.relativePath ?? null
-}
-
-export async function openMobileNativeChatFile(args: {
- client: RpcClient
- worktreeId: string
- pathText: string
- terminal: string | null
-}): Promise {
- const target = await resolveMobileNativeChatWorktreeTarget(args)
- if (target) {
- try {
- await args.client.sendRequest('files.open', {
- worktree: `id:${target.worktreeId}`,
- relativePath: target.relativePath
- })
- } catch {
- // Best-effort open; failures surface as a no-op rather than an
- // unhandled rejection.
- }
- }
+/**
+ * Open a file reference tapped in native chat: same haptic / preview-route /
+ * tab-activation flow as terminal taps, but chat paths are worktree-root
+ * relative (or absolute), so resolution deliberately passes no terminal handle
+ * and no cwd — a terminal's live cwd (e.g. `/mobile`) would misplace
+ * them. Agent-style `path:line(:col)` citations carry their location through.
+ */
+export function openMobileNativeChatFileTap(
+ options: OpenMobileNativeChatFileTapOptions
+): void {
+ const { path, line, column } = splitFilePathLineSuffix(options.pathText)
+ openMobileFileTap({
+ ...options,
+ pathText: path,
+ line,
+ column
+ })
}
diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts
new file mode 100644
index 00000000000..a795d8d9ed6
--- /dev/null
+++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts
@@ -0,0 +1,134 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers'
+
+const push = vi.fn()
+
+vi.mock('expo-router', () => ({ useRouter: () => ({ push }) }))
+vi.mock('../platform/haptics', () => ({ triggerSelection: vi.fn() }))
+
+type Handlers = ReturnType
+
+function ok(result: unknown) {
+ return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+describe('useMobileFileTapHandlers', () => {
+ let renderer: ReactTestRenderer | null = null
+ let handlers: Handlers | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ push.mockClear()
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ handlers = null
+ })
+
+ function createOptions(sendRequest: ReturnType) {
+ return {
+ client: { sendRequest },
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ worktreeName: 'Orca',
+ activeHandleRef: { current: 'terminal-1' as string | null },
+ terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) },
+ openBrowser: vi.fn(),
+ fetchSessionTabs: vi.fn(async () => {}),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActiveSessionTabType: () => 'terminal',
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn(),
+ reportChatTapFailure: vi.fn()
+ }
+ }
+
+ function Harness({ options }: { options: ReturnType }): null {
+ handlers = useMobileFileTapHandlers(options)
+ return null
+ }
+
+ it('keeps handler identities stable across rerenders', () => {
+ const options = createOptions(vi.fn())
+ act(() => {
+ renderer = create(createElement(Harness, { options }))
+ })
+ const first = handlers
+ act(() => {
+ renderer!.update(createElement(Harness, { options: { ...options } }))
+ })
+ expect(handlers!.handleFileTap).toBe(first!.handleFileTap)
+ expect(handlers!.handleNativeChatFileTap).toBe(first!.handleNativeChatFileTap)
+ })
+
+ it('dispatches through the latest options after a rerender', () => {
+ const firstSendRequest = vi.fn()
+ const firstOptions = createOptions(firstSendRequest)
+ act(() => {
+ renderer = create(createElement(Harness, { options: firstOptions }))
+ })
+
+ const latestSendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
+ act(() => {
+ renderer!.update(
+ createElement(Harness, {
+ options: { ...firstOptions, client: { sendRequest: latestSendRequest } }
+ })
+ )
+ })
+ handlers!.handleFileTap('terminal-1', 'index.ts', null, null)
+
+ expect(firstSendRequest).not.toHaveBeenCalled()
+ expect(latestSendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('resolves terminal taps with the terminal handle and cwd', async () => {
+ const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
+ act(() => {
+ renderer = create(createElement(Harness, { options: createOptions(sendRequest) }))
+ })
+
+ handlers!.handleFileTap('terminal-1', 'index.ts', null, null)
+ await act(async () => {})
+
+ expect(sendRequest).toHaveBeenCalledWith(
+ 'files.resolveTerminalPath',
+ { worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'terminal-1', cwd: '/repo/sub' },
+ { timeoutMs: 10_000 }
+ )
+ })
+
+ it('ignores terminal taps from a non-active handle', () => {
+ const sendRequest = vi.fn()
+ act(() => {
+ renderer = create(createElement(Harness, { options: createOptions(sendRequest) }))
+ })
+
+ handlers!.handleFileTap('terminal-2', 'index.ts', null, null)
+
+ expect(sendRequest).not.toHaveBeenCalled()
+ })
+
+ it('resolves chat taps against the worktree root and reports a miss', async () => {
+ const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
+ const options = createOptions(sendRequest)
+ act(() => {
+ renderer = create(createElement(Harness, { options }))
+ })
+
+ handlers!.handleNativeChatFileTap('mobile/src/x.ts:12')
+ await act(async () => {})
+
+ expect(sendRequest).toHaveBeenCalledWith(
+ 'files.resolveTerminalPath',
+ { worktree: 'id:wt-1', pathText: 'mobile/src/x.ts' },
+ { timeoutMs: 10_000 }
+ )
+ expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
+ })
+})
diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts
new file mode 100644
index 00000000000..276be5b566f
--- /dev/null
+++ b/mobile/src/session/use-mobile-file-tap-handlers.ts
@@ -0,0 +1,174 @@
+import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
+import { useRouter } from 'expo-router'
+import { triggerSelection } from '../platform/haptics'
+import type { RpcClient } from '../transport/rpc-client'
+import { openMobileFileTap, type FileTapSessionTab } from './mobile-file-tap-open'
+import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
+
+type MobileFileTapHandlerOptions = {
+ client: Pick | null
+ hostId: string
+ worktreeId: string
+ worktreeName?: string
+ activeHandleRef: MutableRefObject
+ terminalCwdRef: MutableRefObject>
+ openBrowser: (url: string) => void
+ fetchSessionTabs: () => Promise
+ getSessionTabs: () => readonly T[]
+ getActiveSessionTabId: () => string | null
+ getActiveSessionTabType: () => string | null
+ switchSessionTab: (tab: T) => void
+ scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
+ reportChatTapFailure: (message: string) => void
+}
+
+/**
+ * Tap-to-open handlers for file references, shared by the terminal (link taps
+ * with the terminal's cwd) and native chat (worktree-root-relative paths, with
+ * failure feedback). Handlers are identity-stable and read the latest options at
+ * dispatch time; the shared activation seq lets a newer tap on either surface
+ * supersede an in-flight one.
+ */
+export function useMobileFileTapHandlers(
+ options: MobileFileTapHandlerOptions
+): {
+ handleFileTap: (
+ handle: string,
+ pathText: string,
+ line: number | null,
+ column: number | null
+ ) => void
+ handleNativeChatFileTap: (pathText: string) => void
+} {
+ const {
+ activeHandleRef,
+ client,
+ fetchSessionTabs,
+ getActiveSessionTabId,
+ getActiveSessionTabType,
+ getSessionTabs,
+ hostId,
+ openBrowser,
+ scheduleDelayedAction,
+ reportChatTapFailure,
+ switchSessionTab,
+ terminalCwdRef,
+ worktreeId,
+ worktreeName
+ } = options
+ const router = useRouter()
+ const routerRef = useRef(router)
+ const optionsRef = useRef(options)
+ const activationSeqRef = useRef(0)
+
+ useLayoutEffect(() => {
+ routerRef.current = router
+ optionsRef.current = {
+ activeHandleRef,
+ client,
+ fetchSessionTabs,
+ getActiveSessionTabId,
+ getActiveSessionTabType,
+ getSessionTabs,
+ hostId,
+ openBrowser,
+ scheduleDelayedAction,
+ reportChatTapFailure,
+ switchSessionTab,
+ terminalCwdRef,
+ worktreeId,
+ worktreeName
+ }
+ }, [
+ activeHandleRef,
+ client,
+ fetchSessionTabs,
+ getActiveSessionTabId,
+ getActiveSessionTabType,
+ getSessionTabs,
+ hostId,
+ openBrowser,
+ router,
+ scheduleDelayedAction,
+ reportChatTapFailure,
+ switchSessionTab,
+ terminalCwdRef,
+ worktreeId,
+ worktreeName
+ ])
+
+ const handleFileTap = useCallback(
+ (handle: string, pathText: string, line: number | null, column: number | null) => {
+ const current = optionsRef.current
+ if (handle !== current.activeHandleRef.current || !current.client) {
+ return
+ }
+ const activationSeq = ++activationSeqRef.current
+ openMobileFileTap({
+ client: current.client,
+ hostId: current.hostId,
+ worktreeId: current.worktreeId,
+ worktreeName: current.worktreeName,
+ terminalHandle: handle,
+ pathText,
+ cwd: current.terminalCwdRef.current.get(handle) ?? null,
+ line,
+ column,
+ pushPreviewRoute: (href) => routerRef.current.push(href),
+ openBrowser: current.openBrowser,
+ triggerOpenFeedback: triggerSelection,
+ fetchSessionTabs: current.fetchSessionTabs,
+ getSessionTabs: current.getSessionTabs,
+ getActiveSessionTabId: current.getActiveSessionTabId,
+ getActivationState: (activated) => ({
+ activated,
+ activationSeq,
+ latestActivationSeq: activationSeqRef.current,
+ sourceTerminalHandle: handle,
+ activeTerminalHandle: current.activeHandleRef.current,
+ activeTabType: current.getActiveSessionTabType()
+ }),
+ switchSessionTab: current.switchSessionTab,
+ scheduleDelayedAction: current.scheduleDelayedAction
+ })
+ },
+ []
+ )
+
+ const handleNativeChatFileTap = useCallback((pathText: string) => {
+ const current = optionsRef.current
+ // The chat overlay rides on its backing terminal tab; that handle anchors
+ // the activation gate even though resolution ignores the terminal's cwd.
+ const sourceTerminalHandle = current.activeHandleRef.current
+ if (!current.client || !sourceTerminalHandle) {
+ return
+ }
+ const activationSeq = ++activationSeqRef.current
+ openMobileNativeChatFileTap({
+ client: current.client,
+ hostId: current.hostId,
+ worktreeId: current.worktreeId,
+ worktreeName: current.worktreeName,
+ pathText,
+ pushPreviewRoute: (href) => routerRef.current.push(href),
+ openBrowser: current.openBrowser,
+ triggerOpenFeedback: triggerSelection,
+ fetchSessionTabs: current.fetchSessionTabs,
+ getSessionTabs: current.getSessionTabs,
+ getActiveSessionTabId: current.getActiveSessionTabId,
+ getActivationState: (activated) => ({
+ activated,
+ activationSeq,
+ latestActivationSeq: activationSeqRef.current,
+ sourceTerminalHandle,
+ activeTerminalHandle: current.activeHandleRef.current,
+ activeTabType: current.getActiveSessionTabType()
+ }),
+ switchSessionTab: current.switchSessionTab,
+ scheduleDelayedAction: current.scheduleDelayedAction,
+ onOpenFailed: () => current.reportChatTapFailure(`Couldn't open ${pathText}`)
+ })
+ }, [])
+
+ return { handleFileTap, handleNativeChatFileTap }
+}
diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts
index 37f7b5399c2..ad8adce189c 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.ts
@@ -1,10 +1,4 @@
-import {
- useCallback,
- useRef,
- type Dispatch,
- type MutableRefObject,
- type SetStateAction
-} from 'react'
+import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -16,7 +10,6 @@ import {
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import { detectAgentPermission } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
-import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
@@ -56,7 +49,6 @@ export type MobileNativeChatController = {
nativeChatPermission: ReturnType
nativeChatQuestion: ReturnType
nativeChatAsk: ReturnType
- handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
@@ -188,21 +180,6 @@ export function useMobileNativeChatController(args: {
messages: nativeChatSession.messages
})
- const handleNativeChatOpenFile = useCallback(
- (pathText: string) => {
- if (!client) {
- return
- }
- void openMobileNativeChatFile({
- client,
- worktreeId,
- pathText,
- terminal: activeHandleRef.current
- })
- },
- [activeHandleRef, client, worktreeId]
- )
-
// Every chat write gates on both: the lease proves the input floor is ours, and
// `connState` collapses a render before the lease does on disconnect.
const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
@@ -290,7 +267,6 @@ export function useMobileNativeChatController(args: {
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
- handleNativeChatOpenFile,
handleNativeChatAnswerAsk: answerAsk,
handleNativeChatCancelAsk: cancelAsk,
handleNativeChatRespondPermission: respond,
diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts
index f6531adaf35..eee08ceae2b 100644
--- a/src/renderer/src/components/native-chat/native-chat-file-link.ts
+++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts
@@ -1,5 +1,4 @@
-import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path'
-import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
+import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing'
import type { Worktree } from '../../../../shared/types'
import {
parseExplicitFileLinkTarget,
@@ -82,50 +81,6 @@ export function resolveNativeChatFileLinkContext(
}
}
-function maybeDecodeHrefPath(value: string): string {
- try {
- return decodeURIComponent(value)
- } catch {
- return value
- }
-}
-
-function stripQueryAndHash(value: string): { pathText: string; line: number | null } {
- const hashIndex = value.indexOf('#')
- const queryIndex = value.indexOf('?')
- const suffixIndex =
- hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex)
- const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex)
- const hash =
- hashIndex === -1
- ? ''
- : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined)
- const line = parseLineFragment(hash)
- return { pathText, line }
-}
-
-function parseLineFragment(hash: string): number | null {
- if (!hash) {
- return null
- }
- let decoded = hash
- try {
- decoded = decodeURIComponent(hash)
- } catch {
- decoded = hash
- }
- const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded)
- return match ? Number.parseInt(match[1], 10) : null
-}
-
-function hasNonFileUriProtocol(value: string): boolean {
- if (isWindowsAbsolutePathLike(value)) {
- return false
- }
- const match = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(value)
- return Boolean(match && match[0].toLowerCase() !== 'file:')
-}
-
function resolvePathText(
pathText: string,
fallbackLine: number | null,
@@ -148,43 +103,16 @@ function resolvePathText(
}
}
-function resolveFileUriLink(
- href: string,
- context: NativeChatFileLinkContext
-): NativeChatResolvedFileLink | null {
- let url: URL
- try {
- url = new URL(href)
- } catch {
- return null
- }
- if (url.protocol !== 'file:') {
- return null
- }
- const filePath = fileUriToFilesystemPath(url)
- if (!filePath) {
- return null
- }
- return resolvePathText(filePath, parseLineFragment(url.hash.replace(/^#/, '')), context)
-}
-
export function resolveNativeChatFileLink(
href: string | undefined,
context: NativeChatFileLinkContext | null
): NativeChatResolvedFileLink | null {
- const rawHref = href?.trim()
- if (!rawHref || rawHref.startsWith('#') || !context) {
+ if (!context) {
return null
}
-
- if (rawHref.toLowerCase().startsWith('file:')) {
- return resolveFileUriLink(rawHref, context)
- }
- if (hasNonFileUriProtocol(rawHref)) {
+ const route = routeNativeChatHref(href)
+ if (route.kind !== 'file') {
return null
}
-
- const { pathText, line } = stripQueryAndHash(rawHref)
- const decodedPathText = maybeDecodeHrefPath(pathText)
- return resolvePathText(decodedPathText, line, context)
+ return resolvePathText(route.pathText, route.line, context)
}
diff --git a/src/shared/native-chat-href-routing.test.ts b/src/shared/native-chat-href-routing.test.ts
new file mode 100644
index 00000000000..1b7c276ffd3
--- /dev/null
+++ b/src/shared/native-chat-href-routing.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { routeNativeChatHref } from './native-chat-href-routing'
+
+describe('routeNativeChatHref', () => {
+ it('classifies web and mail links', () => {
+ expect(routeNativeChatHref('https://example.com/docs')).toEqual({
+ kind: 'web',
+ url: 'https://example.com/docs'
+ })
+ expect(routeNativeChatHref(' mailto:dev@example.com ')).toEqual({
+ kind: 'web',
+ url: 'mailto:dev@example.com'
+ })
+ })
+
+ it('parses relative file hrefs and line fragments', () => {
+ expect(routeNativeChatHref('docs/plan.md?plain=1#line-7')).toEqual({
+ kind: 'file',
+ pathText: 'docs/plan.md',
+ line: 7
+ })
+ expect(routeNativeChatHref('docs/plan.md#usage')).toEqual({
+ kind: 'file',
+ pathText: 'docs/plan.md',
+ line: null
+ })
+ })
+
+ it('decodes relative and file URI paths', () => {
+ expect(routeNativeChatHref('docs/release%20notes.md')).toEqual({
+ kind: 'file',
+ pathText: 'docs/release notes.md',
+ line: null
+ })
+ expect(routeNativeChatHref('file:///Users/me/wt/My%20File.tsx#L12')).toEqual({
+ kind: 'file',
+ pathText: '/Users/me/wt/My File.tsx',
+ line: 12
+ })
+ })
+
+ it('keeps Windows drive paths out of the scheme filter', () => {
+ expect(routeNativeChatHref(String.raw`C:\repo\src\index.ts`)).toEqual({
+ kind: 'file',
+ pathText: String.raw`C:\repo\src\index.ts`,
+ line: null
+ })
+ })
+
+ it('drops anchors, unknown schemes, malformed file URIs, and empty hrefs', () => {
+ expect(routeNativeChatHref('#section')).toEqual({ kind: 'none' })
+ expect(routeNativeChatHref(undefined)).toEqual({ kind: 'none' })
+ expect(routeNativeChatHref('editor://file/x.ts')).toEqual({ kind: 'none' })
+ expect(routeNativeChatHref('javascript:alert(1)')).toEqual({ kind: 'none' })
+ expect(routeNativeChatHref('file:///tmp/%E0%A4%A.txt')).toEqual({ kind: 'none' })
+ })
+})
diff --git a/src/shared/native-chat-href-routing.ts b/src/shared/native-chat-href-routing.ts
new file mode 100644
index 00000000000..005ab957f65
--- /dev/null
+++ b/src/shared/native-chat-href-routing.ts
@@ -0,0 +1,74 @@
+import { isWindowsAbsolutePathLike } from './cross-platform-path'
+import { fileUriToFilesystemPath } from './file-uri-path'
+
+export type NativeChatHrefRoute =
+ | { kind: 'web'; url: string }
+ | { kind: 'file'; pathText: string; line: number | null }
+ | { kind: 'none' }
+
+const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i
+const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/
+
+function parseLineFragment(hash: string): number | null {
+ if (!hash) {
+ return null
+ }
+ let decoded = hash
+ try {
+ decoded = decodeURIComponent(hash)
+ } catch {
+ // Keep the raw fragment when decoding fails.
+ }
+ const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded)
+ return match ? Number.parseInt(match[1]!, 10) : null
+}
+
+function stripQueryAndHash(value: string): { pathText: string; line: number | null } {
+ const hashIndex = value.indexOf('#')
+ const queryIndex = value.indexOf('?')
+ const suffixIndex =
+ hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex)
+ const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex)
+ const hash =
+ hashIndex === -1
+ ? ''
+ : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined)
+ return { pathText, line: parseLineFragment(hash) }
+}
+
+function maybeDecodeHrefPath(value: string): string {
+ try {
+ return decodeURIComponent(value)
+ } catch {
+ return value
+ }
+}
+
+export function routeNativeChatHref(href: string | null | undefined): NativeChatHrefRoute {
+ const trimmed = href?.trim()
+ if (!trimmed || trimmed.startsWith('#')) {
+ return { kind: 'none' }
+ }
+ if (WEB_SCHEME_PATTERN.test(trimmed)) {
+ return { kind: 'web', url: trimmed }
+ }
+ if (/^file:/i.test(trimmed)) {
+ let url: URL
+ try {
+ url = new URL(trimmed)
+ } catch {
+ return { kind: 'none' }
+ }
+ const pathText = fileUriToFilesystemPath(url)
+ if (!pathText) {
+ return { kind: 'none' }
+ }
+ return { kind: 'file', pathText, line: parseLineFragment(url.hash.slice(1)) }
+ }
+ if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) {
+ return { kind: 'none' }
+ }
+ const { pathText, line } = stripQueryAndHash(trimmed)
+ const decodedPathText = maybeDecodeHrefPath(pathText)
+ return decodedPathText ? { kind: 'file', pathText: decodedPathText, line } : { kind: 'none' }
+}
From 40ea4ece1a5d8eaf97920248db59f520fb714b01 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:47:29 -0700
Subject: [PATCH 15/42] Track Claude models from the installed CLI per host
(STA-3330) (#12369)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(native-chat): track Claude models from the installed CLI per host (STA-3330)
The Claude seed no longer pins version labels to aliases that resolve
differently across CLI versions, and the catalog now defines listModels
backed by a one-shot list_models control request over --print stream-json.
Hosts whose CLI predates the request answer with a control error and keep
the seed. Discovery also feeds Source Control AI via the commit-message
spec, and the /model echo detector matches resolved model names.
* fix(native-chat): preserve discovered Claude capabilities
* fix(native-chat): tolerate malformed Claude model entries
* fix(native-chat): discover models in folder workspaces
* fix(native-chat): trust discovered Claude capabilities
* fix(native-chat): remove Claude model fallbacks
* fix(native-chat): keep the Claude model picker rendered
The Claude picker rendered nothing until the per-host `list_models` probe
returned, so it popped in ~1s after mount and never appeared at all when
the probe failed — an old CLI without `list_models`, no `claude` on PATH,
or an older remote runtime whose response omits `catalogOrigin`.
Restore the version-neutral family seed as the starting list; discovery
still replaces it wholesale on success, so a host with a real catalog
never shows an obsolete hardcoded row.
Separately, the tracked model could fall outside the active list: the
terminal header scrape yields family ids (`opus`) while a current CLI
lists `opus[1m]` and no plain `opus`. That blanked the picker trigger and
dropped the model's effort and fast-mode controls. Reconcile the tracked
id into the active list once, so the snapshot, the appliers, and typed
command recording all see a labelled, operable row for it.
---
src/main/ipc/filesystem.test.ts | 105 +++++++++++++++
src/main/ipc/filesystem.ts | 31 ++++-
.../commit-message-text-generation.test.ts | 89 +++++++++++++
.../commit-message-text-generation.ts | 24 +++-
src/preload/api-types.ts | 1 +
.../native-chat/NativeChatComposer.test.tsx | 84 +++++++++++-
.../claude-model-switch-confirmation.test.ts | 50 ++++++++
.../claude-model-switch-confirmation.ts | 21 ++-
.../claude-terminal-session-options.test.ts | 13 ++
.../native-chat-pty-session-options.test.ts | 79 +++++++++++-
.../native-chat-pty-session-options.ts | 31 ++++-
.../native-chat-session-option-discovery.ts | 48 ++++++-
...ive-chat-session-option-enrichment.test.ts | 120 +++++++++++++++++-
.../native-chat-session-option-enrichment.ts | 3 +-
.../native-chat-session-option-snapshot.ts | 18 +--
.../use-native-chat-session-options.ts | 3 +
.../settings/CommitMessageAiPane.test.tsx | 3 +
.../src/runtime/runtime-git-client.ts | 2 +
...ent-session-option-catalog-claude-codex.ts | 67 +++++++++-
.../agent-session-option-catalog.test.ts | 85 +++++++++++++
src/shared/agent-session-option-catalog.ts | 7 +-
src/shared/claude-model-list-probe.test.ts | 114 +++++++++++++++++
src/shared/claude-model-list-probe.ts | 120 ++++++++++++++++++
src/shared/commit-message-agent-spec.test.ts | 76 +++++++++++
src/shared/commit-message-agent-spec.ts | 52 +++++++-
src/shared/commit-message-host-key.ts | 7 +
26 files changed, 1203 insertions(+), 50 deletions(-)
create mode 100644 src/shared/claude-model-list-probe.test.ts
create mode 100644 src/shared/claude-model-list-probe.ts
diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts
index e712a2938fc..78b950fe5a9 100644
--- a/src/main/ipc/filesystem.test.ts
+++ b/src/main/ipc/filesystem.test.ts
@@ -2419,6 +2419,111 @@ describe('registerFilesystemHandlers', () => {
)
})
+ it('discovers models from an exact repo-less folder workspace root', async () => {
+ const folderPath = path.resolve('/outside-workspace/folder-project')
+ const folderStore = {
+ ...store,
+ getFolderWorkspaces: () => [
+ {
+ id: 'folder-1',
+ projectGroupId: 'group-1',
+ folderPath,
+ connectionId: null
+ }
+ ]
+ }
+ discoverCommitMessageModelsLocalMock.mockResolvedValue({
+ success: true,
+ models: [{ id: 'sonnet', label: 'Sonnet' }],
+ defaultModelId: 'sonnet'
+ })
+
+ registerFilesystemHandlers(folderStore as never)
+
+ await handlers.get('git:discoverCommitMessageModels')!(null, {
+ agentId: 'claude',
+ worktreePath: folderPath
+ })
+
+ expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
+ 'claude',
+ undefined,
+ undefined,
+ { cwd: folderPath }
+ )
+ })
+
+ it('does not authorize remote-only folder roots as local discovery paths', async () => {
+ const folderPath = path.resolve('/remote-only/folder-project')
+ const folderStore = {
+ ...store,
+ getFolderWorkspaces: () => [
+ {
+ id: 'folder-1',
+ projectGroupId: 'group-1',
+ folderPath,
+ connectionId: 'ssh-1'
+ }
+ ]
+ }
+
+ registerFilesystemHandlers(folderStore as never)
+
+ await expect(
+ handlers.get('git:discoverCommitMessageModels')!(null, {
+ agentId: 'claude',
+ worktreePath: folderPath
+ })
+ ).rejects.toThrow('Access denied')
+ expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled()
+ })
+
+ it('routes a repo-less WSL folder workspace discovery through its distro', async () => {
+ await withPlatform('win32', async () => {
+ const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\tester\\folder-project'
+ const prepareForClaudeLaunch = vi.fn().mockResolvedValue({
+ configDir: '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.claude',
+ envPatch: { CLAUDE_CONFIG_DIR: '/home/tester/.claude' },
+ stripAuthEnv: true,
+ provenance: 'managed:account-1'
+ })
+ const folderStore = {
+ ...store,
+ getFolderWorkspaces: () => [
+ {
+ id: 'folder-1',
+ projectGroupId: 'group-1',
+ folderPath,
+ connectionId: null
+ }
+ ]
+ }
+ discoverCommitMessageModelsLocalMock.mockResolvedValue({
+ success: true,
+ models: [{ id: 'sonnet', label: 'Sonnet' }],
+ defaultModelId: 'sonnet'
+ })
+
+ registerFilesystemHandlers(folderStore as never, { prepareForClaudeLaunch })
+
+ await handlers.get('git:discoverCommitMessageModels')!(null, {
+ agentId: 'claude',
+ worktreePath: folderPath
+ })
+
+ expect(prepareForClaudeLaunch).toHaveBeenCalledWith({
+ runtime: 'wsl',
+ wslDistro: 'Ubuntu'
+ })
+ expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
+ 'claude',
+ expect.objectContaining({ CLAUDE_CONFIG_DIR: '/home/tester/.claude' }),
+ undefined,
+ { cwd: path.resolve(folderPath), wslDistro: 'Ubuntu' }
+ )
+ })
+ })
+
it('routes local WSL project model discovery through the project runtime target', async () => {
await withPlatform('win32', async () => {
discoverCommitMessageModelsLocalMock.mockResolvedValue({
diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts
index 466166cfcad..48ae7f8f6f2 100644
--- a/src/main/ipc/filesystem.ts
+++ b/src/main/ipc/filesystem.ts
@@ -409,6 +409,26 @@ function getLocalAgentRuntimeTarget(
: { runtime: 'host' }
}
+async function resolveModelDiscoveryLocalPath(
+ store: Store,
+ requestedPath: string
+): Promise {
+ try {
+ return await resolveRegisteredWorktreePath(requestedPath, store)
+ } catch (error) {
+ const folderWorkspaces =
+ typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : []
+ const isFolderWorkspaceRoot = folderWorkspaces.some(
+ (workspace) =>
+ comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath)
+ )
+ if (!isFolderWorkspaceRoot) {
+ throw error
+ }
+ return resolveAuthorizedPath(requestedPath, store)
+ }
+}
+
function getLocalTextGenerationTarget(
worktreePath: string,
gitOptions: LocalProjectWorktreeGitOptions,
@@ -1517,16 +1537,17 @@ export function registerFilesystemHandlers(
let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' }
let localDiscoveryOptions: Parameters[3]
if (args.worktreePath) {
- const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
+ const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
args.worktreePath,
worktreePath
)
- localRuntimeTarget = getLocalAgentRuntimeTarget(gitOptions)
- localDiscoveryOptions = gitOptions.wslDistro
- ? { cwd: worktreePath, wslDistro: gitOptions.wslDistro }
- : { cwd: worktreePath }
+ const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro
+ localRuntimeTarget = wslDistro
+ ? { runtime: 'wsl', wslDistro }
+ : getLocalAgentRuntimeTarget(gitOptions)
+ localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath }
}
const localEnv = await prepareLocalCommitMessageAgentEnv(
agentId,
diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts
index 3d435ee9477..3048b2d0f87 100644
--- a/src/main/text-generation/commit-message-text-generation.test.ts
+++ b/src/main/text-generation/commit-message-text-generation.test.ts
@@ -311,6 +311,7 @@ describe('discoverCommitMessageModelsLocal', () => {
expect(result).toMatchObject({
success: true,
+ catalogOrigin: 'spec',
defaultModelId: 'smart'
})
expect(spawnMock).not.toHaveBeenCalled()
@@ -348,6 +349,94 @@ describe('discoverCommitMessageModelsLocal', () => {
)
})
+ it('writes the Claude list_models request to stdin and parses the control response', async () => {
+ const listeners = new Map void>()
+ const child = {
+ pid: 123,
+ kill: vi.fn(),
+ stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
+ stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
+ stdin: { on: vi.fn(), end: vi.fn() },
+ on: vi.fn((event, callback) => listeners.set(event, callback))
+ }
+ spawnMock.mockReturnValue(child as never)
+
+ const pending = discoverCommitMessageModelsLocal('claude', undefined)
+
+ listeners.get('stdout:data')?.(
+ Buffer.from(
+ `${JSON.stringify({
+ type: 'control_response',
+ response: {
+ subtype: 'success',
+ request_id: 'orca-model-discovery',
+ response: {
+ models: [
+ { value: 'default', displayName: 'Default (recommended)' },
+ {
+ value: 'opus[1m]',
+ displayName: 'Opus (1M context)',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
+ },
+ { value: 'sonnet', displayName: 'Sonnet' },
+ { value: 'haiku', displayName: 'Haiku' }
+ ]
+ }
+ }
+ })}\n`
+ )
+ )
+ listeners.get('close')?.(0)
+
+ await expect(pending).resolves.toMatchObject({
+ success: true,
+ catalogOrigin: 'probe',
+ defaultModelId: 'sonnet',
+ models: [
+ { id: 'opus[1m]', label: 'Opus (1M context)' },
+ { id: 'sonnet', label: 'Sonnet' },
+ { id: 'haiku', label: 'Haiku' }
+ ]
+ })
+ expect(spawnMock).toHaveBeenCalledWith(
+ 'claude',
+ ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'],
+ expect.objectContaining({ windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
+ )
+ expect(child.stdin.end).toHaveBeenCalledWith(expect.stringContaining('"list_models"'))
+ })
+
+ it('falls back to the Claude seed models when the CLI lacks list_models', async () => {
+ const listeners = new Map void>()
+ const child = {
+ pid: 123,
+ kill: vi.fn(),
+ stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) },
+ stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) },
+ stdin: { on: vi.fn(), end: vi.fn() },
+ on: vi.fn((event, callback) => listeners.set(event, callback))
+ }
+ spawnMock.mockReturnValue(child as never)
+
+ const pending = discoverCommitMessageModelsLocal('claude', undefined)
+
+ // Captured from claude 2.1.100: the unsupported subtype still exits 0.
+ listeners.get('stdout:data')?.(
+ Buffer.from(
+ '{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
+ )
+ )
+ listeners.get('close')?.(0)
+
+ await expect(pending).resolves.toMatchObject({
+ success: true,
+ catalogOrigin: 'spec',
+ defaultModelId: 'sonnet',
+ models: [{ id: 'haiku' }, { id: 'sonnet' }, { id: 'opus' }]
+ })
+ })
+
it('discovers dynamic models through the configured agent command override', async () => {
const listeners = new Map void>()
const child = {
diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts
index 98314f6975b..7115ce9d3f4 100644
--- a/src/main/text-generation/commit-message-text-generation.ts
+++ b/src/main/text-generation/commit-message-text-generation.ts
@@ -75,6 +75,7 @@ export type DiscoverCommitMessageModelsResult =
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
+ catalogOrigin: 'probe' | 'spec'
}
| { success: false; error: string }
@@ -229,7 +230,8 @@ function userFacingUnsafeWindowsBatchArgs(label: string): string {
function toModelDiscoveryCapability(
spec: NonNullable>,
models = spec.models,
- defaultModelId = spec.defaultModelId
+ defaultModelId = spec.defaultModelId,
+ catalogOrigin: 'probe' | 'spec' = 'spec'
): Extract {
return {
success: true,
@@ -241,7 +243,8 @@ function toModelDiscoveryCapability(
models
},
models,
- defaultModelId
+ defaultModelId,
+ catalogOrigin
}
}
@@ -281,7 +284,7 @@ function finalizeModelDiscoveryOutput(
const defaultModelId = models.some((model) => model.id === spec.defaultModelId)
? spec.defaultModelId
: models[0].id
- return toModelDiscoveryCapability(spec, models, defaultModelId)
+ return toModelDiscoveryCapability(spec, models, defaultModelId, 'probe')
}
function planModelDiscovery(
@@ -301,7 +304,7 @@ function planModelDiscovery(
plan: {
binary: command.binary,
args: [...command.prefixArgs, ...modelDiscovery.args],
- stdinPayload: null,
+ stdinPayload: modelDiscovery.stdinPayload ?? null,
label: spec.label
}
}
@@ -330,6 +333,7 @@ export async function discoverCommitMessageModelsLocal(
const result = new Promise((resolve) => {
let child: ChildProcess
const spawnEnv = env ?? process.env
+ let discoveryStdin: string | null = null
try {
const planned = planModelDiscovery(spec, agentCommandOverride)
if (!planned.ok) {
@@ -337,11 +341,13 @@ export async function discoverCommitMessageModelsLocal(
resolve({ success: false, error: planned.error })
return
}
+ discoveryStdin = planned.plan.stdinPayload
+ const stdinMode = discoveryStdin === null ? 'ignore' : 'pipe'
if (process.platform === 'win32' && options.wslDistro) {
child = wslAwareSpawn(planned.plan.binary, planned.plan.args, {
cwd: options.cwd,
env: buildWslLauncherEnv(env),
- stdio: ['ignore', 'pipe', 'pipe'],
+ stdio: [stdinMode, 'pipe', 'pipe'],
windowsHide: true,
wslDistro: options.wslDistro,
useWslLoginShell: true
@@ -356,10 +362,16 @@ export async function discoverCommitMessageModelsLocal(
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args)
child = spawn(spawnCmd, spawnArgs, {
env: spawnEnv,
- stdio: ['ignore', 'pipe', 'pipe'],
+ stdio: [stdinMode, 'pipe', 'pipe'],
windowsHide: true
})
}
+ if (discoveryStdin !== null) {
+ // Why: a CLI that rejects the args exits before reading stdin; the
+ // resulting EPIPE must surface as exit-code fallback, not a crash.
+ child.stdin?.on?.('error', () => {})
+ child.stdin?.end(discoveryStdin)
+ }
} catch (error) {
markProcessClosed()
console.error('[commit-message] Failed to spawn model discovery:', error)
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index c0c57731e1e..93b4a2e4d23 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -3019,6 +3019,7 @@ export type PreloadApi = {
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
+ catalogOrigin: 'probe' | 'spec'
}
| { success: false; error: string }
>
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
index bef6151532a..4eac2b5739c 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
@@ -8,6 +8,7 @@ import type {
} from '../../../../shared/native-chat-session-options'
import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles'
import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache'
+import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment'
const mocks = vi.hoisted(() => ({
cancelPendingSends: vi.fn(),
@@ -28,6 +29,7 @@ const mocks = vi.hoisted(() => ({
dispose: ReturnType
} | null,
createClaudeModelSwitchConfirmationObserver: vi.fn(),
+ discoverCommitMessageModels: vi.fn(),
getMainBufferSnapshot: vi.fn(),
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
sendNativeChatMessage: vi.fn(),
@@ -137,6 +139,7 @@ describe('NativeChatComposer', () => {
beforeEach(() => {
vi.clearAllMocks()
clearNativeChatSessionOptionCacheForTests()
+ clearNativeChatModelEnrichmentForTests()
mocks.fieldProps = null
mocks.modelSwitchOutcome = 'applied'
mocks.draftScopeKeys.length = 0
@@ -153,12 +156,36 @@ describe('NativeChatComposer', () => {
return observer
})
mocks.getMainBufferSnapshot.mockResolvedValue(null)
+ mocks.discoverCommitMessageModels.mockResolvedValue({
+ success: true,
+ catalogOrigin: 'probe',
+ models: [
+ {
+ id: 'opus',
+ label: 'Opus',
+ thinkingLevels: [
+ { id: 'medium', label: 'Medium' },
+ { id: 'high', label: 'High' }
+ ]
+ },
+ {
+ id: 'sonnet',
+ label: 'Sonnet',
+ thinkingLevels: [
+ { id: 'medium', label: 'Medium' },
+ { id: 'high', label: 'High' }
+ ]
+ },
+ { id: 'fable', label: 'Fable' }
+ ]
+ })
mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
mocks.sendNativeChatMessageVerified.mockResolvedValue(true)
mocks.sendHandle.settleAfterMs = 500
Object.defineProperty(window, 'api', {
configurable: true,
value: {
+ git: { discoverCommitMessageModels: mocks.discoverCommitMessageModels },
pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot },
ui: { onFileDrop: () => vi.fn() }
}
@@ -293,6 +320,59 @@ describe('NativeChatComposer', () => {
expect(mocks.setDraft).not.toHaveBeenCalled()
})
+ it('renders the Claude model picker while host discovery is still pending', () => {
+ mocks.discoverCommitMessageModels.mockReturnValue(new Promise(() => {}))
+ render(
+ null}
+ />
+ )
+
+ expect(mocks.fieldProps?.sessionOptionsSnapshot?.[0]).toMatchObject({
+ id: 'model',
+ kind: {
+ choices: expect.arrayContaining([
+ expect.objectContaining({ value: 'opus', label: 'Opus' }),
+ expect.objectContaining({ value: 'sonnet', label: 'Sonnet' })
+ ])
+ }
+ })
+ })
+
+ it('keeps the Claude model picker when an older remote runtime omits the catalog origin', async () => {
+ mocks.discoverCommitMessageModels.mockResolvedValue({
+ success: true,
+ defaultModelId: 'sonnet',
+ models: [{ id: 'sonnet', label: 'Sonnet' }]
+ })
+ render(
+ null}
+ />
+ )
+
+ await waitFor(() => expect(mocks.discoverCommitMessageModels).toHaveBeenCalled())
+ await act(async () => undefined)
+
+ expect(mocks.fieldProps?.sessionOptionsSnapshot?.[0]).toMatchObject({
+ id: 'model',
+ kind: {
+ choices: expect.arrayContaining([
+ expect.objectContaining({ value: 'fable', label: 'Fable' }),
+ expect.objectContaining({ value: 'haiku', label: 'Haiku' })
+ ])
+ }
+ })
+ })
+
it('shows the model already selected in the Claude TUI when chat opens', async () => {
mocks.getMainBufferSnapshot.mockResolvedValue({
data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing',
@@ -398,7 +478,7 @@ describe('NativeChatComposer', () => {
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
ptyId: 'pty-1',
settings: {},
- expectedModelLabel: 'Opus 4.8'
+ expectedModelLabel: 'Opus'
})
expect(onSwitchToTerminal).not.toHaveBeenCalled()
})
@@ -429,7 +509,7 @@ describe('NativeChatComposer', () => {
expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({
ptyId: 'pty-1',
settings: {},
- expectedModelLabel: 'Fable 5'
+ expectedModelLabel: 'Fable'
})
expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce()
expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan(
diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts
index e57f42e2efe..ed4d9f725b0 100644
--- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts
+++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts
@@ -40,6 +40,56 @@ describe('Claude model switch confirmation detection', () => {
expect(unsubscribe).toHaveBeenCalledOnce()
})
+ it('matches the resolved-model echo when the picker label carries no version', async () => {
+ // Why: the CLI echoes what the alias resolved to ("Opus 5 (1M context)")
+ // while discovered picker labels read "Opus (1M context)"; the family word
+ // must bridge the two so verified switches do not report as unverifiable.
+ const dataObserver = { current: (_data: string): void => {} }
+ const observer = createClaudeModelSwitchConfirmationObserver({
+ ptyId: 'pty-1',
+ settings: {},
+ expectedModelLabel: 'Opus (1M context)',
+ subscribeToData: (watcher) => {
+ dataObserver.current = watcher
+ return vi.fn(() => {})
+ },
+ timeoutMs: 100
+ })
+
+ await observer.ready
+ observer.arm()
+ dataObserver.current('Set model to Opus 5 (1M context) and saved as your default')
+
+ await expect(observer.result).resolves.toBe('applied')
+ })
+
+ it('does not confirm a different context variant from the same model family', async () => {
+ vi.useFakeTimers()
+ try {
+ const dataObserver = { current: (_data: string): void => {} }
+ const observer = createClaudeModelSwitchConfirmationObserver({
+ ptyId: 'pty-1',
+ settings: {},
+ expectedModelLabel: 'Opus (1M context)',
+ subscribeToData: (watcher) => {
+ dataObserver.current = watcher
+ return vi.fn(() => {})
+ },
+ timeoutMs: 100
+ })
+
+ await observer.ready
+ observer.arm()
+ observer.startDetection()
+ dataObserver.current('Set model to Opus 5 and saved as your default')
+ await vi.advanceTimersByTimeAsync(100)
+
+ await expect(observer.result).resolves.toBe('unknown')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('accepts the exact cached-history confirmation once and keeps observing', async () => {
const dataObserver = { current: (_data: string): void => {} }
const submitConfirmation = vi.fn()
diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts
index 64828018181..b96d3fad358 100644
--- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts
+++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts
@@ -36,7 +36,26 @@ function compactTerminalText(buffer: string): string {
function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean {
const text = compactTerminalText(buffer)
const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}`
- return text.includes(marker)
+ if (text.includes(marker)) {
+ return true
+ }
+ // Why: resolved echoes insert a version ("Opus 5 (1M context)"); retaining
+ // every picker token prevents one context variant from confirming another.
+ const labelTokens = modelLabel.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? []
+ const successStart = text.lastIndexOf('setmodelto')
+ if (successStart < 0 || labelTokens.length === 0) {
+ return false
+ }
+ const successText = text.slice(successStart)
+ let tokenEnd = 0
+ for (const token of labelTokens) {
+ const tokenStart = successText.indexOf(token, tokenEnd)
+ if (tokenStart < 0) {
+ return false
+ }
+ tokenEnd = tokenStart + token.length
+ }
+ return true
}
function hasClaudeModelSwitchRejection(buffer: string): boolean {
diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts
index e095adf1132..a41d62eeb7a 100644
--- a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts
+++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts
@@ -37,6 +37,19 @@ describe('Claude terminal session option detection', () => {
})
})
+ it('matches headers from newer CLIs where the alias resolves to another version', () => {
+ // Why: family labels must keep matching as `opus` moves across releases.
+ const screen =
+ 'Claude Code v2.1.220\r\n' +
+ 'Opus 5 (1M context) with xhigh effort · API Usage Billing\r\n' +
+ '~/repo'
+
+ expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
+ model: 'opus',
+ effort: 'xhigh'
+ })
+ })
+
it('reports an option-less Haiku model without inventing effort', () => {
expect(
readClaudeSessionOptionsFromTerminalScreen(
diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts
index 8475167186f..c4de028b244 100644
--- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts
@@ -9,17 +9,36 @@ import { createNativeChatPtySessionOptions } from './native-chat-pty-session-opt
describe('native chat PTY session options', () => {
beforeEach(() => clearNativeChatSessionOptionCacheForTests())
- it('starts attached sessions unknown and hides model-scoped options', () => {
+ it('renders nothing when no model list exists at all', () => {
+ const surface = createNativeChatPtySessionOptions({
+ agent: 'claude',
+ scopeKey: 'pty-1',
+ initialModels: [],
+ mode: 'live',
+ dispatchCommand: vi.fn()
+ })!
+ expect(surface.getSnapshot()).toEqual([])
+ })
+
+ it('renders the version-neutral seed picker before any host catalog arrives', () => {
const surface = createNativeChatPtySessionOptions({
agent: 'claude',
scopeKey: 'pty-1',
mode: 'live',
dispatchCommand: vi.fn()
})!
- expect(surface.getSnapshot()).toHaveLength(1)
+
expect(surface.getSnapshot()[0]).toMatchObject({
id: 'model',
- valueSource: 'unknown'
+ valueSource: 'unknown',
+ kind: {
+ choices: [
+ expect.objectContaining({ value: 'fable', label: 'Fable' }),
+ expect.objectContaining({ value: 'opus', label: 'Opus' }),
+ expect.objectContaining({ value: 'sonnet', label: 'Sonnet' }),
+ expect.objectContaining({ value: 'haiku', label: 'Haiku' })
+ ]
+ }
})
})
@@ -122,7 +141,7 @@ describe('native chat PTY session options', () => {
expect(dispatch).toHaveBeenCalledWith('/model fable', {
detectAgentInteraction: 'claude-model-switch-confirmation',
- expectedChoiceLabel: 'Fable 5'
+ expectedChoiceLabel: 'Fable'
})
expect(onAgentPicker).not.toHaveBeenCalled()
expect(result.snapshot[0]).toMatchObject({
@@ -603,6 +622,58 @@ describe('native chat PTY session options', () => {
})
})
+ it('keeps a tracked alias selectable when the host catalog omits it', async () => {
+ seedNativeChatAppliedSessionOptions('pty-1', 'claude', {
+ model: 'opus',
+ effort: 'xhigh'
+ })
+ const dispatch = vi.fn()
+ const surface = createNativeChatPtySessionOptions({
+ agent: 'claude',
+ scopeKey: 'pty-1',
+ // Why: current CLIs list `opus[1m]` and no plain `opus`.
+ initialModels: [
+ { id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
+ { id: 'sonnet', label: 'Sonnet', options: [] }
+ ],
+ mode: 'live',
+ dispatchCommand: dispatch
+ })!
+
+ expect(surface.getSnapshot()[0].kind).toMatchObject({
+ currentValue: 'opus',
+ choices: expect.arrayContaining([
+ { value: 'opus', label: 'Opus', description: expect.any(String) }
+ ])
+ })
+ expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({
+ settable: true,
+ kind: { currentValue: 'xhigh' }
+ })
+
+ await surface.setOption('effort', 'high')
+
+ expect(dispatch).toHaveBeenCalledWith('/effort high')
+ })
+
+ it('drops the reconciled row once the tracked model moves onto the host catalog', async () => {
+ seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'opus' })
+ const surface = createNativeChatPtySessionOptions({
+ agent: 'claude',
+ scopeKey: 'pty-1',
+ initialModels: [{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] }],
+ mode: 'live',
+ dispatchCommand: vi.fn()
+ })!
+
+ await surface.setOption('model', 'opus[1m]')
+
+ expect(surface.getSnapshot()[0].kind).toMatchObject({
+ currentValue: 'opus[1m]',
+ choices: [{ value: 'opus[1m]', label: 'Opus (1M context)' }]
+ })
+ })
+
it('recomposes Cursor model slugs for live option changes', async () => {
seedNativeChatAppliedSessionOptions('pty-1', 'cursor', {
model: 'gpt-5.3-codex',
diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts
index a97b471edee..a48a3611a22 100644
--- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts
@@ -1,5 +1,6 @@
import {
getAgentSessionOptionCatalog,
+ type AgentSessionOptionCatalog,
type CatalogModel
} from '../../../../shared/agent-session-option-catalog'
import type { AgentType } from '../../../../shared/agent-status-types'
@@ -11,7 +12,8 @@ import type {
import {
createNativeChatSessionOptionRecord,
readNativeChatSessionOptionCache,
- writeNativeChatSessionOptionCache
+ writeNativeChatSessionOptionCache,
+ type NativeChatSessionOptionRecord
} from './native-chat-session-option-cache'
import { createSessionOptionAppliers } from './native-chat-session-option-apply'
import {
@@ -47,6 +49,24 @@ export type CreateNativeChatPtySessionOptionsArgs = {
onDraftValuesChanged?: (values: Record) => void
}
+/**
+ * Why: the tracked model can sit outside the active list — a persisted default,
+ * or an alias this host's CLI no longer lists. Keeping a row for it preserves
+ * the labelled selection and the model's own options instead of blanking both.
+ */
+function withTrackedModel(
+ catalog: AgentSessionOptionCatalog,
+ models: readonly CatalogModel[],
+ record: NativeChatSessionOptionRecord
+): CatalogModel[] {
+ const trackedId = typeof record.model?.value === 'string' ? record.model.value : null
+ if (!trackedId || models.some((model) => model.id === trackedId)) {
+ return [...models]
+ }
+ const seeded = catalog.models.find((model) => model.id === trackedId)
+ return [...models, seeded ?? { id: trackedId, label: trackedId, options: [] }]
+}
+
export function createNativeChatPtySessionOptions(
args: CreateNativeChatPtySessionOptionsArgs
): NativeChatPtySessionOptionsSurface | null {
@@ -65,9 +85,10 @@ export function createNativeChatPtySessionOptions(
if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) {
writeNativeChatSessionOptionCache(args.scopeKey, record)
}
+ const activeModels = (): CatalogModel[] => withTrackedModel(catalog, models, record)
let snapshot = buildNativeChatSessionOptionSnapshot({
catalog,
- models,
+ models: activeModels(),
record,
mode: args.mode
})
@@ -77,7 +98,7 @@ export function createNativeChatPtySessionOptions(
writeNativeChatSessionOptionCache(args.scopeKey, record)
snapshot = buildNativeChatSessionOptionSnapshot({
catalog,
- models,
+ models: activeModels(),
record,
mode: args.mode
})
@@ -124,7 +145,7 @@ export function createNativeChatPtySessionOptions(
const appliers = createSessionOptionAppliers({
mode: args.mode,
catalog,
- getModels: () => models,
+ getModels: activeModels,
getRecord: () => record,
dispatchCommand: args.dispatchCommand,
onAgentPicker: args.onAgentPicker,
@@ -146,7 +167,7 @@ export function createNativeChatPtySessionOptions(
recordOutgoingCommand: (command) => {
const result = recordNativeChatSessionOptionCommand({
catalog,
- models,
+ models: activeModels(),
record,
command,
persist
diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts
index bdf8ee6d1a2..035f05312d4 100644
--- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts
+++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts
@@ -1,8 +1,18 @@
import type { AgentType } from '../../../../shared/agent-status-types'
-import type { CatalogModel } from '../../../../shared/agent-session-option-catalog'
-import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key'
+import {
+ createClaudeCatalogOptions,
+ type CatalogModel
+} from '../../../../shared/agent-session-option-catalog'
+import {
+ getCommitMessageModelDiscoveryHostKeyForLocalRuntime,
+ getCommitMessageModelDiscoveryHostKeyForScope
+} from '../../../../shared/commit-message-host-key'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import { getConnectionIdFromState } from '@/lib/connection-context'
+import {
+ getLocalProjectExecutionRuntimeContext,
+ getWslDistroFromPath
+} from '@/lib/local-preflight-context'
import {
discoverRuntimeCommitMessageModels,
getRuntimeGitScope,
@@ -15,6 +25,23 @@ export type NativeChatModelDiscoveryContext = {
runtime: RuntimeGitContext
}
+export function resolveNativeChatModelDiscoveryHostKey(
+ state: Parameters[0],
+ worktreeId: string | null,
+ worktreePath: string,
+ scope: string | null | undefined
+): string {
+ if (scope !== null) {
+ return getCommitMessageModelDiscoveryHostKeyForScope(scope)
+ }
+ const localProjectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId)
+ const wslDistro =
+ localProjectRuntime?.status === 'resolved' && localProjectRuntime.runtime.kind === 'wsl'
+ ? localProjectRuntime.runtime.distro
+ : getWslDistroFromPath(worktreePath)
+ return getCommitMessageModelDiscoveryHostKeyForLocalRuntime(wslDistro)
+}
+
export function resolveNativeChatModelDiscoveryContext(
terminalTabId: string
): NativeChatModelDiscoveryContext | null {
@@ -31,7 +58,7 @@ export function resolveNativeChatModelDiscoveryContext(
const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : ''
const scope = getRuntimeGitScope(settings, connectionId)
return {
- hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope),
+ hostKey: resolveNativeChatModelDiscoveryHostKey(state, worktreeId, worktreePath, scope),
runtime: {
settings,
worktreeId,
@@ -46,12 +73,23 @@ export async function discoverNativeChatCatalogModels(
context: RuntimeGitContext
): Promise {
const result = await discoverRuntimeCommitMessageModels(context, agent)
- if (!result.success || result.models.length === 0) {
+ if (
+ !result.success ||
+ result.models.length === 0 ||
+ (agent === 'claude' && result.catalogOrigin !== 'probe')
+ ) {
return null
}
return result.models.map((model) => ({
id: model.id,
label: model.label,
- options: []
+ ...(model.description ? { description: model.description } : {}),
+ options:
+ agent === 'claude'
+ ? createClaudeCatalogOptions({
+ effortLevelIds: model.thinkingLevels?.map(({ id }) => id) ?? [],
+ supportsFastMode: model.supportsFastMode
+ })
+ : []
}))
}
diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts
index 008d857ec9c..b46f78ef1b3 100644
--- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CatalogModel } from '../../../../shared/agent-session-option-catalog'
+import {
+ discoverNativeChatCatalogModels,
+ resolveNativeChatModelDiscoveryHostKey
+} from './native-chat-session-option-discovery'
import {
clearNativeChatModelEnrichmentForTests,
ensureNativeChatModelEnrichment,
@@ -7,8 +11,20 @@ import {
subscribeNativeChatEnrichedModels
} from './native-chat-session-option-enrichment'
+const mocks = vi.hoisted(() => ({
+ discoverRuntimeCommitMessageModels: vi.fn()
+}))
+
+vi.mock('@/runtime/runtime-git-client', () => ({
+ discoverRuntimeCommitMessageModels: mocks.discoverRuntimeCommitMessageModels,
+ getRuntimeGitScope: vi.fn()
+}))
+
describe('native chat session option enrichment', () => {
- beforeEach(() => clearNativeChatModelEnrichmentForTests())
+ beforeEach(() => {
+ clearNativeChatModelEnrichmentForTests()
+ mocks.discoverRuntimeCommitMessageModels.mockReset()
+ })
it('keeps reads synchronous while one host-scoped probe is in flight', async () => {
let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined
@@ -55,7 +71,107 @@ describe('native chat session option enrichment', () => {
it('does not probe agents whose catalogs have no discovery command', () => {
const discover = vi.fn()
- ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover })
+ ensureNativeChatModelEnrichment({ agent: 'gemini', hostKey: 'local', discover })
expect(discover).not.toHaveBeenCalled()
})
+
+ it('keeps WSL discovery separate from the Windows host and other distros', () => {
+ expect(
+ resolveNativeChatModelDiscoveryHostKey(
+ {} as never,
+ null,
+ '\\\\wsl.localhost\\Ubuntu\\home\\orca',
+ null
+ )
+ ).toBe('wsl:Ubuntu')
+ expect(
+ resolveNativeChatModelDiscoveryHostKey(
+ {} as never,
+ null,
+ '\\\\wsl.localhost\\Debian\\home\\orca',
+ null
+ )
+ ).toBe('wsl:Debian')
+ expect(resolveNativeChatModelDiscoveryHostKey({} as never, null, 'C:\\repo', null)).toBe(
+ 'local'
+ )
+ })
+
+ it('uses only discovered Claude rows and capabilities per host', async () => {
+ mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
+ success: true,
+ catalogOrigin: 'probe',
+ models: [
+ {
+ id: 'opus[1m]',
+ label: 'Opus (1M context)',
+ description: 'Opus 5 with 1M context',
+ thinkingLevels: [
+ { id: 'low', label: 'Low' },
+ { id: 'high', label: 'High' }
+ ],
+ defaultThinkingLevel: 'low',
+ supportsFastMode: true
+ },
+ {
+ id: 'sonnet',
+ label: 'Sonnet',
+ thinkingLevels: [{ id: 'medium', label: 'Medium' }]
+ }
+ ]
+ })
+ const discover = vi.fn(() =>
+ discoverNativeChatCatalogModels('claude', {
+ settings: {},
+ worktreeId: 'repo::/worktree',
+ worktreePath: '/worktree'
+ })
+ )
+ const listener = vi.fn()
+ subscribeNativeChatEnrichedModels('claude', 'ssh:host', listener)
+
+ ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'ssh:host', discover })
+ await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce())
+
+ const models = readNativeChatEnrichedModels('claude', 'ssh:host')!
+ expect(models.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet'])
+ const sonnetEffort = models.find(({ id }) => id === 'sonnet')?.options[0]
+ expect(sonnetEffort?.kind).toMatchObject({
+ type: 'select',
+ choices: [{ value: 'medium', label: 'Medium' }]
+ })
+ expect(models.find(({ id }) => id === 'opus[1m]')).toMatchObject({
+ id: 'opus[1m]',
+ description: 'Opus 5 with 1M context',
+ options: [
+ expect.objectContaining({
+ id: 'effort',
+ kind: expect.objectContaining({
+ choices: [
+ { value: 'low', label: 'Low' },
+ { value: 'high', label: 'High' }
+ ]
+ })
+ }),
+ expect.objectContaining({ id: 'fastMode' })
+ ]
+ })
+ expect(readNativeChatEnrichedModels('claude', 'local')).toBeNull()
+ })
+
+ it('does not advertise the Claude spec fallback when probing is unavailable', async () => {
+ mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
+ success: true,
+ catalogOrigin: 'spec',
+ models: [{ id: 'sonnet', label: 'Sonnet' }]
+ })
+
+ await expect(
+ discoverNativeChatCatalogModels('claude', {
+ settings: {},
+ worktreeId: 'repo::/worktree',
+ worktreePath: '/worktree'
+ })
+ ).resolves.toBeNull()
+ })
})
diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts
index 5fde5625c52..ee2ad888e6f 100644
--- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts
+++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts
@@ -72,7 +72,8 @@ export function ensureNativeChatModelEnrichment(args: {
if (!discovered || discovered.length === 0) {
return
}
- entry.models = mergeCatalogModels(catalog.models, discovered)
+ entry.models =
+ args.agent === 'claude' ? [...discovered] : mergeCatalogModels(catalog.models, discovered)
for (const listener of entry.listeners) {
listener([...entry.models])
}
diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts
index 125bc1dbb9b..273267f3690 100644
--- a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts
+++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts
@@ -120,15 +120,17 @@ export function buildNativeChatSessionOptionSnapshot(args: {
mode: NativeChatSessionOptionMode
}): SessionOptionDescriptor[] {
const { catalog, models, record, mode } = args
+ if (models.length === 0) {
+ return []
+ }
const modelTracked = record.model
- const modelChoices = choiceWithCurrent(
- models.map(({ id, label, description }) => ({
- value: id,
- label,
- ...(description ? { description } : {})
- })),
- modelTracked
- )
+ // Why: callers reconcile the tracked model into `models`, so every listed row
+ // is a real choice and the trigger never shows a value without one.
+ const modelChoices = models.map(({ id, label, description }) => ({
+ value: id,
+ label,
+ ...(description ? { description } : {})
+ }))
const modelSettable = settableState({ mode, apply: catalog.modelApply })
const modelAction = actionForApply(catalog.modelApply, modelTracked, mode)
const snapshot: SessionOptionDescriptor[] = [
diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts
index 23c5f87a294..b0dfe6503fe 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts
@@ -54,6 +54,9 @@ export function useNativeChatSessionOptions(args: {
agent,
scopeKey,
...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}),
+ // Why: the catalog seed carries version-neutral family labels, so it is
+ // safe on every host while the once-per-host probe runs or after it fails
+ // — without it the whole picker would pop in late or never appear.
...(discoveryContext
? {
initialModels:
diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx
index aaab680a016..fba530089bf 100644
--- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx
+++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx
@@ -5,6 +5,7 @@ import type { GlobalSettings } from '../../../../shared/types'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import {
getCommitMessageModelDiscoveryHostKey,
+ getCommitMessageModelDiscoveryHostKeyForLocalRuntime,
getCommitMessageModelDiscoveryHostKeyForScope
} from '../../../../shared/commit-message-host-key'
import { useAppStore } from '../../store'
@@ -441,6 +442,8 @@ describe('CommitMessageAiPane', () => {
expect(getCommitMessageModelDiscoveryHostKey(null)).toBe('local')
expect(getCommitMessageModelDiscoveryHostKey('ssh-1')).toBe('ssh:ssh-1')
expect(getCommitMessageModelDiscoveryHostKey(undefined)).toBe('unknown')
+ expect(getCommitMessageModelDiscoveryHostKeyForLocalRuntime('Ubuntu')).toBe('wsl:Ubuntu')
+ expect(getCommitMessageModelDiscoveryHostKeyForLocalRuntime(null)).toBe('local')
expect(getCommitMessageModelDiscoveryHostKeyForScope('runtime:env-1')).toBe('runtime:env-1')
expect(getCommitMessageModelDiscoveryHostKeyForScope('ssh-1')).toBe('ssh:ssh-1')
})
diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts
index 2398252afb6..adf06f74206 100644
--- a/src/renderer/src/runtime/runtime-git-client.ts
+++ b/src/renderer/src/runtime/runtime-git-client.ts
@@ -62,6 +62,8 @@ type RuntimeDiscoverCommitMessageModelsResult =
capability: CommitMessageAgentCapability
models: CommitMessageModelCapability[]
defaultModelId: string
+ /** Missing only when an older remote runtime produced the response. */
+ catalogOrigin?: 'probe' | 'spec'
}
| { success: false; error: string }
diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts
index 47fa8226af7..71327f1d532 100644
--- a/src/shared/agent-session-option-catalog-claude-codex.ts
+++ b/src/shared/agent-session-option-catalog-claude-codex.ts
@@ -1,4 +1,13 @@
-import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types'
+import type {
+ AgentSessionOptionCatalog,
+ CatalogModel,
+ CatalogOption
+} from './agent-session-option-catalog-types'
+import {
+ CLAUDE_MODEL_LIST_ARGS,
+ CLAUDE_MODEL_LIST_STDIN,
+ parseClaudeModelList
+} from './claude-model-list-probe'
function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean {
return tokens.some((token) =>
@@ -40,14 +49,20 @@ const EXTENDED_EFFORT_CHOICES = [
]
function claudeEffort(extended: boolean): CatalogOption {
+ return claudeEffortWithChoices(extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES)
+}
+
+function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): CatalogOption {
return {
id: 'effort',
label: 'Effort',
category: 'thought_level',
kind: {
type: 'select',
- choices: extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES,
- defaultValue: 'high'
+ choices,
+ defaultValue: choices.some((choice) => choice.value === 'high')
+ ? 'high'
+ : (choices[0]?.value ?? 'high')
},
apply: {
launchArgs: (value) => ['--effort', String(value)],
@@ -57,6 +72,33 @@ function claudeEffort(extended: boolean): CatalogOption {
}
}
+export function createClaudeCatalogOptions(args: {
+ effortLevelIds: readonly string[]
+ supportsFastMode?: boolean
+}): CatalogOption[] {
+ const effortChoices = EXTENDED_EFFORT_CHOICES.filter((choice) =>
+ args.effortLevelIds.includes(choice.value)
+ )
+ return [
+ ...(effortChoices.length > 0 ? [claudeEffortWithChoices(effortChoices)] : []),
+ ...(args.supportsFastMode ? [CLAUDE_FAST_MODE] : [])
+ ]
+}
+
+function parseClaudeCatalogModels(stdout: string): CatalogModel[] {
+ return parseClaudeModelList(stdout).map((model) => {
+ return {
+ id: model.id,
+ label: model.label,
+ ...(model.description ? { description: model.description } : {}),
+ options: createClaudeCatalogOptions({
+ effortLevelIds: model.effortLevels,
+ supportsFastMode: model.supportsFastMode
+ })
+ }
+ })
+}
+
const CLAUDE_FAST_MODE: CatalogOption = {
id: 'fastMode',
label: 'Fast mode',
@@ -66,26 +108,35 @@ const CLAUDE_FAST_MODE: CatalogOption = {
}
export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
+ // Why: these ids are Claude CLI aliases that resolve to the newest model of
+ // each family on the host's CLI (`opus` is Opus 5 on current CLIs, older
+ // Opus on older CLIs), so pinned version labels lie on part of the fleet.
+ // Family labels also keep header scraping and /model echo detection working
+ // across CLI versions; listModels overlays exact per-host names below.
models: [
{
id: 'fable',
- label: 'Fable 5',
+ label: 'Fable',
+ description: 'Most capable for the hardest, longest-running tasks',
options: [claudeEffort(true)]
},
{
id: 'opus',
- label: 'Opus 4.8',
+ label: 'Opus',
+ description: 'Best for everyday, complex tasks',
options: [claudeEffort(true), CLAUDE_FAST_MODE]
},
{
id: 'sonnet',
- label: 'Sonnet 5',
+ label: 'Sonnet',
+ description: 'Efficient for routine tasks',
isDefault: true,
options: [claudeEffort(true)]
},
{
id: 'haiku',
label: 'Haiku',
+ description: 'Fastest for quick answers',
options: []
}
],
@@ -100,6 +151,10 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// actual prompt so ordinary model changes stay in native chat.
detectAgentInteraction: 'claude-model-switch-confirmation'
}
+ },
+ listModels: {
+ command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`,
+ parse: parseClaudeCatalogModels
}
}
diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts
index ffabb087e61..e7aae4aaaaa 100644
--- a/src/shared/agent-session-option-catalog.test.ts
+++ b/src/shared/agent-session-option-catalog.test.ts
@@ -36,6 +36,91 @@ describe('agent session option catalog', () => {
})
})
+ it('labels Claude seed models by alias family so no host is mislabeled', () => {
+ const catalog = getAgentSessionOptionCatalog('claude')!
+ expect(catalog.models.map(({ id, label }) => ({ id, label }))).toEqual([
+ { id: 'fable', label: 'Fable' },
+ { id: 'opus', label: 'Opus' },
+ { id: 'sonnet', label: 'Sonnet' },
+ { id: 'haiku', label: 'Haiku' }
+ ])
+ expect(catalog.models.find((model) => model.isDefault)?.id).toBe('sonnet')
+ })
+
+ it('parses Claude list_models discovery into catalog models with options', () => {
+ const stdout = JSON.stringify({
+ type: 'control_response',
+ response: {
+ subtype: 'success',
+ response: {
+ models: [
+ {
+ value: 'default',
+ displayName: 'Default (recommended)',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ supportsFastMode: true
+ },
+ {
+ value: 'opus[1m]',
+ displayName: 'Opus (1M context)',
+ description: 'Opus 5 with 1M context',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ supportsFastMode: true
+ },
+ {
+ value: 'sonnet',
+ displayName: 'Sonnet',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high']
+ },
+ { value: 'haiku', displayName: 'Haiku' }
+ ]
+ }
+ }
+ })
+ const parsed = getAgentSessionOptionCatalog('claude')!.listModels!.parse(stdout)
+ expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku'])
+ expect(parsed[0]).toMatchObject({
+ label: 'Opus (1M context)',
+ description: 'Opus 5 with 1M context'
+ })
+ expect(parsed[0].options.map(({ id }) => id)).toEqual(['effort', 'fastMode'])
+ const opusEffort = parsed[0].options[0]
+ expect(opusEffort.kind).toMatchObject({ defaultValue: 'high' })
+ expect(
+ opusEffort.kind.type === 'select' ? opusEffort.kind.choices.map((c) => c.value) : []
+ ).toEqual(['low', 'medium', 'high', 'xhigh', 'max'])
+ const sonnetEffort = parsed[1].options[0]
+ expect(
+ sonnetEffort.kind.type === 'select' ? sonnetEffort.kind.choices.map((c) => c.value) : []
+ ).toEqual(['low', 'medium', 'high'])
+ expect(parsed[2].options).toEqual([])
+ })
+
+ it('keeps the Claude seed when list_models output is unsupported or malformed', () => {
+ const parse = getAgentSessionOptionCatalog('claude')!.listModels!.parse
+ const unsupported =
+ '{"type":"control_response","response":{"subtype":"error","request_id":"x","error":"Unsupported control request subtype: list_models"}}'
+ expect(parse(unsupported)).toEqual([])
+ expect(parse('')).toEqual([])
+ expect(parse('garbage')).toEqual([])
+ })
+
+ it('merges discovered Claude variants after the seed and overlays matched labels', () => {
+ const catalog = getAgentSessionOptionCatalog('claude')!
+ const merged = mergeCatalogModels(catalog.models, [
+ { id: 'opus[1m]', label: 'Opus (1M context)', options: [] },
+ { id: 'sonnet', label: 'Sonnet', description: 'Sonnet 5 · Efficient', options: [] }
+ ])
+ expect(merged.map(({ id }) => id)).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]'])
+ const sonnet = merged.find((model) => model.id === 'sonnet')!
+ expect(sonnet.description).toBe('Sonnet 5 · Efficient')
+ expect(sonnet.isDefault).toBe(true)
+ expect(sonnet.options.map(({ id }) => id)).toEqual(['effort'])
+ })
+
it('parses Cursor model discovery without treating headings as models', () => {
const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse(
'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n'
diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts
index 6ba6ee65043..76074aeb7b7 100644
--- a/src/shared/agent-session-option-catalog.ts
+++ b/src/shared/agent-session-option-catalog.ts
@@ -1,7 +1,8 @@
import type { AgentType } from './agent-status-types'
import {
CLAUDE_SESSION_OPTION_CATALOG,
- CODEX_SESSION_OPTION_CATALOG
+ CODEX_SESSION_OPTION_CATALOG,
+ createClaudeCatalogOptions
} from './agent-session-option-catalog-claude-codex'
import {
CURSOR_SESSION_OPTION_CATALOG,
@@ -23,6 +24,7 @@ export type {
CatalogOption,
CatalogOptionApply
} from './agent-session-option-catalog-types'
+export { createClaudeCatalogOptions }
const CATALOGS: AgentSessionOptionCatalogMap = {
claude: CLAUDE_SESSION_OPTION_CATALOG,
@@ -49,8 +51,7 @@ export function findCatalogOption(
return model?.options.find((option) => option.id === optionId)
}
-/** Merge live rows over the static seed while retaining only option shapes Orca
- * can actually map. Newly discovered ids remain model-only until cataloged. */
+/** Merge live rows over the static seed while retaining cataloged option mappings. */
export function mergeCatalogModels(
seed: readonly CatalogModel[],
discovered: readonly CatalogModel[]
diff --git a/src/shared/claude-model-list-probe.test.ts b/src/shared/claude-model-list-probe.test.ts
new file mode 100644
index 00000000000..86305b45549
--- /dev/null
+++ b/src/shared/claude-model-list-probe.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest'
+import { parseClaudeModelList } from './claude-model-list-probe'
+
+function controlResponseLine(models: unknown[]): string {
+ return JSON.stringify({
+ type: 'control_response',
+ response: {
+ subtype: 'success',
+ request_id: 'orca-model-discovery',
+ response: { models }
+ }
+ })
+}
+
+// Captured from `claude` 2.1.220 answering a list_models control request.
+const LIVE_MODELS = [
+ {
+ value: 'default',
+ resolvedModel: 'claude-opus-5[1m]',
+ displayName: 'Default (recommended)',
+ description: 'Use the default model (currently Opus 5 (1M context)) · $5/$25 per Mtok',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ supportsFastMode: true
+ },
+ {
+ value: 'opus[1m]',
+ resolvedModel: 'claude-opus-5[1m]',
+ displayName: 'Opus (1M context)',
+ description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ supportsFastMode: true
+ },
+ {
+ value: 'sonnet',
+ resolvedModel: 'claude-sonnet-5',
+ displayName: 'Sonnet',
+ description: 'Sonnet 5 · Efficient for routine tasks · $2/$10 per Mtok',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ supportsAdaptiveThinking: true
+ },
+ {
+ value: 'haiku',
+ resolvedModel: 'claude-haiku-4-5-20251001',
+ displayName: 'Haiku',
+ description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok'
+ }
+]
+
+describe('parseClaudeModelList', () => {
+ it('parses the picker catalog and drops the mirror default row', () => {
+ const parsed = parseClaudeModelList(`${controlResponseLine(LIVE_MODELS)}\n`)
+ expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku'])
+ expect(parsed[0]).toEqual({
+ id: 'opus[1m]',
+ label: 'Opus (1M context)',
+ description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok',
+ effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ supportsFastMode: true
+ })
+ expect(parsed[2]).toMatchObject({ effortLevels: [], supportsFastMode: false })
+ })
+
+ it('skips init noise, CRLF endings, and duplicate values', () => {
+ const stdout =
+ '{"type":"system","subtype":"init","model":"claude-sonnet-5"}\r\n' +
+ 'not json at all\r\n' +
+ `${controlResponseLine([
+ { value: 'sonnet', displayName: 'Sonnet' },
+ { value: 'sonnet', displayName: 'Sonnet (duplicate)' },
+ { value: ' ', displayName: 'Blank' }
+ ])}\r\n`
+ expect(parseClaudeModelList(stdout)).toEqual([
+ { id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false }
+ ])
+ })
+
+ it('skips non-object model entries instead of failing the discovery response', () => {
+ const stdout = controlResponseLine([null, 7, [], { value: 'sonnet', displayName: 'Sonnet' }])
+ expect(parseClaudeModelList(stdout)).toEqual([
+ { id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false }
+ ])
+ })
+
+ it('returns no models for the control error emitted by CLIs without list_models', () => {
+ // Captured from `claude` 2.1.100: unsupported subtype still exits 0.
+ const stdout =
+ '{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
+ expect(parseClaudeModelList(stdout)).toEqual([])
+ })
+
+ it('returns no models for empty, malformed, or structurally hostile output', () => {
+ expect(parseClaudeModelList('')).toEqual([])
+ expect(parseClaudeModelList('{"type":"control_response"')).toEqual([])
+ expect(
+ parseClaudeModelList(
+ `{"type":"control_response","response":{"subtype":"success","response":{"models":${'['.repeat(64)}${']'.repeat(64)}}}}`
+ )
+ ).toEqual([])
+ const hostile = `{"a":${'['.repeat(40)}${']'.repeat(40)},"type":"control_response"}`
+ expect(parseClaudeModelList(hostile)).toEqual([])
+ })
+
+ it('ignores effort levels when the model does not declare effort support', () => {
+ const parsed = parseClaudeModelList(
+ controlResponseLine([
+ { value: 'haiku', displayName: 'Haiku', supportedEffortLevels: ['low', 'high'] }
+ ])
+ )
+ expect(parsed[0]?.effortLevels).toEqual([])
+ })
+})
diff --git a/src/shared/claude-model-list-probe.ts b/src/shared/claude-model-list-probe.ts
new file mode 100644
index 00000000000..fa953d3da16
--- /dev/null
+++ b/src/shared/claude-model-list-probe.ts
@@ -0,0 +1,120 @@
+import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
+
+// Why: the Claude CLI has no model-listing subcommand (`claude models` starts a
+// chat session). One `list_models` control request over --print stream-json
+// returns the CLI's /model picker catalog without starting an API turn. CLIs
+// that predate the request answer `{"subtype":"error"}` and still exit 0, so
+// parsing yields no models and callers keep their seed list.
+export const CLAUDE_MODEL_LIST_STDIN = `${JSON.stringify({
+ type: 'control_request',
+ request_id: 'orca-model-discovery',
+ request: { subtype: 'list_models' }
+})}\n`
+
+// Why: --print rejects stream-json output unless --verbose is also set.
+export const CLAUDE_MODEL_LIST_ARGS = [
+ '-p',
+ '--input-format',
+ 'stream-json',
+ '--output-format',
+ 'stream-json',
+ '--verbose'
+]
+
+export type ClaudeListedModel = {
+ /** Value the CLI accepts for `--model` and `/model` (e.g. `opus[1m]`). */
+ id: string
+ /** The CLI's own picker label (e.g. `Opus (1M context)`). */
+ label: string
+ /** Names what the value resolves to on this host (e.g. `Opus 5 with 1M context …`). */
+ description?: string
+ /** `--effort` values this model accepts; empty when it has no effort control. */
+ effortLevels: string[]
+ supportsFastMode: boolean
+}
+
+const CLAUDE_MODEL_LIST_JSON_LIMITS = {
+ structuralTokens: 64 * 1024,
+ nestingDepth: 16
+} as const
+
+type RawControlResponse = {
+ type?: unknown
+ response?: {
+ subtype?: unknown
+ response?: { models?: unknown }
+ }
+}
+
+type RawListedModel = {
+ value?: unknown
+ displayName?: unknown
+ description?: unknown
+ supportsEffort?: unknown
+ supportedEffortLevels?: unknown
+ supportsFastMode?: unknown
+}
+
+function toListedModel(value: unknown): ClaudeListedModel | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null
+ }
+ const raw = value as RawListedModel
+ const id = typeof raw.value === 'string' ? raw.value.trim() : ''
+ if (!id) {
+ return null
+ }
+ const label = typeof raw.displayName === 'string' && raw.displayName.trim() ? raw.displayName : id
+ const description =
+ typeof raw.description === 'string' && raw.description.trim() ? raw.description : undefined
+ const effortLevels =
+ raw.supportsEffort === true && Array.isArray(raw.supportedEffortLevels)
+ ? raw.supportedEffortLevels.filter((level): level is string => typeof level === 'string')
+ : []
+ return {
+ id,
+ label,
+ ...(description ? { description } : {}),
+ effortLevels,
+ supportsFastMode: raw.supportsFastMode === true
+ }
+}
+
+export function parseClaudeModelList(stdout: string): ClaudeListedModel[] {
+ for (const rawLine of stdout.split(/\r?\n/)) {
+ const line = rawLine.trim()
+ if (!line.startsWith('{') || !line.includes('control_response')) {
+ continue
+ }
+ let parsed: RawControlResponse
+ try {
+ assertJsonTextStructureWithinLimits(line, CLAUDE_MODEL_LIST_JSON_LIMITS)
+ parsed = JSON.parse(line) as RawControlResponse
+ } catch {
+ continue
+ }
+ if (parsed.type !== 'control_response' || parsed.response?.subtype !== 'success') {
+ continue
+ }
+ const models = parsed.response.response?.models
+ if (!Array.isArray(models)) {
+ continue
+ }
+ const seen = new Set()
+ const listed: ClaudeListedModel[] = []
+ for (const entry of models) {
+ const model = toListedModel(entry)
+ // Why: the `default` row mirrors whichever entry it currently resolves
+ // to; Orca's pickers manage their own default selection.
+ if (!model || model.id === 'default' || seen.has(model.id)) {
+ continue
+ }
+ seen.add(model.id)
+ listed.push(model)
+ }
+ if (listed.length > 0) {
+ return listed
+ }
+ }
+ return []
+}
diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts
index 3d8eac29c81..af2d0ea43ee 100644
--- a/src/shared/commit-message-agent-spec.test.ts
+++ b/src/shared/commit-message-agent-spec.test.ts
@@ -12,6 +12,7 @@ import {
listCommitMessageAgentCapabilities,
listCommitMessageAgentIds,
parseAntigravityModels,
+ parseClaudeModels,
parseCodexModels,
parseCursorModels,
parseLineModels,
@@ -194,6 +195,81 @@ describe('buildArgs (Claude)', () => {
})
describe('model discovery parsers', () => {
+ it('parses Claude list_models output into commit-message models', () => {
+ const stdout = `${JSON.stringify({
+ type: 'control_response',
+ response: {
+ subtype: 'success',
+ request_id: 'orca-model-discovery',
+ response: {
+ models: [
+ {
+ value: 'default',
+ displayName: 'Default (recommended)',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max']
+ },
+ {
+ value: 'opus[1m]',
+ displayName: 'Opus (1M context)',
+ description: 'Opus 5 with 1M context · $5/$25 per Mtok',
+ supportsEffort: true,
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
+ supportsFastMode: true
+ },
+ { value: 'haiku', displayName: 'Haiku' }
+ ]
+ }
+ }
+ })}\n`
+ expect(parseClaudeModels(stdout)).toEqual([
+ {
+ id: 'opus[1m]',
+ label: 'Opus (1M context)',
+ description: 'Opus 5 with 1M context · $5/$25 per Mtok',
+ thinkingLevels: [
+ { id: 'low', label: 'Low' },
+ { id: 'medium', label: 'Medium' },
+ { id: 'high', label: 'High' },
+ { id: 'xhigh', label: 'Extra High' },
+ { id: 'max', label: 'Max' }
+ ],
+ defaultThinkingLevel: 'low',
+ supportsFastMode: true
+ },
+ { id: 'haiku', label: 'Haiku' }
+ ])
+ })
+
+ it('returns no Claude models when the CLI lacks list_models so the seed stays', () => {
+ expect(
+ parseClaudeModels(
+ '{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n'
+ )
+ ).toEqual([])
+ })
+
+ it('declares stdin-driven dynamic discovery for Claude', () => {
+ const discovery = COMMIT_MESSAGE_AGENT_SPECS.claude?.modelDiscovery
+ expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.modelSource).toBe('dynamic')
+ expect(discovery?.binary).toBe('claude')
+ expect(discovery?.args).toEqual([
+ '-p',
+ '--input-format',
+ 'stream-json',
+ '--output-format',
+ 'stream-json',
+ '--verbose'
+ ])
+ const payload = JSON.parse(discovery?.stdinPayload ?? '') as {
+ type?: string
+ request?: { subtype?: string }
+ }
+ expect(payload.type).toBe('control_request')
+ expect(payload.request?.subtype).toBe('list_models')
+ expect(discovery?.stdinPayload?.endsWith('\n')).toBe(true)
+ })
+
it('parses Codex model JSON', () => {
expect(
parseCodexModels(
diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts
index 98ed09d681e..a6b91755b74 100644
--- a/src/shared/commit-message-agent-spec.ts
+++ b/src/shared/commit-message-agent-spec.ts
@@ -1,6 +1,11 @@
import type { TuiAgent } from './types'
import { isTuiAgentEnabled } from './tui-agent-selection'
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
+import {
+ CLAUDE_MODEL_LIST_ARGS,
+ CLAUDE_MODEL_LIST_STDIN,
+ parseClaudeModelList
+} from './claude-model-list-probe'
/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */
@@ -16,10 +21,14 @@ export type CommitMessageModel = {
id: string
/** Visible label in the model dropdown. */
label: string
+ /** Discovery-provided detail, e.g. what a CLI alias resolves to on this host. */
+ description?: string
/** Omit when the model does not expose an effort selector — the UI then hides the dropdown. */
thinkingLevels?: ThinkingLevel[]
/** Required when thinkingLevels is present. */
defaultThinkingLevel?: string
+ /** Whether the model exposes Claude's mid-session Fast mode toggle. */
+ supportsFastMode?: boolean
}
export type CommitMessageAgentSpec = {
@@ -37,6 +46,8 @@ export type CommitMessageAgentSpec = {
modelDiscovery?: {
binary: string
args: string[]
+ /** Written to the CLI's stdin, for CLIs whose listing is request-driven. */
+ stdinPayload?: string
parse: (stdout: string) => CommitMessageModel[]
}
models: CommitMessageModel[]
@@ -46,8 +57,10 @@ export type CommitMessageAgentSpec = {
export type CommitMessageModelCapability = {
id: string
label: string
+ description?: string
thinkingLevels?: ThinkingLevel[]
defaultThinkingLevel?: string
+ supportsFastMode?: boolean
}
export type CommitMessageAgentCapability = {
@@ -139,6 +152,30 @@ function withOpenAiThinking(
: {}
}
+export function parseClaudeModels(stdout: string): CommitMessageModel[] {
+ return uniqueModels(
+ parseClaudeModelList(stdout).map((model) => {
+ const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) =>
+ model.effortLevels.includes(level.id)
+ )
+ return {
+ id: model.id,
+ label: model.label,
+ ...(model.description ? { description: model.description } : {}),
+ ...(thinkingLevels.length > 0
+ ? {
+ thinkingLevels,
+ defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low')
+ ? 'low'
+ : thinkingLevels[0].id
+ }
+ : {}),
+ ...(model.supportsFastMode ? { supportsFastMode: true } : {})
+ }
+ })
+ )
+}
+
export function parseCodexModels(stdout: string): CommitMessageModel[] {
try {
assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS)
@@ -311,7 +348,16 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial ({
id: model.id,
label: model.label,
+ ...(model.description ? { description: model.description } : {}),
...(model.thinkingLevels ? { thinkingLevels: [...model.thinkingLevels] } : {}),
- ...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {})
+ ...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}),
+ ...(model.supportsFastMode ? { supportsFastMode: true } : {})
}))
}
}
diff --git a/src/shared/commit-message-host-key.ts b/src/shared/commit-message-host-key.ts
index 6edf51a9bc3..9a93dec3992 100644
--- a/src/shared/commit-message-host-key.ts
+++ b/src/shared/commit-message-host-key.ts
@@ -2,6 +2,13 @@ export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local'
export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown'
export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:'
+export function getCommitMessageModelDiscoveryHostKeyForLocalRuntime(
+ wslDistro: string | null | undefined
+): string {
+ const distro = wslDistro?.trim()
+ return distro ? `wsl:${distro}` : LOCAL_COMMIT_MESSAGE_HOST_KEY
+}
+
export function getCommitMessageModelDiscoveryHostKey(
connectionId: string | null | undefined
): string {
From 9deee5ad2f2d1170c30b45febb014a0792ce42ab Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:53:11 -0700
Subject: [PATCH 16/42] perf(worktrees): delete worktree directories after the
removal returns (#12416)
* perf(worktrees): delete worktree directories after the removal returns
`git worktree remove` deleted the whole checkout inline, so the remove IPC held the
watcher/PTY gate for the entire recursive delete (prod traces: worktree.remove.git_remove
p50 8-14s, p90 29s, max 34.7s). Local removals now rename the checkout into a hidden
sibling trash root, clear Git's registration for the missing path, and delete the moved
tree in the background. Renames that cannot run (WSL, other volume, Windows open handles)
fall back to the previous in-place removal unchanged.
* test(worktrees): keep no empty trash root when the rename cannot run
* fix(worktrees): harden deferred trash cleanup
* fix(worktrees): keep WSL trash on its owning host
---
src/main/git/remove-worktree.test.ts | 247 +++++++++++++++++-
...worktree-deferred-removal-real-git.test.ts | 113 ++++++++
.../worktree-removal-large-tree.bench.test.ts | 85 ++++++
src/main/git/worktree.test.ts | 31 ++-
src/main/git/worktree.ts | 120 +++++++--
src/main/index.ts | 8 +
src/main/observability/instrumentation.ts | 1 +
src/main/worktree-trash.test.ts | 220 ++++++++++++++++
src/main/worktree-trash.ts | 175 +++++++++++++
src/shared/git-binary-compatibility.test.ts | 15 +-
10 files changed, 982 insertions(+), 33 deletions(-)
create mode 100644 src/main/git/worktree-deferred-removal-real-git.test.ts
create mode 100644 src/main/git/worktree-removal-large-tree.bench.test.ts
create mode 100644 src/main/worktree-trash.test.ts
create mode 100644 src/main/worktree-trash.ts
diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts
index db008799de9..15e115221ea 100644
--- a/src/main/git/remove-worktree.test.ts
+++ b/src/main/git/remove-worktree.test.ts
@@ -9,14 +9,26 @@ const {
translateWslOutputPathsMock,
statMock,
readFileMock,
- resolveGitDirMock
+ resolveGitDirMock,
+ moveWorktreeDirectoryToTrashMock,
+ restoreWorktreeDirectoryFromTrashMock,
+ scheduleWorktreeTrashDeletionMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output),
statMock: vi.fn(),
readFileMock: vi.fn(),
- resolveGitDirMock: vi.fn()
+ resolveGitDirMock: vi.fn(),
+ moveWorktreeDirectoryToTrashMock: vi.fn(),
+ restoreWorktreeDirectoryFromTrashMock: vi.fn(),
+ scheduleWorktreeTrashDeletionMock: vi.fn()
+}))
+
+vi.mock('../worktree-trash', () => ({
+ moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock,
+ restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock,
+ scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock
}))
vi.mock('./runner', () => ({
@@ -45,7 +57,8 @@ import {
removeWorktree,
_resetWorktreeScanCacheForTests,
WORKTREE_LIST_TIMEOUT_MS,
- WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
+ WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS,
+ WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
} from './worktree'
// Why: detectSparseCheckout on main also requires core.sparseCheckout=true in git
@@ -57,6 +70,12 @@ const ENABLED_SPARSE_CHECKOUT_CONFIG = '[core]\nsparseCheckout = true\n'
beforeEach(() => {
clearGitCapabilityStateForTests()
_resetWorktreeScanCacheForTests()
+ // Default: the checkout cannot be renamed aside, so removal deletes it in place.
+ moveWorktreeDirectoryToTrashMock.mockReset()
+ moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined)
+ restoreWorktreeDirectoryFromTrashMock.mockReset()
+ restoreWorktreeDirectoryFromTrashMock.mockResolvedValue(true)
+ scheduleWorktreeTrashDeletionMock.mockReset()
})
type MockResult = {
@@ -276,6 +295,8 @@ branch refs/heads/main
const calls = getGitCalls()
expect(calls).toEqual([
'git worktree list --porcelain -z',
+ // The cleanliness probe that decides whether the checkout may be renamed aside.
+ 'git status --porcelain --untracked-files=all',
'git worktree remove /repo-feature',
'git branch -d -- feature/test',
'git worktree prune',
@@ -283,6 +304,209 @@ branch refs/heads/main
])
})
+ it('renames the checkout aside and deregisters the missing path', async () => {
+ mockGitCommands({
+ 'git worktree list --porcelain': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-feature
+HEAD def456
+branch refs/heads/feature/test
+`
+ },
+ 'git worktree list --porcelain#2': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+`
+ }
+ })
+ moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-1-abcdef01')
+
+ await removeWorktree('/repo', '/repo-feature')
+
+ const calls = getGitCalls()
+ expect(moveWorktreeDirectoryToTrashMock).toHaveBeenCalledWith('/repo-feature')
+ expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-1-abcdef01')
+ expect(calls).toContain('git worktree remove --force /repo-feature')
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
+ ['worktree', 'remove', '--force', '/repo-feature'],
+ { cwd: '/repo', timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS }
+ )
+ expect(calls).not.toContain('git worktree remove /repo-feature')
+ expect(calls).not.toContain('git worktree prune')
+ expect(calls).toContain('git branch -d -- feature/test')
+ })
+
+ it('prunes the registration when deregistering the moved checkout fails', async () => {
+ mockGitCommands({
+ 'git worktree list --porcelain': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-feature
+HEAD def456
+branch refs/heads/feature/test
+`
+ },
+ 'git worktree list --porcelain#2': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+`
+ },
+ 'git worktree remove --force /repo-feature': {
+ error: new Error('fatal: validation failed, cannot remove working directory')
+ }
+ })
+ moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-2-abcdef02')
+
+ await removeWorktree('/repo', '/repo-feature')
+
+ expect(getGitCalls()).toContain('git worktree prune')
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
+ cwd: '/repo',
+ timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
+ })
+ expect(
+ gitExecFileAsyncMock.mock.calls.filter(
+ ([args, options]) =>
+ args.join(' ') === 'worktree list --porcelain -z' &&
+ options.timeout === WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
+ )
+ ).toHaveLength(2)
+ expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-2-abcdef02')
+ expect(restoreWorktreeDirectoryFromTrashMock).not.toHaveBeenCalled()
+ })
+
+ it('restores the moved checkout and removes in place when the registration survives pruning', async () => {
+ mockGitCommands({
+ 'git worktree list --porcelain': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-feature
+HEAD def456
+branch refs/heads/feature/test
+`
+ },
+ 'git worktree remove --force /repo-feature': {
+ error: new Error('fatal: validation failed, cannot remove working directory')
+ }
+ })
+ moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-3-abcdef03')
+
+ await removeWorktree('/repo', '/repo-feature')
+
+ expect(restoreWorktreeDirectoryFromTrashMock).toHaveBeenCalledWith(
+ '/trash/wt-3-abcdef03',
+ '/repo-feature'
+ )
+ expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled()
+ expect(getGitCalls()).toContain('git worktree remove /repo-feature')
+ })
+
+ it('removes in place when the checkout cannot be renamed aside', async () => {
+ mockGitCommands({
+ 'git worktree list --porcelain': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-feature
+HEAD def456
+branch refs/heads/feature/test
+`
+ },
+ 'git worktree list --porcelain#2': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+`
+ }
+ })
+ // Windows open handles and cross-volume renames both surface as an unavailable rename.
+ moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined)
+
+ await removeWorktree('/repo', '/repo-feature')
+
+ expect(getGitCalls()).toContain('git worktree remove /repo-feature')
+ expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled()
+ })
+
+ it('never renames a dirty checkout aside', async () => {
+ mockGitCommands({
+ 'git worktree list --porcelain': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-feature
+HEAD def456
+branch refs/heads/feature/test
+`
+ },
+ 'git status --porcelain --untracked-files=all': { stdout: ' M src/app.ts\n' },
+ 'git worktree remove /repo-feature': {
+ error: new Error('fatal: contains modified or untracked files, use --force to delete it')
+ }
+ })
+
+ await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow(
+ 'contains modified or untracked files'
+ )
+ expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
+ })
+
+ it('deletes WSL-hosted checkouts in place inside the distro', async () => {
+ mockGitCommands({
+ 'git worktree list --porcelain': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-feature
+HEAD def456
+branch refs/heads/feature/test
+`
+ },
+ 'git worktree list --porcelain#2': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+`
+ }
+ })
+
+ await removeWorktree('/repo', '/repo-feature', false, { wslDistro: 'Ubuntu' })
+
+ expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
+ expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all')
+ expect(getGitCalls()).toContain('git worktree remove /repo-feature')
+ })
+
+ it('does not rename a WSL checkout configured for a native Windows repo', async () => {
+ const originalPlatform = process.platform
+ const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature'
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ try {
+ mockGitCommands({})
+
+ await removeWorktree('C:\\repo', worktreePath, false, {
+ knownRemovedWorktree: { branch: '', head: '', locked: false }
+ })
+
+ expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
+ expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`])
+ } finally {
+ Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
+ }
+ })
+
it('passes one --force before the worktree path for dirty-file removal', async () => {
mockGitCommands({
'git worktree list --porcelain': {
@@ -339,13 +563,15 @@ branch refs/heads/main
expectGitCallOrder(
calls,
'git worktree remove /repo-feature',
- 'git status --porcelain --untracked-files=all'
- )
- expectGitCallOrder(
- calls,
- 'git status --porcelain --untracked-files=all',
'git worktree remove --force /repo-feature'
)
+ // The re-proof of cleanliness between the refusal and the forced retry.
+ expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeGreaterThan(
+ calls.indexOf('git worktree remove /repo-feature')
+ )
+ expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeLessThan(
+ calls.indexOf('git worktree remove --force /repo-feature')
+ )
expect(calls).toContain('git branch -d -- feature/test')
})
@@ -420,7 +646,10 @@ branch refs/heads/feature/test
'git worktree remove failed'
)
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
- expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all')
+ // Only the pre-rename probe ran: an unrelated failure must not re-prove cleanliness.
+ expect(
+ getGitCalls().filter((call) => call === 'git status --porcelain --untracked-files=all')
+ ).toHaveLength(1)
})
it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => {
diff --git a/src/main/git/worktree-deferred-removal-real-git.test.ts b/src/main/git/worktree-deferred-removal-real-git.test.ts
new file mode 100644
index 00000000000..374be4ecc0f
--- /dev/null
+++ b/src/main/git/worktree-deferred-removal-real-git.test.ts
@@ -0,0 +1,113 @@
+// Real-binary coverage for deferred worktree deletion: the mocked-runner suite cannot prove that Git
+// accepts `worktree remove --force` on a path Orca just renamed away.
+import { execFile } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { promisify } from 'node:util'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { removeWorktree } from './worktree'
+import {
+ getWorktreeTrashRoot,
+ isWorktreeTrashEntryName,
+ sweepStaleWorktreeTrash,
+ whenWorktreeTrashDeletionsSettled,
+ WORKTREE_TRASH_DIR_NAME
+} from '../worktree-trash'
+
+const execFileAsync = promisify(execFile)
+
+let scratchDir = ''
+let repoPath = ''
+let workspaceRoot = ''
+let worktreePath = ''
+
+async function git(args: string[], cwd: string): Promise {
+ const { stdout } = await execFileAsync('git', args, { cwd })
+ return stdout
+}
+
+beforeEach(async () => {
+ // realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca
+ // matches the worktree it is removing against Git's own list.
+ scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-deferred-worktree-removal-')))
+ repoPath = join(scratchDir, 'repo')
+ workspaceRoot = join(scratchDir, 'workspaces')
+ worktreePath = join(workspaceRoot, 'repo', 'feature')
+ await mkdir(repoPath, { recursive: true })
+ await mkdir(join(workspaceRoot, 'repo'), { recursive: true })
+ await git(['init', '-q'], repoPath)
+ await git(['config', 'user.email', 'deferred@example.invalid'], repoPath)
+ await git(['config', 'user.name', 'Deferred Removal'], repoPath)
+ await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
+ // Committed before the worktree exists so its branch stays merged and branch cleanup can run.
+ await mkdir(join(repoPath, 'node_modules', 'pkg'), { recursive: true })
+ await writeFile(join(repoPath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n')
+ await git(['add', '-A'], repoPath)
+ await git(['commit', '-qm', 'seed'], repoPath)
+ await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath)
+})
+
+afterEach(async () => {
+ await whenWorktreeTrashDeletionsSettled()
+ await rm(scratchDir, { recursive: true, force: true })
+})
+
+describe('deferred worktree removal against the real Git binary', () => {
+ it('clears the registration and deletes the renamed checkout in the background', async () => {
+ const trashRoot = getWorktreeTrashRoot(worktreePath)
+
+ await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
+
+ // The user-visible removal is complete: nothing on disk, nothing registered.
+ expect(existsSync(worktreePath)).toBe(false)
+ expect(await git(['worktree', 'list'], repoPath)).not.toContain(worktreePath)
+ // Only the rename path creates this root, so its presence proves the deletion was deferred.
+ expect(existsSync(trashRoot)).toBe(true)
+ expect((await readdir(trashRoot)).every(isWorktreeTrashEntryName)).toBe(true)
+
+ await whenWorktreeTrashDeletionsSettled()
+ expect(await readdir(trashRoot)).toEqual([])
+ })
+
+ it('leaves sibling worktrees registered', async () => {
+ const siblingPath = join(workspaceRoot, 'repo', 'sibling')
+ await git(['worktree', 'add', '-q', siblingPath, '-b', 'sibling'], repoPath)
+
+ await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
+
+ expect(await git(['worktree', 'list'], repoPath)).toContain(siblingPath)
+ expect(existsSync(siblingPath)).toBe(true)
+ })
+
+ it('deletes the branch exactly as the in-place removal did', async () => {
+ await removeWorktree(repoPath, worktreePath, false)
+
+ expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('')
+ })
+
+ it('refuses to move a dirty checkout aside', async () => {
+ await writeFile(join(worktreePath, 'seed.txt'), 'edited\n')
+
+ await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow()
+ expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true)
+ expect(await git(['worktree', 'list'], repoPath)).toContain(worktreePath)
+ expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false)
+ })
+
+ it('sweeps trash a previous run left behind', async () => {
+ const stalePath = join(
+ workspaceRoot,
+ 'repo',
+ WORKTREE_TRASH_DIR_NAME,
+ 'wt-1700000000000-deadbeef'
+ )
+ await mkdir(join(stalePath, 'node_modules'), { recursive: true })
+
+ await sweepStaleWorktreeTrash([workspaceRoot])
+
+ expect(existsSync(stalePath)).toBe(false)
+ expect(existsSync(worktreePath)).toBe(true)
+ })
+})
diff --git a/src/main/git/worktree-removal-large-tree.bench.test.ts b/src/main/git/worktree-removal-large-tree.bench.test.ts
new file mode 100644
index 00000000000..debeb6210ed
--- /dev/null
+++ b/src/main/git/worktree-removal-large-tree.bench.test.ts
@@ -0,0 +1,85 @@
+// Manual benchmark for the `worktree.remove.git_remove` stage on a large checkout.
+// Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 pnpm exec vitest run \
+// --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { promisify } from 'node:util'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { removeWorktree } from './worktree'
+import { getWorktreeTrashRoot, whenWorktreeTrashDeletionsSettled } from '../worktree-trash'
+
+const execFileAsync = promisify(execFile)
+const describeBench = process.env.ORCA_WORKTREE_REMOVAL_BENCH ? describe : describe.skip
+
+const FIXTURE_DIRECTORIES = 200
+const FIXTURE_FILES_PER_DIRECTORY = 500
+
+describeBench('worktree removal on a large checkout', () => {
+ let scratchDir = ''
+ let repoPath = ''
+ let worktreePath = ''
+
+ async function git(args: string[], cwd: string): Promise {
+ await execFileAsync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024 })
+ }
+
+ beforeAll(async () => {
+ scratchDir = await mkdtemp(join(tmpdir(), 'orca-worktree-removal-bench-'))
+ repoPath = join(scratchDir, 'repo')
+ worktreePath = join(scratchDir, 'workspaces', 'repo', 'bench')
+ await mkdir(repoPath, { recursive: true })
+ await git(['init', '-q', '-b', 'main'], repoPath)
+ await git(['config', 'user.email', 'bench@example.invalid'], repoPath)
+ await git(['config', 'user.name', 'Bench'], repoPath)
+ await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
+ await git(['add', 'seed.txt'], repoPath)
+ await git(['commit', '-qm', 'seed'], repoPath)
+ await mkdir(join(scratchDir, 'workspaces', 'repo'), { recursive: true })
+ await git(['worktree', 'add', '-q', worktreePath, '-b', 'bench'], repoPath)
+
+ // A committed synthetic node_modules-shaped tree: the removal must be clean-checked and deleted.
+ for (let dirIndex = 0; dirIndex < FIXTURE_DIRECTORIES; dirIndex += 1) {
+ const dir = join(worktreePath, 'node_modules', `pkg-${dirIndex}`)
+ await mkdir(dir, { recursive: true })
+ await Promise.all(
+ Array.from({ length: FIXTURE_FILES_PER_DIRECTORY }, (_unused, fileIndex) =>
+ writeFile(join(dir, `file-${fileIndex}.js`), `module.exports = ${fileIndex}\n`)
+ )
+ )
+ }
+ await git(['add', '-A'], worktreePath)
+ await git(['commit', '-qm', 'large tree'], worktreePath)
+ }, 900_000)
+
+ afterAll(async () => {
+ await whenWorktreeTrashDeletionsSettled()
+ if (scratchDir) {
+ await rm(scratchDir, { recursive: true, force: true })
+ }
+ })
+
+ it('returns from removeWorktree without waiting for the recursive delete', async () => {
+ const startedAt = Date.now()
+ await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
+ const userVisibleMs = Date.now() - startedAt
+
+ const trashRoot = getWorktreeTrashRoot(worktreePath)
+ console.log(
+ `[bench] files=${FIXTURE_DIRECTORIES * FIXTURE_FILES_PER_DIRECTORY} removeWorktree=${userVisibleMs}ms`
+ )
+ expect(existsSync(worktreePath)).toBe(false)
+ const { stdout } = await execFileAsync('git', ['worktree', 'list'], { cwd: repoPath })
+ expect(stdout).not.toContain(worktreePath)
+
+ const backgroundStartedAt = Date.now()
+ await whenWorktreeTrashDeletionsSettled()
+ console.log(
+ `[bench] background deletion=${Date.now() - backgroundStartedAt}ms (trash root ${trashRoot})`
+ )
+ expect(existsSync(trashRoot)).toBe(true)
+ expect(await readdir(trashRoot)).toEqual([])
+ }, 900_000)
+})
diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts
index 493beb1d91f..ff72ce6ea2c 100644
--- a/src/main/git/worktree.test.ts
+++ b/src/main/git/worktree.test.ts
@@ -4,13 +4,17 @@
without a meaningful boundary. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
- () => ({
- gitExecFileAsyncMock: vi.fn(),
- gitExecFileSyncMock: vi.fn(),
- translateWslOutputPathsMock: vi.fn((output: string) => output)
- })
-)
+const {
+ gitExecFileAsyncMock,
+ gitExecFileSyncMock,
+ translateWslOutputPathsMock,
+ moveWorktreeDirectoryToTrashMock
+} = vi.hoisted(() => ({
+ gitExecFileAsyncMock: vi.fn(),
+ gitExecFileSyncMock: vi.fn(),
+ translateWslOutputPathsMock: vi.fn((output: string) => output),
+ moveWorktreeDirectoryToTrashMock: vi.fn()
+}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
@@ -18,6 +22,13 @@ vi.mock('./runner', () => ({
translateWslOutputPaths: translateWslOutputPathsMock
}))
+// Default: the checkout cannot be renamed aside, so removal deletes it in place.
+vi.mock('../worktree-trash', () => ({
+ moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined),
+ restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true),
+ scheduleWorktreeTrashDeletion: vi.fn()
+}))
+
import { clearGitCapabilityStateForTests } from './git-capability-state'
import {
@@ -1792,6 +1803,7 @@ describe('removeWorktree', () => {
it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
// Git refuses to delete an unmerged branch with `-d`.
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d
@@ -1808,6 +1820,7 @@ describe('removeWorktree', () => {
it('deletes the branch when `branch -d` succeeds (fully merged)', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds
@@ -1822,6 +1835,7 @@ describe('removeWorktree', () => {
})
it('reuses known removed worktree metadata instead of relisting before removal', async () => {
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds
@@ -1833,6 +1847,7 @@ describe('removeWorktree', () => {
})
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
+ ['status', '--porcelain', '--untracked-files=all'],
['worktree', 'remove', '/repo-feature'],
['branch', '-d', '--', 'feature/test']
])
@@ -1840,6 +1855,7 @@ describe('removeWorktree', () => {
it('prunes and retries branch deletion only when Git reports a checked-out branch', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
gitExecFileAsyncMock.mockRejectedValueOnce(
new Error("error: cannot delete branch 'feature/test' used by worktree at '/repo-stale'")
@@ -1851,6 +1867,7 @@ describe('removeWorktree', () => {
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
['worktree', 'list', '--porcelain', '-z'],
+ ['status', '--porcelain', '--untracked-files=all'],
['worktree', 'remove', '/repo-feature'],
['branch', '-d', '--', 'feature/test'],
['worktree', 'prune'],
diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts
index 4ea08ef687b..064eb0887fd 100644
--- a/src/main/git/worktree.ts
+++ b/src/main/git/worktree.ts
@@ -7,6 +7,13 @@ import {
} from '../../shared/git-branch-cleanup'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { withSpan } from '../observability/tracer'
+import { withWorktreeRemoveStageSpan } from '../observability/instrumentation'
+import {
+ moveWorktreeDirectoryToTrash,
+ restoreWorktreeDirectoryFromTrash,
+ scheduleWorktreeTrashDeletion
+} from '../worktree-trash'
+import { parseWslPath } from '../wsl'
import type {
GitWorktreeInfo,
LocalBaseRefRefreshResult,
@@ -87,6 +94,7 @@ const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225).
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000
+export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000
// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list.
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
@@ -1131,23 +1139,27 @@ async function performRemoveWorktree(
// Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects.
assertWorktreeUnlockedForRemoval(removedWorktree)
- const args = ['worktree', 'remove']
- if (force) {
- args.push('--force')
- }
- args.push(worktreePath)
- try {
- await gitExecFileAsync(args, gitExecOptions(repoPath, options))
- } catch (error) {
- if (force || !isSubmoduleWorktreeRemovalRefusal(error)) {
- throw error
+ if (
+ !(await tryRemoveWorktreeWithDeferredDirectoryDeletion(repoPath, worktreePath, force, options))
+ ) {
+ const args = ['worktree', 'remove']
+ if (force) {
+ args.push('--force')
+ }
+ args.push(worktreePath)
+ try {
+ await gitExecFileAsync(args, gitExecOptions(repoPath, options))
+ } catch (error) {
+ if (force || !isSubmoduleWorktreeRemovalRefusal(error)) {
+ throw error
+ }
+ // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force.
+ await assertWorktreeCleanForRemoval(worktreePath, false, options)
+ await gitExecFileAsync(
+ ['worktree', 'remove', '--force', worktreePath],
+ gitExecOptions(repoPath, options)
+ )
}
- // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force.
- await assertWorktreeCleanForRemoval(worktreePath, false, options)
- await gitExecFileAsync(
- ['worktree', 'remove', '--force', worktreePath],
- gitExecOptions(repoPath, options)
- )
}
if (!branchName) {
@@ -1164,6 +1176,82 @@ async function performRemoveWorktree(
)
}
+/**
+ * Rename the checkout into a sibling trash directory and clear Git's registration for the
+ * now-missing path, so the multi-GB recursive delete runs after this removal has returned.
+ * Returns false when the caller must let `git worktree remove` delete the directory inline.
+ */
+async function tryRemoveWorktreeWithDeferredDirectoryDeletion(
+ repoPath: string,
+ worktreePath: string,
+ force: boolean,
+ options: RemoveWorktreeOptions
+): Promise {
+ // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not rename them.
+ if (options.wslDistro || parseWslPath(worktreePath)) {
+ return false
+ }
+ if (!force) {
+ try {
+ // Why: `git worktree remove` re-checks cleanliness as it removes; prove the same thing here or leave removal to Git.
+ await assertWorktreeCleanForRemoval(worktreePath, false, options)
+ } catch {
+ return false
+ }
+ }
+
+ const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () =>
+ moveWorktreeDirectoryToTrash(worktreePath)
+ )
+ if (!trashPath) {
+ return false
+ }
+ try {
+ await clearGitRegistrationForMissingWorktree(repoPath, worktreePath, options)
+ } catch (error) {
+ // Why: put the checkout back so the in-place removal below still sees the worktree Git registered.
+ if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) {
+ return false
+ }
+ throw error
+ }
+ scheduleWorktreeTrashDeletion(trashPath)
+ return true
+}
+
+async function clearGitRegistrationForMissingWorktree(
+ repoPath: string,
+ worktreePath: string,
+ options: RemoveWorktreeOptions
+): Promise {
+ const registrationOptions = {
+ ...options,
+ timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
+ }
+ try {
+ // Removing an already-missing directory is accepted back to the Git 2.25 baseline and touches only this entry.
+ await gitExecFileAsync(
+ ['worktree', 'remove', '--force', worktreePath],
+ gitExecOptions(repoPath, registrationOptions)
+ )
+ return
+ } catch (error) {
+ console.warn(
+ `[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`,
+ error
+ )
+ }
+
+ await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, registrationOptions))
+ // Strict (not the shared scan): an unreadable repo must not read as proof that the row is gone.
+ const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some(
+ (worktree) => areWorktreePathsEqual(worktree.path, worktreePath)
+ )
+ if (stillRegistered) {
+ throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`)
+ }
+}
+
async function deleteBranchAfterWorktreeRemoval(
repoPath: string,
branchName: string,
diff --git a/src/main/index.ts b/src/main/index.ts
index 6e769cd39d6..87a6b3fc345 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -221,6 +221,7 @@ import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/cod
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume'
import { getDefaultWslDistro } from './wsl'
+import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash'
import { ClaudeAccountService } from './claude-accounts/service'
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
import {
@@ -2627,6 +2628,13 @@ void app.whenReady().then(async () => {
// Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace.
const emulatorBridge = new EmulatorBridge()
runtimeService.setEmulatorBridge(emulatorBridge)
+ // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or
+ // crash mid-delete can leave the moved directory on disk.
+ void sweepStaleWorktreeTrash(
+ collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings())
+ ).catch((error) => {
+ console.warn('[worktrees] Failed to sweep leftover worktree directories:', error)
+ })
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) {
// Why: establish capability before managed-hook reconciliation so an
diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts
index d4ab5d4e497..369312476f2 100644
--- a/src/main/observability/instrumentation.ts
+++ b/src/main/observability/instrumentation.ts
@@ -214,6 +214,7 @@ export type WorktreeRemoveStage =
| 'git_remove'
| 'metadata_purge'
| 'pty_sweep'
+ | 'trash_rename'
| 'watcher_gate'
/** Wrap one stage of a worktree removal. Children share the parent's `kind` so `kind`-filtered
diff --git a/src/main/worktree-trash.test.ts b/src/main/worktree-trash.test.ts
new file mode 100644
index 00000000000..49a8af75a1d
--- /dev/null
+++ b/src/main/worktree-trash.test.ts
@@ -0,0 +1,220 @@
+import { existsSync } from 'node:fs'
+import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import type { Repo } from '../shared/types'
+import {
+ collectWorktreeTrashSweepRoots,
+ getWorktreeTrashRoot,
+ isWorktreeTrashEntryName,
+ moveWorktreeDirectoryToTrash,
+ restoreWorktreeDirectoryFromTrash,
+ scheduleWorktreeTrashDeletion,
+ sweepStaleWorktreeTrash,
+ whenWorktreeTrashDeletionsSettled,
+ WORKTREE_TRASH_DIR_NAME
+} from './worktree-trash'
+
+let scratchDir = ''
+
+beforeEach(async () => {
+ scratchDir = await mkdtemp(join(tmpdir(), 'orca-worktree-trash-'))
+})
+
+afterEach(async () => {
+ await whenWorktreeTrashDeletionsSettled()
+ await rm(scratchDir, { recursive: true, force: true })
+})
+
+async function createWorktreeDirectory(worktreePath: string): Promise {
+ await mkdir(join(worktreePath, 'node_modules', 'pkg'), { recursive: true })
+ await writeFile(join(worktreePath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n')
+}
+
+describe('moveWorktreeDirectoryToTrash', () => {
+ it('renames the checkout into a hidden sibling trash root', async () => {
+ const worktreePath = join(scratchDir, 'repo', 'feature')
+ await createWorktreeDirectory(worktreePath)
+
+ const trashPath = await moveWorktreeDirectoryToTrash(worktreePath)
+
+ expect(trashPath).toBeDefined()
+ expect(existsSync(worktreePath)).toBe(false)
+ expect(trashPath!.startsWith(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(true)
+ expect(existsSync(join(trashPath!, 'node_modules', 'pkg', 'index.js'))).toBe(true)
+ })
+
+ it('generates sweepable, collision-free entry names', async () => {
+ const first = await moveWorktreeDirectoryToTrash(await seededWorktree('one'))
+ const second = await moveWorktreeDirectoryToTrash(await seededWorktree('two'))
+
+ expect(first).not.toEqual(second)
+ expect(await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toHaveLength(2)
+ for (const entry of await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))) {
+ expect(isWorktreeTrashEntryName(entry)).toBe(true)
+ }
+ })
+
+ it('reports the rename as unavailable and leaves no trash root when the checkout is missing', async () => {
+ const worktreePath = join(scratchDir, 'repo', 'gone')
+
+ expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined()
+ expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false)
+ })
+
+ it.skipIf(process.platform === 'win32')('refuses a symlinked trash root', async () => {
+ const worktreePath = join(scratchDir, 'repo', 'feature')
+ const externalRoot = join(scratchDir, 'external')
+ await createWorktreeDirectory(worktreePath)
+ await mkdir(externalRoot)
+ await symlink(externalRoot, getWorktreeTrashRoot(worktreePath))
+
+ expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined()
+ expect(existsSync(worktreePath)).toBe(true)
+ expect(await readdir(externalRoot)).toEqual([])
+ })
+
+ async function seededWorktree(name: string): Promise {
+ const worktreePath = join(scratchDir, 'repo', name)
+ await createWorktreeDirectory(worktreePath)
+ return worktreePath
+ }
+})
+
+describe('restoreWorktreeDirectoryFromTrash', () => {
+ it('puts the checkout back where Git registered it', async () => {
+ const worktreePath = join(scratchDir, 'repo', 'feature')
+ await createWorktreeDirectory(worktreePath)
+ const trashPath = await moveWorktreeDirectoryToTrash(worktreePath)
+
+ expect(await restoreWorktreeDirectoryFromTrash(trashPath!, worktreePath)).toBe(true)
+ expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true)
+ expect(existsSync(trashPath!)).toBe(false)
+ })
+
+ it('reports failure instead of throwing when the trashed path is gone', async () => {
+ const trashRoot = getWorktreeTrashRoot(join(scratchDir, 'repo', 'feature'))
+ expect(
+ await restoreWorktreeDirectoryFromTrash(
+ join(trashRoot, 'wt-1-abcdef01'),
+ join(scratchDir, 'repo', 'feature')
+ )
+ ).toBe(false)
+ })
+})
+
+describe('scheduleWorktreeTrashDeletion', () => {
+ it('deletes trashed checkouts in the background', async () => {
+ const worktreePath = join(scratchDir, 'repo', 'feature')
+ await createWorktreeDirectory(worktreePath)
+ const trashPath = await moveWorktreeDirectoryToTrash(worktreePath)
+
+ scheduleWorktreeTrashDeletion(trashPath!)
+ await whenWorktreeTrashDeletionsSettled()
+
+ expect(existsSync(trashPath!)).toBe(false)
+ expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(true)
+ })
+})
+
+describe('sweepStaleWorktreeTrash', () => {
+ it('removes leftover entries from both workspace layouts and nothing else', async () => {
+ const nestedTrashRoot = join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME)
+ const flatTrashRoot = join(scratchDir, WORKTREE_TRASH_DIR_NAME)
+ await mkdir(join(nestedTrashRoot, 'wt-1700000000000-abcdef01', 'src'), { recursive: true })
+ await mkdir(join(flatTrashRoot, 'wt-1700000000001-abcdef02'), { recursive: true })
+ // Not generated by Orca: a stray directory and file inside the trash root must survive.
+ await mkdir(join(nestedTrashRoot, 'unrelated-directory'), { recursive: true })
+ await writeFile(join(nestedTrashRoot, 'wt-notes.txt'), 'keep me\n')
+ const liveWorktree = join(scratchDir, 'repo', 'feature')
+ await createWorktreeDirectory(liveWorktree)
+
+ const { removed } = await sweepStaleWorktreeTrash([scratchDir])
+
+ expect(removed).toBe(2)
+ expect(existsSync(join(nestedTrashRoot, 'wt-1700000000000-abcdef01'))).toBe(false)
+ expect(existsSync(join(flatTrashRoot, 'wt-1700000000001-abcdef02'))).toBe(false)
+ expect(existsSync(join(nestedTrashRoot, 'unrelated-directory'))).toBe(true)
+ expect(existsSync(join(nestedTrashRoot, 'wt-notes.txt'))).toBe(true)
+ expect(existsSync(join(liveWorktree, 'node_modules', 'pkg', 'index.js'))).toBe(true)
+ })
+
+ it('ignores workspace roots that do not exist', async () => {
+ expect(await sweepStaleWorktreeTrash([join(scratchDir, 'missing')])).toEqual({ removed: 0 })
+ })
+
+ it('never descends past the trash roots beside worktrees', async () => {
+ const deepTrashRoot = join(scratchDir, 'repo', 'feature', WORKTREE_TRASH_DIR_NAME)
+ await mkdir(join(deepTrashRoot, 'wt-1700000000002-abcdef03'), { recursive: true })
+
+ expect(await sweepStaleWorktreeTrash([scratchDir])).toEqual({ removed: 0 })
+ expect(existsSync(join(deepTrashRoot, 'wt-1700000000002-abcdef03'))).toBe(true)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'does not sweep through a symlinked trash root',
+ async () => {
+ const externalEntry = join(scratchDir, 'external', 'wt-1700000000003-abcdef04')
+ await mkdir(externalEntry, { recursive: true })
+ await symlink(join(scratchDir, 'external'), join(scratchDir, WORKTREE_TRASH_DIR_NAME))
+
+ expect(await sweepStaleWorktreeTrash([scratchDir])).toEqual({ removed: 0 })
+ expect(existsSync(externalEntry)).toBe(true)
+ }
+ )
+})
+
+describe('collectWorktreeTrashSweepRoots', () => {
+ const settings = { workspaceDir: '/home/dev/orca/workspaces', nestWorkspaces: true }
+
+ function repo(overrides: Partial): Repo {
+ return { id: 'repo-1', path: '/home/dev/code/orca', ...overrides } as unknown as Repo
+ }
+
+ it('collects one root per local git repo', () => {
+ expect(
+ collectWorktreeTrashSweepRoots(
+ [repo({}), repo({ id: 'repo-2', path: '/code/other' })],
+ settings
+ )
+ ).toEqual(['/home/dev/orca/workspaces'])
+ })
+
+ it('honours a repo-specific worktree base path', () => {
+ expect(
+ collectWorktreeTrashSweepRoots([repo({ worktreeBasePath: '/volumes/fast/trees' })], settings)
+ ).toEqual(['/volumes/fast/trees'])
+ })
+
+ it('skips SSH repos and folder workspaces', () => {
+ expect(
+ collectWorktreeTrashSweepRoots(
+ [repo({ connectionId: 'ssh-1' }), repo({ id: 'repo-3', kind: 'folder' })],
+ settings
+ )
+ ).toEqual([])
+ })
+
+ it('skips WSL repos that cannot create host trash', () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ try {
+ expect(
+ collectWorktreeTrashSweepRoots(
+ [
+ repo({ path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }),
+ repo({
+ id: 'repo-2',
+ path: 'C:\\code\\orca',
+ worktreeBasePath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\trees'
+ })
+ ],
+ settings
+ )
+ ).toEqual([])
+ } finally {
+ Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
+ }
+ })
+})
diff --git a/src/main/worktree-trash.ts b/src/main/worktree-trash.ts
new file mode 100644
index 00000000000..5c0aa32be2b
--- /dev/null
+++ b/src/main/worktree-trash.ts
@@ -0,0 +1,175 @@
+// Why: `git worktree remove` deletes the whole checkout (usually a multi-GB node_modules) inside the
+// remove IPC, so the UI sat on a spinner for 8-35s. Renaming the directory into a sibling trash root
+// is a metadata operation, and the recursive delete then runs after the IPC has already returned.
+
+import { randomBytes } from 'node:crypto'
+import { lstat, mkdir, readdir, rename, rmdir } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { removeHostTree } from './host-tree-removal'
+import { isFolderRepo } from '../shared/repo-kind'
+import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic'
+import type { GlobalSettings, Repo } from '../shared/types'
+import { parseWslPath } from './wsl'
+
+export const WORKTREE_TRASH_DIR_NAME = '.orca-worktree-trash'
+
+// `-`: the nonce keeps concurrent removals of same-named worktrees from colliding.
+const TRASH_ENTRY_PATTERN = /^wt-\d+-[0-9a-f]{8}$/
+
+// Why: the sweep must stay cheap on a workspace root holding many repo containers.
+const TRASH_SWEEP_MAX_CONTAINERS = 200
+
+/** Trash root for a worktree: a hidden sibling, so the rename always stays on one volume. */
+export function getWorktreeTrashRoot(worktreePath: string): string {
+ return join(dirname(worktreePath), WORKTREE_TRASH_DIR_NAME)
+}
+
+export function isWorktreeTrashEntryName(entryName: string): boolean {
+ return TRASH_ENTRY_PATTERN.test(entryName)
+}
+
+/**
+ * Move a worktree directory aside so the caller can return before it is deleted.
+ * Returns the trash path, or `undefined` when the rename is unavailable (a different
+ * volume, or Windows open handles) and the caller must delete in place instead.
+ */
+export async function moveWorktreeDirectoryToTrash(
+ worktreePath: string
+): Promise {
+ const trashRoot = getWorktreeTrashRoot(worktreePath)
+ const trashPath = join(trashRoot, `wt-${Date.now()}-${randomBytes(4).toString('hex')}`)
+ try {
+ await mkdir(trashRoot, { recursive: true })
+ const trashRootStat = await lstat(trashRoot)
+ if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) {
+ throw new Error(`Refusing non-directory worktree trash root: ${trashRoot}`)
+ }
+ await rename(worktreePath, trashPath)
+ return trashPath
+ } catch (error) {
+ console.warn(
+ `[worktrees] Deferred deletion unavailable for ${worktreePath}; deleting in place`,
+ error
+ )
+ // Leave no empty trash root behind when the rename never happened; rmdir keeps queued entries.
+ await rmdir(trashRoot).catch(() => {})
+ return undefined
+ }
+}
+
+/** Undo a trash rename so a failed registration cleanup leaves the worktree exactly as it was. */
+export async function restoreWorktreeDirectoryFromTrash(
+ trashPath: string,
+ worktreePath: string
+): Promise {
+ try {
+ await rename(trashPath, worktreePath)
+ return true
+ } catch (error) {
+ console.warn(`[worktrees] Failed to restore ${worktreePath} from ${trashPath}`, error)
+ return false
+ }
+}
+
+// Why serialized: one background delete at a time keeps a burst of removals from saturating disk I/O
+// while the user keeps working.
+let queuedTrashDeletions: Promise = Promise.resolve()
+
+export function scheduleWorktreeTrashDeletion(trashPath: string): void {
+ queuedTrashDeletions = queuedTrashDeletions.then(async () => {
+ try {
+ await removeHostTree(trashPath)
+ } catch (error) {
+ // Why only a warning: the directory is already invisible to the user, and the startup sweep retries it.
+ console.warn(`[worktrees] Failed to delete trashed worktree at ${trashPath}`, error)
+ }
+ })
+}
+
+/** Test/shutdown hook: resolves once every queued background deletion has settled. */
+export function whenWorktreeTrashDeletionsSettled(): Promise {
+ return queuedTrashDeletions
+}
+
+/**
+ * Delete trash entries left behind by a previous run (a crash or a kill during background deletion).
+ * Only entries matching the generated name pattern inside a trash root are removed.
+ */
+export async function sweepStaleWorktreeTrash(
+ workspaceRoots: readonly string[]
+): Promise<{ removed: number }> {
+ let removed = 0
+ for (const trashRoot of await collectExistingTrashRoots(workspaceRoots)) {
+ let entries: string[]
+ try {
+ const trashRootStat = await lstat(trashRoot)
+ if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) {
+ continue
+ }
+ entries = await readdir(trashRoot)
+ } catch {
+ continue
+ }
+ for (const entry of entries) {
+ if (!isWorktreeTrashEntryName(entry)) {
+ continue
+ }
+ try {
+ await removeHostTree(join(trashRoot, entry))
+ removed += 1
+ } catch (error) {
+ console.warn(
+ `[worktrees] Failed to sweep leftover worktree at ${trashRoot}/${entry}`,
+ error
+ )
+ }
+ }
+ }
+ if (removed > 0) {
+ console.log(`[worktrees] Swept ${removed} leftover worktree director(ies) from a previous run`)
+ }
+ return { removed }
+}
+
+/** Trash roots live beside worktrees, so they sit at the workspace root (flat) or one level in (nested). */
+async function collectExistingTrashRoots(workspaceRoots: readonly string[]): Promise {
+ const trashRoots = new Set()
+ for (const workspaceRoot of new Set(workspaceRoots)) {
+ trashRoots.add(join(workspaceRoot, WORKTREE_TRASH_DIR_NAME))
+ let containers: string[] = []
+ try {
+ containers = (await readdir(workspaceRoot, { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory() && entry.name !== WORKTREE_TRASH_DIR_NAME)
+ .slice(0, TRASH_SWEEP_MAX_CONTAINERS)
+ .map((entry) => entry.name)
+ } catch {
+ continue
+ }
+ for (const container of containers) {
+ trashRoots.add(join(workspaceRoot, container, WORKTREE_TRASH_DIR_NAME))
+ }
+ }
+ return [...trashRoots]
+}
+
+/** Workspace roots of local git repos — the only places Orca creates worktree trash. */
+export function collectWorktreeTrashSweepRoots(
+ repos: readonly Repo[],
+ settings: Pick
+): string[] {
+ const roots = new Set()
+ for (const repo of repos) {
+ if (repo.connectionId || isFolderRepo(repo) || parseWslPath(repo.path)) {
+ continue
+ }
+ try {
+ const workspaceRoot = computeWorkspaceRoot(repo.path, getWorktreePathSettings(repo, settings))
+ if (!parseWslPath(workspaceRoot)) {
+ roots.add(workspaceRoot)
+ }
+ } catch {
+ // A repo with an unusable configured base path simply has no trash root to sweep.
+ }
+ }
+ return [...roots]
+}
diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts
index 906a9b78811..660a2b7aaad 100644
--- a/src/shared/git-binary-compatibility.test.ts
+++ b/src/shared/git-binary-compatibility.test.ts
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process'
-import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
@@ -137,6 +137,19 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
).resolves.toBeDefined()
})
+ it('deregisters a worktree whose directory was renamed away', async () => {
+ // Orca renames the checkout into a trash directory and then clears the registration, so every
+ // supported Git must accept `worktree remove --force` on the now-missing path.
+ await runGit(['worktree', 'add', '-b', 'compat-deferred', 'deferred-wt'])
+ await rename(join(repoPath, 'deferred-wt'), join(repoPath, 'deferred-trash'))
+
+ await expect(runGit(['worktree', 'remove', '--force', 'deferred-wt'])).resolves.toBeDefined()
+
+ const remaining = await runGit(['worktree', 'list', '--porcelain'])
+ expect(remaining.stdout).not.toContain('deferred-wt')
+ await rm(join(repoPath, 'deferred-trash'), { recursive: true, force: true })
+ })
+
it('recognizes ref and merge-tree compatibility boundaries', async () => {
await expectPreferredOrRecognizedFallback(
['for-each-ref', '--format=%(refname)', '--exclude=refs/remotes/**/HEAD', '--count=10'],
From fb27702100ea002b368022595df30f588880ce25 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:54:17 -0700
Subject: [PATCH 17/42] feat(updater): restart hourly build numbers per
version, restyle the timestamp (#12587)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The number answers "which build of 1.4.163 is this", so carrying it across
versions made it meaningless — 1.4.164 opened at 38 for no reason a reader
could see. It now counts titles matching the base version being built, so a
version bump restarts the series at 01.
Deriving it moves from workflow jq into the script, because the number depends
on the base version and only the script knows which base the published tags
resolved to.
Timestamps go from `07-31 13:54` to `Jul 31, 1:54PM`, still Pacific.
Co-authored-by: Orca
---
.github/workflows/hourly-mac-build.yml | 20 +++---
config/scripts/adhoc-build-version.mjs | 2 +-
config/scripts/adhoc-build-version.test.mjs | 8 +--
config/scripts/hourly-build-version.mjs | 56 +++++++++++++---
config/scripts/hourly-build-version.test.mjs | 66 ++++++++++++++++---
config/scripts/release-title-timestamp.mjs | 16 +++--
.../settings/ReleaseChannelSection.tsx | 2 +-
7 files changed, 129 insertions(+), 41 deletions(-)
diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml
index bc3c5a8afff..b8ec40a0058 100644
--- a/.github/workflows/hourly-mac-build.yml
+++ b/.github/workflows/hourly-mac-build.yml
@@ -182,20 +182,15 @@ jobs:
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
- # Why read the highest number off existing titles rather than counting
- # releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
- # roll backwards after three days and reissue numbers already in use. The
- # maximum is always among the retained builds, because pruning drops the
- # oldest. `capture(...)?` swallows the non-match on legacy titles, which
- # were the raw tag, so the series simply starts at 01.
+ # Existing titles, which carry the build number this series continues
+ # from. The script picks the number, because it restarts per base version
+ # and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
- last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
- --jq '[.[] | (.name // "") | capture(" • (?[0-9]+) • ")? | .n | tonumber] | max // 0')"
- build_number=$(( last_number + 1 ))
- echo "Hourly build number $build_number (previous high: $last_number)"
+ names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
+ --jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
@@ -207,9 +202,10 @@ jobs:
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
- ORCA_PUBLISHED_VERSIONS="$published" \
- ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
+ ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
+ node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
+ grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs
index b62f28d1c4f..c5a117f8ed6 100644
--- a/config/scripts/adhoc-build-version.mjs
+++ b/config/scripts/adhoc-build-version.mjs
@@ -71,7 +71,7 @@ export function normalizeAdhocLabel(label) {
}
/**
- * `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release
+ * `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release
* title, shown verbatim in both the GitHub releases list and the build picker.
*
* Why the label sits where hourly puts its build number: several adhoc builds
diff --git a/config/scripts/adhoc-build-version.test.mjs b/config/scripts/adhoc-build-version.test.mjs
index 4824720f2fc..b36ac2cdc6b 100644
--- a/config/scripts/adhoc-build-version.test.mjs
+++ b/config/scripts/adhoc-build-version.test.mjs
@@ -88,20 +88,20 @@ describe('formatAdhocReleaseName', () => {
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
it('renders version, label, Pacific timestamp, and short sha', () => {
- expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241')
+ expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
- expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ')
- expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
+ expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ')
+ expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ')
})
it('sanitizes the label before it reaches the title', () => {
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
- '1.4.163 • fix-now • 07-31 13:54 • e698241'
+ '1.4.163 • fix-now • Jul 31, 1:54PM • e698241'
)
})
diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs
index 12257c2b4ff..9f6b9000844 100644
--- a/config/scripts/hourly-build-version.mjs
+++ b/config/scripts/hourly-build-version.mjs
@@ -34,8 +34,34 @@ export function createHourlyBuildVersion(baseVersion, date) {
}
/**
- * `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
- * verbatim in both the GitHub releases list and the in-app build picker.
+ * The next build number for `baseVersion`, counting from the titles of existing
+ * hourly releases.
+ *
+ * Why the series restarts at 01 on every base version: the number answers "which
+ * build of 1.4.163 is this", so a counter shared across versions makes it
+ * meaningless — 1.4.164 would open at 38 for no reason a reader can see.
+ *
+ * Why the maximum rather than a count: the prune step trims to
+ * HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number
+ * already in use. Titles that predate this naming simply do not match, which is
+ * how the first build of a version lands on 01.
+ */
+export function nextHourlyBuildNumber(baseVersion, releaseNames = []) {
+ const prefix = `${baseVersion} • `
+ const highest = releaseNames.reduce((max, entry) => {
+ const name = String(entry ?? '')
+ if (!name.startsWith(prefix)) {
+ return max
+ }
+ const match = /^(\d+) • /.exec(name.slice(prefix.length))
+ return match ? Math.max(max, Number(match[1])) : max
+ }, 0)
+ return highest + 1
+}
+
+/**
+ * `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title,
+ * shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
@@ -49,21 +75,35 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
].join(' • ')
}
-export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) {
+// Why the number is derived here rather than passed in: it counts builds of the
+// base version, and the base is only known once the published tags have been
+// resolved just above. Computing it outside meant numbering against whatever
+// version the caller guessed.
+export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
- const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
+ const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
const version = createHourlyBuildVersion(base, now)
- return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
+ const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? [])
+ return {
+ commit,
+ version,
+ buildNumber,
+ name: formatHourlyReleaseName(version, buildNumber, commit, now)
+ }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
- const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
- const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv())
+ const identity = getHourlyBuildIdentity(new Date(), {
+ publishedVersions: readPublishedVersionsFromEnv(),
+ // Titles are newline separated and contain spaces, so this cannot reuse the
+ // whitespace split the version list gets.
+ releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
+ })
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
- `version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
+ `version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
)
}
diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs
index 4454ff81512..08fc9d28b81 100644
--- a/config/scripts/hourly-build-version.test.mjs
+++ b/config/scripts/hourly-build-version.test.mjs
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
+import {
+ createHourlyBuildVersion,
+ formatHourlyReleaseName,
+ nextHourlyBuildNumber
+} from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
@@ -34,21 +38,28 @@ describe('formatHourlyReleaseName', () => {
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
- expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
+ expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
- expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
- expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
+ expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241')
+ expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241')
})
- // Why: hour12: false renders midnight as "24" on some ICU builds, which would
- // read as an hour that does not exist and sort oddly beside 00:xx.
- it('renders midnight as 00, never 24', () => {
- expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
+ // Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in
+ // particular renders as 0:00 or 24:00 on a formatter set up carelessly.
+ it('renders both noon and midnight as 12', () => {
+ expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM')
+ expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM')
+ })
+
+ // Recent ICU puts U+202F before AM/PM; the title must carry no separator at all.
+ it('joins the meridiem with no whitespace of any kind', () => {
+ expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/)
+ expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u)
})
it('pads to two digits and grows past them', () => {
@@ -70,3 +81,42 @@ describe('formatHourlyReleaseName', () => {
)
})
})
+
+describe('nextHourlyBuildNumber', () => {
+ const titles = [
+ '1.4.163 • 01 • Jul 31, 1:54PM • e698241',
+ '1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa',
+ '1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'
+ ]
+
+ it('continues the series for the version being built', () => {
+ expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38)
+ })
+
+ // The point of the change: 1.4.164 opens its own series at 01 rather than
+ // inheriting 1.4.163's count, which said nothing about 1.4.164.
+ it('restarts at 1 when the base version moves', () => {
+ expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1)
+ expect(
+ nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc'])
+ ).toBe(2)
+ })
+
+ // Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting
+ // would roll backwards and reissue a number already used.
+ it('takes the highest number, not the count', () => {
+ expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10)
+ })
+
+ it('starts at 1 with no history at all', () => {
+ expect(nextHourlyBuildNumber('1.4.163')).toBe(1)
+ expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1)
+ })
+
+ // Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as
+ // a prefix of 1.4.163's series.
+ it('ignores titles that are not this version', () => {
+ expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1)
+ expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
+ })
+})
diff --git a/config/scripts/release-title-timestamp.mjs b/config/scripts/release-title-timestamp.mjs
index 56d3608ccaf..64897b4c319 100644
--- a/config/scripts/release-title-timestamp.mjs
+++ b/config/scripts/release-title-timestamp.mjs
@@ -1,7 +1,7 @@
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
- * `07-31 13:54` — the timestamp segment of a dev build's release title, shown
+ * `Jul 31, 8:10PM` — the timestamp segment of a dev build's release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
@@ -16,15 +16,17 @@ export function formatReleaseTitleTimestamp(date) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
minute: '2-digit',
- // Why h23 rather than hour12: false: some ICU builds render midnight as 24.
- hourCycle: 'h23'
+ hour12: true
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
- return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
+ // Assembled from parts rather than by string-editing the formatted output:
+ // recent ICU separates the time from AM/PM with U+202F, not a plain space, so
+ // a naive replace(' ', '') leaves the gap on some runtimes and not others.
+ return `${parts.month} ${parts.day}, ${parts.hour}:${parts.minute}${parts.dayPeriod.toUpperCase()}`
}
diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx
index dac9f1b0fa2..b89d8aa5084 100644
--- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx
+++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx
@@ -29,7 +29,7 @@ const CHANNEL_DESCRIPTIONS: Record = {
function formatBuildLabel(build: ReleaseBuild): string {
// Why the release's own title wins: the build workflows compose it (hourly
- // `1.4.163 • 01 • 07-31 13:54 • e698241`, adhoc `1.4.163 • wasm-terminal • …`),
+ // `1.4.163 • 01 • Jul 31, 1:54PM • e698241`, adhoc `1.4.163 • wasm-terminal • …`),
// so this row is the same string the GitHub releases list shows — one thing to
// search for in either place, rather than two renderings of the same build that
// have to be matched up by eye. For adhoc it is also the only place the branch
From 535b5a594d87e76970cb62a4d05b6125e59917f9 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:04:14 -0700
Subject: [PATCH 18/42] fix(mobile): keep paged chat history coherent across
reconnect replays (STA-3333) (#12494)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mobile): keep paged chat history coherent across reconnect replays
The transport replays nativeChat.subscribe with its original params after an
in-place reconnect, and the session hook treated every snapshot as a fresh
base — so a socket blip truncated paged-in history back to the initial 40.
A replay snapshot that extends a contiguous retained tail now merges in by id;
a disjoint replay (long outage, compaction while away) still replaces, since
stitching would leave a silent gap. Only a genuinely replaced window resets the
grown read limit and paging cursor, and any snapshot invalidates an in-flight
older-page request so a stale cursor result cannot land on the new window.
Refs STA-3333.
* test(mobile): pin the replay contiguity-scan rejection branches
The scan's three rejection rules were unpinned: deleting the
`sawNewMessage` guard, the ordering check, or the retained-tail anchor
each left the whole suite green. Cover the interleaved-new-row,
reordered-id, and short-of-tail cases, and the replaced-window hasMore
fallback. Each new test is mutation-proven to kill exactly one mutant.
* test(mobile): pin replay paging-metadata and base-snapshot authority
Two more branches of the replay logic were unpinned. Adopting a replay's
`beforeOffset` when it starts partway into paged-in history would make the
next loadEarlier re-fetch on-screen rows and prepend duplicates; treating a
post-replacement snapshot as a replay would retain a row the authoritative
window dropped. Both mutants now fail exactly one test.
* test(mobile): pin the replay removal boundary and base-snapshot bookkeeping
Two branches introduced by this PR survived the suite unpinned:
- `firstIndex > 0` was only pinned one-directionally. Weakening it to
`firstIndex > 1` kept all 28 tests green, so an off-by-one would silently
retain one row the host had already dropped.
- `snapshotSeenRef` is set only for snapshot frames. Setting it
unconditionally is invisible in normal flows, where the first frame is the
snapshot, but demotes the real base snapshot to a replay when a live append
lands first.
Each new test kills exactly one of those mutants and nothing else. No source
change.
* test(mobile): pin the older-page fence against a cursor-re-cutting replay
The snapshot arm of `if (applied.windowReplaced || frame.type === 'snapshot')`
was unpinned: deleting it kept the suite green.
It is load-bearing. A replay that merges cleanly can still carry a new
`beforeOffset`, which the hook adopts via `replayStillStartsAtOldest`. The page
already in flight was addressed with the old offset, so without the fence it
lands and writes its own stale cursor back over the fresh one, leaving the next
`loadEarlier` addressed from a byte offset that no longer describes the file.
Row order alone stays correct, which is why the ordering-only reasoning missed
this.
Sole failure under the mutation. No source change.
---
.../mobile-native-chat-stream-frame.test.ts | 246 +++++++++++++++++-
.../mobile-native-chat-stream-frame.ts | 60 ++++-
.../use-mobile-native-chat-session.test.ts | 215 +++++++++++++++
.../session/use-mobile-native-chat-session.ts | 36 ++-
4 files changed, 538 insertions(+), 19 deletions(-)
diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts
index 8d7d1e7e07a..b4ffe542266 100644
--- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts
+++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts
@@ -32,7 +32,8 @@ describe('applyMobileNativeChatStreamFrame', () => {
kind: 'messages',
messages: [message('a'), message('b')],
hasMore: true,
- beforeOffset: 123
+ beforeOffset: 123,
+ windowReplaced: true
})
})
@@ -42,7 +43,12 @@ describe('applyMobileNativeChatStreamFrame', () => {
const result = applyMobileNativeChatStreamFrame({
merger,
- frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] },
+ frame: {
+ type: 'snapshot',
+ messages: [message('b'), message('c'), message('d')],
+ hasMore: true,
+ beforeOffset: 20
+ },
limit: 3,
replaceSnapshot: false
})
@@ -50,7 +56,207 @@ describe('applyMobileNativeChatStreamFrame', () => {
expect(result).toMatchObject({
kind: 'messages',
messages: [message('b'), message('c'), message('d')],
- cursorInvalidated: true
+ cursorInvalidated: true,
+ hasMore: true
+ })
+ })
+
+ it('refreshes paging metadata when a replay still starts at the retained oldest row', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, [message('a'), message('b')])
+
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: {
+ type: 'snapshot',
+ messages: [message('a'), message('b')],
+ hasMore: false,
+ beforeOffset: 0
+ },
+ limit: 40,
+ replaceSnapshot: false
+ })
+ ).toEqual({
+ kind: 'messages',
+ messages: [message('a'), message('b')],
+ hasMore: false,
+ beforeOffset: 0
+ })
+ })
+
+ it('keeps paged-in history when a reconnect replay overlaps it', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['p1', 'p2', 'a', 'b'].map(message))
+
+ const result = applyMobileNativeChatStreamFrame({
+ merger,
+ // Replayed window: the retained tail plus what arrived while disconnected.
+ frame: { type: 'snapshot', messages: [message('a'), message('b'), message('c')] },
+ limit: 100,
+ replaceSnapshot: false
+ })
+
+ expect(result).toEqual({
+ kind: 'messages',
+ messages: ['p1', 'p2', 'a', 'b', 'c'].map(message)
+ })
+ })
+
+ it('ignores replay paging metadata that describes a row other than our oldest', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['p1', 'p2', 'a', 'b'].map(message))
+
+ // `beforeOffset` here points before 'a', not before 'p1'. Adopting it would
+ // make the next loadEarlier re-fetch 'p1'/'p2' and prepend them twice.
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: {
+ type: 'snapshot',
+ messages: [message('a'), message('b')],
+ hasMore: true,
+ beforeOffset: 500
+ },
+ limit: 100,
+ replaceSnapshot: false
+ })
+ ).toEqual({ kind: 'messages', messages: ['p1', 'p2', 'a', 'b'].map(message) })
+ })
+
+ it('replaces the window when a reconnect replay is disjoint from history', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, [message('old-1'), message('old-2')])
+
+ // A long outage (or compaction while away) cannot be stitched without a gap.
+ const result = applyMobileNativeChatStreamFrame({
+ merger,
+ frame: {
+ type: 'snapshot',
+ messages: [message('fresh-1'), message('fresh-2')],
+ hasMore: true,
+ beforeOffset: 900
+ },
+ limit: 100,
+ replaceSnapshot: false
+ })
+
+ expect(result).toEqual({
+ kind: 'messages',
+ messages: [message('fresh-1'), message('fresh-2')],
+ hasMore: true,
+ beforeOffset: 900,
+ windowReplaced: true
+ })
+ })
+
+ it('replaces a partially overlapping replay that no longer extends the retained tail', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['old-1', 'shared', 'old-tail'].map(message))
+
+ // `hasMore: true` so the authoritative-removal rule can't short-circuit —
+ // the contiguity scan itself must reject the interleaved new message.
+ const replay = [message('shared'), message('compacted-summary'), message('old-tail')]
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 9 },
+ limit: 100,
+ replaceSnapshot: false
+ })
+ ).toEqual({
+ kind: 'messages',
+ messages: replay,
+ hasMore: true,
+ beforeOffset: 9,
+ windowReplaced: true
+ })
+ })
+
+ it('replaces a replay that repeats retained ids out of order', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['a', 'b', 'c'].map(message))
+
+ const replay = [message('a'), message('c'), message('b')]
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 4 },
+ limit: 100,
+ replaceSnapshot: false
+ })
+ ).toEqual({
+ kind: 'messages',
+ messages: replay,
+ hasMore: true,
+ beforeOffset: 4,
+ windowReplaced: true
+ })
+ })
+
+ it('replaces a replay that stops short of the retained newest row', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['a', 'b', 'c'].map(message))
+
+ // Merging would keep 'c', a row the replayed window no longer carries.
+ const replay = [message('a'), message('b')]
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 1 },
+ limit: 100,
+ replaceSnapshot: false
+ })
+ ).toEqual({
+ kind: 'messages',
+ messages: replay,
+ hasMore: true,
+ beforeOffset: 1,
+ windowReplaced: true
+ })
+ })
+
+ it('replaces retained history when replay metadata says no earlier rows remain', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['removed-1', 'removed-2', 'a', 'b'].map(message))
+
+ const replay = [message('a'), message('b')]
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 },
+ limit: 100,
+ replaceSnapshot: false
+ })
+ ).toEqual({
+ kind: 'messages',
+ messages: replay,
+ hasMore: false,
+ beforeOffset: 0,
+ windowReplaced: true
+ })
+ })
+
+ it('replaces retained history when a single row precedes an authoritative replay', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['removed-1', 'a', 'b'].map(message))
+
+ // Boundary of the `firstIndex > 0` rule: one dropped row is still a dropped
+ // row, and merging here would strand 'removed-1' the host no longer has.
+ const replay = [message('a'), message('b')]
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 },
+ limit: 100,
+ replaceSnapshot: false
+ })
+ ).toEqual({
+ kind: 'messages',
+ messages: replay,
+ hasMore: false,
+ beforeOffset: 0,
+ windowReplaced: true
})
})
@@ -79,7 +285,39 @@ describe('applyMobileNativeChatStreamFrame', () => {
limit: 40,
replaceSnapshot: false
})
- ).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false })
+ ).toEqual({
+ kind: 'messages',
+ messages: [message('new')],
+ hasMore: false,
+ windowReplaced: true
+ })
+ })
+
+ it('still treats the base snapshot as authoritative after a replacement frame', () => {
+ const merger = createNativeChatMerger()
+ replaceList(merger, ['a', 'b'].map(message))
+
+ // A replacement is not this subscription's base snapshot, so the first real
+ // snapshot still replaces — merging would keep 'a', which it does not carry.
+ expect(
+ applyMobileNativeChatStreamFrame({
+ merger,
+ frame: {
+ type: 'snapshot',
+ messages: [message('b'), message('c')],
+ hasMore: true,
+ beforeOffset: 77
+ },
+ limit: 100,
+ replaceSnapshot: true
+ })
+ ).toEqual({
+ kind: 'messages',
+ messages: [message('b'), message('c')],
+ hasMore: true,
+ beforeOffset: 77,
+ windowReplaced: true
+ })
})
it('surfaces snapshot errors and ignores unrelated frames', () => {
diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts
index 690994f02f3..bef3dc5ef27 100644
--- a/mobile/src/session/mobile-native-chat-stream-frame.ts
+++ b/mobile/src/session/mobile-native-chat-stream-frame.ts
@@ -19,10 +19,47 @@ export type AppliedMobileNativeChatFrame =
hasMore?: boolean
beforeOffset?: number
cursorInvalidated?: boolean
+ /** The frame replaced the whole retained window (replacement, first
+ * snapshot, or a replay snapshot disjoint from local history) — the
+ * caller must reset its paging window/cursor to the frame's. */
+ windowReplaced?: boolean
}
+function replayRetainedTailStart(
+ merger: NativeChatMerger,
+ messages: readonly NativeChatMessage[],
+ hasMore: boolean | undefined
+): number | null {
+ const firstIndex = messages[0] ? merger.indexById.get(messages[0].id) : undefined
+ if (firstIndex === undefined) {
+ return null
+ }
+ // `hasMore: false` is authoritative: retained rows before the replay window
+ // were removed while disconnected, even when the newest IDs still match.
+ if (hasMore === false && firstIndex > 0) {
+ return null
+ }
+ let expectedIndex = firstIndex
+ let sawNewMessage = false
+ for (const message of messages) {
+ const existingIndex = merger.indexById.get(message.id)
+ if (existingIndex === undefined) {
+ sawNewMessage = true
+ } else if (sawNewMessage || existingIndex !== expectedIndex) {
+ return null
+ } else {
+ expectedIndex += 1
+ }
+ }
+ return expectedIndex === merger.list.length ? firstIndex : null
+}
+
/** Applies runtime stream frames while preserving the initial-snapshot versus
- * reconnect-replay distinction owned by the session hook. */
+ * reconnect-replay distinction owned by the session hook. A replay snapshot
+ * that extends a contiguous retained tail merges in by id — paged-in history
+ * survives a socket blip instead of collapsing to the replayed window. A
+ * discontinuous replay (long outage, compaction while away) can't be stitched
+ * without a gap, so it falls back to the fresh authoritative window. */
export function applyMobileNativeChatStreamFrame(args: {
merger: NativeChatMerger
frame: MobileNativeChatStreamFrame
@@ -42,22 +79,39 @@ export function applyMobileNativeChatStreamFrame(args: {
if (!Array.isArray(frame.messages)) {
return { kind: 'ignored' }
}
- if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) {
+ const replayStartIndex =
+ frame.type === 'snapshot' && !replaceSnapshot && merger.list.length > 0
+ ? replayRetainedTailStart(merger, frame.messages, frame.hasMore)
+ : null
+ if (frame.type === 'replacement' || (frame.type === 'snapshot' && replayStartIndex === null)) {
replaceList(merger, frame.messages)
return {
kind: 'messages',
messages: merger.list,
hasMore: frame.hasMore,
+ windowReplaced: true,
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
}
}
const previousFirstId = merger.list[0]?.id
const messages = applyAppend(merger, frame.messages, limit)
+ const cursorInvalidated = Boolean(previousFirstId && messages[0]?.id !== previousFirstId)
+ const replayStillStartsAtOldest = frame.type === 'snapshot' && replayStartIndex === 0
return {
kind: 'messages',
messages,
// Why: once the bounded live window drops its oldest row, the snapshot's
// byte cursor no longer describes the oldest retained message.
- ...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {})
+ ...(cursorInvalidated ? { cursorInvalidated: true } : {}),
+ // A trimmed replay creates page-able history even if the prior window had
+ // none; otherwise only a replay sharing our oldest row owns its metadata.
+ ...(frame.type === 'snapshot' && cursorInvalidated
+ ? { hasMore: true }
+ : replayStillStartsAtOldest
+ ? {
+ ...(frame.hasMore == null ? {} : { hasMore: frame.hasMore }),
+ ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
+ }
+ : {})
}
}
diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts
index 3c12630690b..844c311b827 100644
--- a/mobile/src/session/use-mobile-native-chat-session.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-session.test.ts
@@ -174,6 +174,221 @@ describe('useMobileNativeChatSession', () => {
}
)
+ it('keeps paged-in history across an auto-reconnect replay snapshot', async () => {
+ // The transport replays the subscription with its original params after an
+ // in-place reconnect, so the replayed snapshot is the newest initial window
+ // again. It must merge into the grown history, not truncate it back to 40.
+ const sendRequest = vi.fn().mockResolvedValue({
+ ok: true,
+ result: {
+ messages: Array.from({ length: 60 }, (_unused, index) => message(`paged-${index}`)),
+ hasMore: false,
+ beforeOffset: 40
+ }
+ })
+ const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
+ const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
+ emit = onData
+ onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 })
+ return () => {}
+ })
+ await mount({ sendRequest, subscribe } as unknown as RpcClient)
+ await act(async () => {
+ state?.loadEarlier()
+ await Promise.resolve()
+ })
+ expect(state?.messages).toHaveLength(100)
+
+ // Reconnect replay: the same newest-40 window. History survives untouched.
+ await act(async () =>
+ emit({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 })
+ )
+ expect(state?.messages).toHaveLength(100)
+ expect(state?.messages[0]?.id).toBe('paged-0')
+
+ // A replay carrying one message that arrived while away merges it in; the
+ // grown window stays bounded, so only the single oldest row trims.
+ await act(async () =>
+ emit({
+ type: 'snapshot',
+ messages: [...window, message('live-1')],
+ hasMore: true,
+ beforeOffset: 100
+ })
+ )
+ expect(state?.messages).toHaveLength(100)
+ expect(state?.messages[0]?.id).toBe('paged-1')
+ expect(state?.messages.at(-1)?.id).toBe('live-1')
+ })
+
+ it('enables paging when a replay trims a window that previously had no earlier rows', async () => {
+ // Never settles: this asserts the request the replay's cursor produces.
+ const sendRequest = vi.fn(() => new Promise(() => {}))
+ const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
+ const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
+ emit = onData
+ onData({ type: 'snapshot', messages: window, hasMore: false, beforeOffset: 0 })
+ return () => {}
+ })
+ await mount({ sendRequest, subscribe } as unknown as RpcClient)
+
+ await act(async () =>
+ emit({
+ type: 'snapshot',
+ messages: [...window.slice(1), message('live-1')],
+ hasMore: true,
+ beforeOffset: 10
+ })
+ )
+
+ expect(state?.messages[0]?.id).toBe('win-1')
+ expect(state?.hasMore).toBe(true)
+ act(() => state?.loadEarlier())
+ expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
+ agent: 'claude',
+ sessionId: 'session',
+ limit: 100
+ })
+ })
+
+ it('drops paged rows that an authoritative replay says were removed', async () => {
+ const sendRequest = vi.fn().mockResolvedValue({
+ ok: true,
+ result: {
+ messages: Array.from({ length: 60 }, (_unused, index) => message(`paged-${index}`)),
+ hasMore: false,
+ beforeOffset: 40
+ }
+ })
+ const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
+ const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
+ emit = onData
+ onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 })
+ return () => {}
+ })
+ await mount({ sendRequest, subscribe } as unknown as RpcClient)
+ await act(async () => {
+ state?.loadEarlier()
+ await Promise.resolve()
+ })
+ expect(state?.messages).toHaveLength(100)
+
+ await act(async () =>
+ emit({ type: 'snapshot', messages: window, hasMore: false, beforeOffset: 0 })
+ )
+
+ expect(state?.messages).toEqual(window)
+ expect(state?.hasMore).toBe(false)
+ })
+
+ it('clears a stale cursor when a replacement omits paging metadata', async () => {
+ // Never settles: this asserts the request the cleared cursor produces.
+ const sendRequest = vi.fn(() => new Promise(() => {}))
+ const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
+ emit = onData
+ onData({
+ type: 'snapshot',
+ messages: Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)),
+ hasMore: true,
+ beforeOffset: 100
+ })
+ return () => {}
+ })
+ await mount({ sendRequest, subscribe } as unknown as RpcClient)
+
+ await act(async () =>
+ emit({
+ type: 'replacement',
+ messages: Array.from({ length: 40 }, (_unused, index) => message(`new-${index}`))
+ })
+ )
+ act(() => state?.loadEarlier())
+
+ expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
+ agent: 'claude',
+ sessionId: 'session',
+ limit: 100
+ })
+ })
+
+ it('clears hasMore when a replaced window is shorter than the initial page', async () => {
+ // A replaced window without paging metadata is judged by its own length:
+ // shorter than a full page means the whole transcript is on screen.
+ const sendRequest = vi.fn()
+ const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
+ emit = onData
+ onData({
+ type: 'snapshot',
+ messages: Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)),
+ hasMore: true,
+ beforeOffset: 100
+ })
+ return () => {}
+ })
+ await mount({ sendRequest, subscribe } as unknown as RpcClient)
+ expect(state?.hasMore).toBe(true)
+
+ await act(async () => emit({ type: 'replacement', messages: [message('only')] }))
+
+ expect(state?.hasMore).toBe(false)
+ })
+
+ it('fences an in-flight older page when a merging replay re-cuts the byte cursor', async () => {
+ let resolveEarlier: (response: unknown) => void = () => {}
+ const sendRequest = vi.fn(
+ () => new Promise((resolve) => (resolveEarlier = resolve))
+ ) as unknown as RpcClient['sendRequest']
+ const retained = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
+ const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
+ emit = onData
+ onData({ type: 'snapshot', messages: retained, hasMore: true, beforeOffset: 100 })
+ return () => {}
+ })
+ await mount({ sendRequest, subscribe } as unknown as RpcClient)
+ act(() => state?.loadEarlier())
+
+ // The replay merges (same rows, no trim), but the host re-cut the file, so
+ // it carries a new cursor. The page already in flight was addressed with the
+ // old offset and would write that stale cursor back over the fresh one.
+ await act(async () =>
+ emit({ type: 'snapshot', messages: retained, hasMore: true, beforeOffset: 250 })
+ )
+ await act(async () => {
+ resolveEarlier({
+ ok: true,
+ result: { messages: [message('stale-page')], hasMore: true, beforeOffset: 40 }
+ })
+ await Promise.resolve()
+ })
+
+ expect(state?.messages.some((entry) => entry.id === 'stale-page')).toBe(false)
+ expect(state?.loadingEarlier).toBe(false)
+ })
+
+ it('keeps the base snapshot authoritative when a live append arrives first', async () => {
+ const sendRequest = vi.fn()
+ const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
+ emit = onData
+ return () => {}
+ })
+ await mount({ sendRequest, subscribe } as unknown as RpcClient)
+ // Only a snapshot marks the base as delivered, so an append landing first
+ // must not demote the real base snapshot to a reconnect replay.
+ await act(async () =>
+ emit({ type: 'appended', messages: [message('early-a'), message('early-b')] })
+ )
+ await act(async () =>
+ emit({
+ type: 'snapshot',
+ messages: [message('early-b'), message('base-c')],
+ hasMore: true,
+ beforeOffset: 3
+ })
+ )
+
+ expect(state?.messages.map((entry) => entry.id)).toEqual(['early-b', 'base-c'])
+ })
+
it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => {
let resolveCursorPage: (response: unknown) => void = () => {}
const sendRequest = vi
diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts
index 9c739ce49ef..56db7def584 100644
--- a/mobile/src/session/use-mobile-native-chat-session.ts
+++ b/mobile/src/session/use-mobile-native-chat-session.ts
@@ -92,6 +92,9 @@ export function useMobileNativeChatSession(args: {
const sessionIdRef = useRef(sessionId)
sessionIdRef.current = sessionId
const streamGenerationRef = useRef(0)
+ // Whether this subscription already delivered its base snapshot; later
+ // snapshots on the same subscription are reconnect replays, not fresh bases.
+ const snapshotSeenRef = useRef(false)
// Replace the base list (read results are an ordered tail). Resets the merger
// cache so the index is rebuilt once over the new base.
@@ -107,6 +110,7 @@ export function useMobileNativeChatSession(args: {
streamGenerationRef.current += 1
limitRef.current = INITIAL_LIMIT
loadingEarlierRef.current = false
+ snapshotSeenRef.current = false
setLoadingEarlier(false)
setList([])
setError(undefined)
@@ -133,20 +137,11 @@ export function useMobileNativeChatSession(args: {
return
}
const frame = raw as MobileNativeChatStreamFrame
- if (frame.type === 'replacement' || frame.type === 'snapshot') {
- // Why: replacement and reconnect snapshots are authoritative windows;
- // stale page limits/results must not constrain the fresh generation.
- streamGenerationRef.current += 1
- limitRef.current = INITIAL_LIMIT
- loadingEarlierRef.current = false
- setLoadingEarlier(false)
- }
- const replaceSnapshot = frame.type === 'snapshot'
const applied = applyMobileNativeChatStreamFrame({
merger: mergerRef.current,
frame,
limit: limitRef.current,
- replaceSnapshot
+ replaceSnapshot: !snapshotSeenRef.current
})
if (applied.kind === 'ignored') {
return
@@ -156,11 +151,28 @@ export function useMobileNativeChatSession(args: {
setError(applied.error)
return
}
+ if (frame.type === 'snapshot') {
+ snapshotSeenRef.current = true
+ }
+ if (applied.windowReplaced || frame.type === 'snapshot') {
+ // Why: any authoritative window (and any replay merge) invalidates an
+ // in-flight older-page request; stale results must not land on it.
+ streamGenerationRef.current += 1
+ loadingEarlierRef.current = false
+ setLoadingEarlier(false)
+ }
+ if (applied.windowReplaced) {
+ // Only a genuinely fresh window resets the grown read window — an
+ // overlapping reconnect replay keeps the paged-in history and limit.
+ limitRef.current = INITIAL_LIMIT
+ beforeOffsetRef.current = applied.beforeOffset ?? null
+ setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT)
+ }
setMessages(applied.messages)
- if (applied.hasMore != null) {
+ if (!applied.windowReplaced && applied.hasMore != null) {
setHasMore(applied.hasMore)
}
- if (applied.beforeOffset != null) {
+ if (!applied.windowReplaced && applied.beforeOffset != null) {
beforeOffsetRef.current = applied.beforeOffset
}
if (applied.cursorInvalidated) {
From 7948e46db855f27f5128c5d7b6538e10e66c9e42 Mon Sep 17 00:00:00 2001
From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:06:37 -0700
Subject: [PATCH 19/42] fix(mobile): open the Resume workspace through a
mounted host stack (#12001)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mobile): open the Resume workspace through a mounted host stack
Tapping Resume on Home landed on a blank host screen instead of the
session. A cold push straight into the nested /h/[hostId] navigator
resolves to the host index route without the dynamic id, so
HostProtocolGate mounts with hostId undefined and never connects.
Home already worked around this for the host editor (#11635) and Tasks
(#11853) by mounting /h/[hostId] first and replacing it once the stack
is committed. Extract that mechanism into host-stack-navigation so
Resume uses the same transition instead of a direct push.
The previous Resume fix (#11876) swapped the manual href for a typed
dynamic href, but expo-router's encodeParam already applies
encodeURIComponent to dynamic segments, so it resolved to the same URL
the manual string produced and left the cold-navigator path unchanged.
Claude-Session: https://claude.ai/code/session_01RMoaxp7MLg2ydP28KFLX7B
* fix(mobile): harden the host-stack transition after bot review
- match a host route committed as the encoded segment it was pushed as,
so an id containing `/`, `#`, or `%` still triggers the REPLACE
- share one pending transition across the Home entry points; per-hook
refs let a Tasks tap and a Resume tap arm two pushes that could not
cancel each other
- assert the source markers before slicing in the Resume wiring test
Claude-Session: https://claude.ai/code/session_01RMoaxp7MLg2ydP28KFLX7B
* test(mobile): lock the host-stack transition state machine
Assert the replace waits for the host mount (zero dispatches before it,
exactly one after), and cover cancel/retarget — the paths the shared
pending transition relies on.
* test(mobile): model listener removal in the navigation harness
A no-op unsubscribe let setState keep calling a canceled listener, so the
teardown assertions only exercised the active guard. Dropping the
unsubscribe call from dispose now fails the suite.
---------
Co-authored-by: kaynan
---
mobile/app/index.tsx | 15 +-
.../navigation/host-stack-navigation.test.ts | 141 +++++++++++++++
.../src/navigation/host-stack-navigation.ts | 171 ++++++++++++++++++
.../navigation/use-open-host-stack-route.ts | 38 ++++
.../src/session/mobile-session-route.test.ts | 95 +++++++++-
mobile/src/session/mobile-session-route.ts | 19 +-
mobile/src/session/use-open-mobile-session.ts | 14 ++
mobile/src/tasks/mobile-task-navigation.ts | 166 ++++-------------
mobile/src/tasks/use-open-mobile-tasks.ts | 32 +---
9 files changed, 512 insertions(+), 179 deletions(-)
create mode 100644 mobile/src/navigation/host-stack-navigation.test.ts
create mode 100644 mobile/src/navigation/host-stack-navigation.ts
create mode 100644 mobile/src/navigation/use-open-host-stack-route.ts
create mode 100644 mobile/src/session/use-open-mobile-session.ts
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 52f0647c872..7bfe5ee6773 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -54,7 +54,7 @@ import {
} from '../src/tasks/mobile-task-providers'
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
import { useResponsiveLayout } from '../src/layout/responsive-layout'
-import { createMobileSessionHref } from '../src/session/mobile-session-route'
+import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
function endpointLabel(endpoint: string): string {
try {
@@ -208,6 +208,7 @@ export default function HomeScreen() {
const router = useRouter()
const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
+ const openMobileSession = useOpenMobileSession()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
@@ -737,13 +738,11 @@ export default function HomeScreen() {
[styles.resumeCard, pressed && styles.hostCardPressed]}
onPress={() =>
- router.push(
- createMobileSessionHref({
- hostId: resumeWorktree.hostId,
- worktreeId: resumeWorktree.worktree.worktreeId,
- name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
- })
- )
+ openMobileSession({
+ hostId: resumeWorktree.hostId,
+ worktreeId: resumeWorktree.worktree.worktreeId,
+ name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
+ })
}
>
diff --git a/mobile/src/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts
new file mode 100644
index 00000000000..2dd3cc04e24
--- /dev/null
+++ b/mobile/src/navigation/host-stack-navigation.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ coordinateHostStackNavigation,
+ hostStackHostRoute,
+ navigateToHostStackRoute,
+ type HostStackNavigationState
+} from './host-stack-navigation'
+
+const TARGET = { name: '[hostId]/tasks', params: { hostId: 'host/one' } } as const
+const OTHER_TARGET = {
+ name: '[hostId]/session/[worktreeId]',
+ params: { hostId: 'host/one', worktreeId: 'repo::/tmp/wt' }
+} as const
+
+// Removal is modeled for real: a no-op unsubscribe would let `setState` keep
+// calling a canceled listener, testing the `active` guard instead of teardown.
+function navigationHarness(initialState: HostStackNavigationState) {
+ const stateListeners = new Set<() => void>()
+ let state = initialState
+ const unsubscribe = vi.fn()
+ const navigation = {
+ addListener: vi.fn((_event: 'state', listener: () => void) => {
+ stateListeners.add(listener)
+ return () => {
+ unsubscribe()
+ stateListeners.delete(listener)
+ }
+ }),
+ dispatch: vi.fn(),
+ getState: () => state
+ }
+ return {
+ navigation,
+ unsubscribe,
+ listenerCount: () => stateListeners.size,
+ setState(nextState: HostStackNavigationState) {
+ state = nextState
+ for (const listener of stateListeners) {
+ listener()
+ }
+ }
+ }
+}
+
+function committedHostState(hostIdParam: string): HostStackNavigationState {
+ return {
+ index: 0,
+ routes: [
+ {
+ name: 'h',
+ state: {
+ key: '/h',
+ index: 0,
+ routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: hostIdParam } }]
+ }
+ }
+ ]
+ }
+}
+
+describe('host stack navigation', () => {
+ it('matches a host committed as the encoded segment it was pushed as', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+ const push = vi.fn()
+
+ navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET)
+ expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
+ expect(harness.navigation.dispatch).not.toHaveBeenCalled()
+
+ harness.setState(committedHostState(encodeURIComponent('host/one')))
+
+ expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1)
+ expect(harness.navigation.dispatch).toHaveBeenCalledWith({
+ type: 'REPLACE',
+ target: '/h',
+ source: 'host-index',
+ payload: TARGET
+ })
+ })
+
+ it('ignores a different host whose id merely decodes badly', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+
+ navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
+ harness.setState(committedHostState('100%'))
+
+ expect(harness.navigation.dispatch).not.toHaveBeenCalled()
+ })
+
+ it('stops listening and never replaces once canceled', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+
+ const controller = navigateToHostStackRoute(
+ harness.navigation,
+ { push: vi.fn() },
+ 'host/one',
+ TARGET
+ )
+ expect(harness.listenerCount()).toBe(1)
+ controller.cancel()
+
+ expect(harness.unsubscribe).toHaveBeenCalledTimes(1)
+ expect(harness.listenerCount()).toBe(0)
+ expect(controller.isActive()).toBe(false)
+
+ harness.setState(committedHostState('host/one'))
+ expect(harness.navigation.dispatch).not.toHaveBeenCalled()
+ })
+
+ it('retargets a still-pending transition to the same host instead of pushing twice', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+ const push = vi.fn()
+ const router = { push }
+
+ const pending = coordinateHostStackNavigation(
+ null,
+ harness.navigation,
+ router,
+ 'host/one',
+ TARGET
+ )
+ const retargeted = coordinateHostStackNavigation(
+ pending,
+ harness.navigation,
+ router,
+ 'host/one',
+ OTHER_TARGET
+ )
+
+ expect(retargeted).toBe(pending)
+ expect(push).toHaveBeenCalledTimes(1)
+ expect(harness.listenerCount()).toBe(1)
+
+ harness.setState(committedHostState('host/one'))
+
+ expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1)
+ expect(harness.navigation.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({ payload: OTHER_TARGET })
+ )
+ })
+})
diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts
new file mode 100644
index 00000000000..4fcdff8cac5
--- /dev/null
+++ b/mobile/src/navigation/host-stack-navigation.ts
@@ -0,0 +1,171 @@
+export type HostStackNavigationState = Readonly<{
+ key?: string
+ index: number
+ routes: readonly HostStackNavigationRoute[]
+}>
+
+export type HostStackNavigationRoute = Readonly<{
+ key?: string
+ name: string
+ params?: Readonly<{ hostId?: unknown }>
+ state?: HostStackNavigationState
+}>
+
+/** A screen inside app/h/_layout.tsx plus the params it needs, e.g.
+ * `{ name: '[hostId]/session/[worktreeId]', params: { hostId, worktreeId } }`. */
+export type HostStackRouteTarget = Readonly<{
+ name: string
+ params: Readonly>
+}>
+
+export type HostStackReplaceAction = Readonly<{
+ type: 'REPLACE'
+ target: string
+ source: string
+ payload: HostStackRouteTarget
+}>
+
+export type HostStackRootNavigation = {
+ addListener: (event: 'state', listener: () => void) => () => void
+ dispatch: (action: HostStackReplaceAction) => void
+ getState: () => HostStackNavigationState
+}
+
+export type HostStackHostRoute = `/h/${string}`
+
+export type HostStackRouter = {
+ push: (route: HostStackHostRoute) => void
+}
+
+export type HostStackNavigationController = Readonly<{
+ cancel: () => void
+ isActive: () => boolean
+ retarget: (target: HostStackRouteTarget) => void
+}>
+
+export type PendingHostStackNavigation = Readonly<{
+ hostId: string
+ controller: HostStackNavigationController
+}>
+
+export function hostStackHostRoute(hostId: string): HostStackHostRoute {
+ return `/h/${encodeURIComponent(hostId)}`
+}
+
+// Why: the host is pushed as an encoded segment, so the committed route may hold
+// either form — an id with `/`, `#`, or `%` must still match its own push.
+function hostParamMatches(param: unknown, expectedHostId: string): boolean {
+ if (typeof param !== 'string') {
+ return false
+ }
+ if (param === expectedHostId) {
+ return true
+ }
+ try {
+ return decodeURIComponent(param) === expectedHostId
+ } catch {
+ return false // Lone `%` — not our encoding.
+ }
+}
+
+function mountedHostStack(
+ state: HostStackNavigationState,
+ expectedHostId: string
+): { key: string; routeKey: string } | null {
+ const hostContainer = state.routes[state.index]
+ const hostState = hostContainer?.state
+ const hostRoute = hostState?.routes[hostState.index]
+ if (
+ hostContainer?.name !== 'h' ||
+ !hostState?.key ||
+ hostRoute?.name !== '[hostId]/index' ||
+ !hostRoute.key ||
+ !hostParamMatches(hostRoute.params?.hostId, expectedHostId)
+ ) {
+ return null
+ }
+ return { key: hostState.key, routeKey: hostRoute.key }
+}
+
+/** Opens a deep host route by mounting `/h/[hostId]` first and replacing it once
+ * its stack is committed. A cold push straight to a nested route resolves to the
+ * host index without the dynamic id, which lands on a blank host screen. */
+export function navigateToHostStackRoute(
+ navigation: HostStackRootNavigation,
+ router: HostStackRouter,
+ hostId: string,
+ target: HostStackRouteTarget
+): HostStackNavigationController {
+ let active = true
+ let hostRouteSeen = false
+ let selectedTarget = target
+ let unsubscribeState = () => {}
+ const dispose = () => {
+ if (!active) {
+ return
+ }
+ active = false
+ unsubscribeState()
+ }
+
+ // Why: cold Expo deep links resolve to index; target the route only after its HostStack exists.
+ const onState = () => {
+ if (!active) {
+ return
+ }
+ const state = navigation.getState()
+ const currentRoute = state.routes[state.index]
+ if (currentRoute?.name === 'h') {
+ hostRouteSeen = true
+ } else if (hostRouteSeen) {
+ dispose()
+ return
+ }
+ const hostStack = mountedHostStack(state, hostId)
+ if (!hostStack) {
+ return
+ }
+ dispose()
+ navigation.dispatch({
+ type: 'REPLACE',
+ target: hostStack.key,
+ source: hostStack.routeKey,
+ payload: selectedTarget
+ })
+ }
+
+ try {
+ unsubscribeState = navigation.addListener('state', onState)
+ router.push(hostStackHostRoute(hostId))
+ } catch (error) {
+ dispose()
+ throw error
+ }
+ return {
+ cancel: dispose,
+ isActive: () => active,
+ retarget: (nextTarget) => {
+ if (active) {
+ selectedTarget = nextTarget
+ }
+ }
+ }
+}
+
+export function coordinateHostStackNavigation(
+ current: PendingHostStackNavigation | null,
+ navigation: HostStackRootNavigation,
+ router: HostStackRouter,
+ hostId: string,
+ target: HostStackRouteTarget
+): PendingHostStackNavigation {
+ if (current?.hostId === hostId && current.controller.isActive()) {
+ current.controller.retarget(target)
+ return current
+ }
+ current?.controller.cancel()
+ return {
+ hostId,
+ controller: navigateToHostStackRoute(navigation, router, hostId, target)
+ }
+}
diff --git a/mobile/src/navigation/use-open-host-stack-route.ts b/mobile/src/navigation/use-open-host-stack-route.ts
new file mode 100644
index 00000000000..674ec5c34f7
--- /dev/null
+++ b/mobile/src/navigation/use-open-host-stack-route.ts
@@ -0,0 +1,38 @@
+import { useCallback, useEffect } from 'react'
+import { useNavigation, useRouter } from 'expo-router'
+import {
+ coordinateHostStackNavigation,
+ type HostStackRootNavigation,
+ type HostStackRouteTarget,
+ type PendingHostStackNavigation
+} from './host-stack-navigation'
+
+// Why: one root navigator means one pending transition. A per-hook ref would let a
+// Resume tap and a Tasks tap arm two independent pushes that cannot cancel each other.
+let pendingNavigation: PendingHostStackNavigation | null = null
+
+export function useOpenHostStackRoute(): (hostId: string, target: HostStackRouteTarget) => void {
+ const navigation = useNavigation()
+ const router = useRouter()
+
+ useEffect(
+ () => () => {
+ pendingNavigation?.controller.cancel()
+ pendingNavigation = null
+ },
+ []
+ )
+
+ return useCallback(
+ (hostId, target) => {
+ pendingNavigation = coordinateHostStackNavigation(
+ pendingNavigation,
+ navigation,
+ router,
+ hostId,
+ target
+ )
+ },
+ [navigation, router]
+ )
+}
diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts
index 79b0c1264e4..e97af9df383 100644
--- a/mobile/src/session/mobile-session-route.test.ts
+++ b/mobile/src/session/mobile-session-route.test.ts
@@ -1,19 +1,63 @@
import { readFileSync } from 'node:fs'
-import { describe, expect, it } from 'vitest'
-import { createMobileSessionHref } from './mobile-session-route'
+import { describe, expect, it, vi } from 'vitest'
+import { mobileSessionRouteTarget } from './mobile-session-route'
+import {
+ hostStackHostRoute,
+ navigateToHostStackRoute,
+ type HostStackNavigationState
+} from '../navigation/host-stack-navigation'
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
+function navigationHarness(initialState: HostStackNavigationState) {
+ const stateListeners = new Set<() => void>()
+ let state = initialState
+ const navigation = {
+ addListener: vi.fn((_event: 'state', listener: () => void) => {
+ stateListeners.add(listener)
+ return () => stateListeners.delete(listener)
+ }),
+ dispatch: vi.fn(),
+ getState: () => state
+ }
+ return {
+ navigation,
+ setState(nextState: HostStackNavigationState) {
+ state = nextState
+ for (const listener of stateListeners) {
+ listener()
+ }
+ }
+ }
+}
+
+function mountedHostState(hostId: string): HostStackNavigationState {
+ return {
+ index: 1,
+ routes: [
+ { name: 'index' },
+ {
+ name: 'h',
+ state: {
+ key: '/h',
+ index: 0,
+ routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId } }]
+ }
+ }
+ ]
+ }
+}
+
describe('mobile session route', () => {
- it('keeps dynamic route identities raw for Expo Router to encode', () => {
+ it('keeps dynamic route identities raw for the navigator to encode', () => {
expect(
- createMobileSessionHref({
+ mobileSessionRouteTarget({
hostId: 'host/one',
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
name: 'Fix #1'
})
).toEqual({
- pathname: '/h/[hostId]/session/[worktreeId]',
+ name: '[hostId]/session/[worktreeId]',
params: {
hostId: 'host/one',
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
@@ -22,14 +66,47 @@ describe('mobile session route', () => {
})
})
- it('routes the home Resume card through the typed dynamic href', () => {
+ it('omits an absent workspace name instead of sending an empty param', () => {
+ expect(
+ mobileSessionRouteTarget({ hostId: 'host-1', worktreeId: 'repo::/tmp/wt' }).params
+ ).toEqual({ hostId: 'host-1', worktreeId: 'repo::/tmp/wt' })
+ })
+
+ it('mounts the host before replacing it with the session route', () => {
+ const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
+ const push = vi.fn()
+ const target = mobileSessionRouteTarget({
+ hostId: 'host/one',
+ worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
+ name: 'Fix #1'
+ })
+
+ navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target)
+
+ expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
+ expect(harness.navigation.dispatch).not.toHaveBeenCalled()
+
+ harness.setState(mountedHostState('host/one'))
+
+ expect(harness.navigation.dispatch).toHaveBeenCalledWith({
+ type: 'REPLACE',
+ target: '/h',
+ source: 'host-index',
+ payload: target
+ })
+ })
+
+ it('routes the home Resume card through the cold-navigator-safe transition', () => {
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
- const resumeCard = homeSource.slice(start, end)
+ // Assert the markers first: a renamed banner would otherwise slice garbage and
+ // report a missing call instead of the real cause.
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
- expect(resumeCard).toContain('createMobileSessionHref({')
- expect(resumeCard).not.toContain('encodeURIComponent(resumeWorktree.worktree.worktreeId)')
+
+ const resumeCard = homeSource.slice(start, end)
+ expect(resumeCard).toContain('openMobileSession({')
+ expect(resumeCard).not.toContain('router.push(')
})
})
diff --git a/mobile/src/session/mobile-session-route.ts b/mobile/src/session/mobile-session-route.ts
index 505e53bfb85..66f5c66f76f 100644
--- a/mobile/src/session/mobile-session-route.ts
+++ b/mobile/src/session/mobile-session-route.ts
@@ -1,17 +1,20 @@
+import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
+
export type MobileSessionRouteParams = {
hostId: string
worktreeId: string
name?: string
}
-export type MobileSessionHref = {
- pathname: '/h/[hostId]/session/[worktreeId]'
- params: MobileSessionRouteParams
-}
-
-export function createMobileSessionHref(params: MobileSessionRouteParams): MobileSessionHref {
+/** Identities stay raw — the navigator owns the params, so pre-encoding a
+ * workspace id would reach the session screen still escaped. */
+export function mobileSessionRouteTarget({
+ hostId,
+ worktreeId,
+ name
+}: MobileSessionRouteParams): HostStackRouteTarget {
return {
- pathname: '/h/[hostId]/session/[worktreeId]',
- params
+ name: '[hostId]/session/[worktreeId]',
+ params: name ? { hostId, worktreeId, name } : { hostId, worktreeId }
}
}
diff --git a/mobile/src/session/use-open-mobile-session.ts b/mobile/src/session/use-open-mobile-session.ts
new file mode 100644
index 00000000000..249c9924cb6
--- /dev/null
+++ b/mobile/src/session/use-open-mobile-session.ts
@@ -0,0 +1,14 @@
+import { useCallback } from 'react'
+import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
+import { mobileSessionRouteTarget, type MobileSessionRouteParams } from './mobile-session-route'
+
+export function useOpenMobileSession(): (params: MobileSessionRouteParams) => void {
+ const openHostStackRoute = useOpenHostStackRoute()
+
+ return useCallback(
+ (params) => {
+ openHostStackRoute(params.hostId, mobileSessionRouteTarget(params))
+ },
+ [openHostStackRoute]
+ )
+}
diff --git a/mobile/src/tasks/mobile-task-navigation.ts b/mobile/src/tasks/mobile-task-navigation.ts
index 3537d517106..f1dc733878e 100644
--- a/mobile/src/tasks/mobile-task-navigation.ts
+++ b/mobile/src/tasks/mobile-task-navigation.ts
@@ -1,72 +1,36 @@
+import {
+ coordinateHostStackNavigation,
+ hostStackHostRoute,
+ navigateToHostStackRoute,
+ type HostStackHostRoute,
+ type HostStackNavigationController,
+ type HostStackNavigationState,
+ type HostStackRootNavigation,
+ type HostStackRouteTarget,
+ type HostStackRouter,
+ type PendingHostStackNavigation
+} from '../navigation/host-stack-navigation'
import type { TaskProvider } from './mobile-task-providers'
-export type MobileTasksHostRoute = `/h/${string}`
-
-export type MobileTasksNavigationState = Readonly<{
- key?: string
- index: number
- routes: readonly MobileTasksNavigationRoute[]
-}>
-
-export type MobileTasksNavigationRoute = Readonly<{
- key?: string
- name: string
- params?: Readonly<{ hostId?: unknown }>
- state?: MobileTasksNavigationState
-}>
-
-export type MobileTasksRootNavigation = {
- addListener: (event: 'state', listener: () => void) => () => void
- dispatch: (action: MobileTasksHostReplaceAction) => void
- getState: () => MobileTasksNavigationState
-}
-
-export type MobileTasksHostReplaceAction = Readonly<{
- type: 'REPLACE'
- target: string
- source: string
- payload: Readonly<{
- name: '[hostId]/tasks'
- params: Readonly<{ hostId: string; taskSource?: TaskProvider }>
- }>
-}>
-
-export type MobileTasksRouter = {
- push: (route: MobileTasksHostRoute) => void
-}
-
-export type MobileTasksNavigationController = Readonly<{
- cancel: () => void
- isActive: () => boolean
- selectProvider: (provider?: TaskProvider) => void
-}>
-
-export type PendingMobileTasksNavigation = Readonly<{
- hostId: string
- controller: MobileTasksNavigationController
-}>
+export type MobileTasksHostRoute = HostStackHostRoute
+export type MobileTasksNavigationState = HostStackNavigationState
+export type MobileTasksRootNavigation = HostStackRootNavigation
+export type MobileTasksRouter = HostStackRouter
+export type MobileTasksNavigationController = HostStackNavigationController
+export type PendingMobileTasksNavigation = PendingHostStackNavigation
export function mobileTasksHostRoute(hostId: string): MobileTasksHostRoute {
- return `/h/${encodeURIComponent(hostId)}`
+ return hostStackHostRoute(hostId)
}
-function mountedHostStack(
- state: MobileTasksNavigationState,
- expectedHostId: string
-): { key: string; routeKey: string } | null {
- const hostContainer = state.routes[state.index]
- const hostState = hostContainer?.state
- const hostRoute = hostState?.routes[hostState.index]
- if (
- hostContainer?.name !== 'h' ||
- !hostState?.key ||
- hostRoute?.name !== '[hostId]/index' ||
- !hostRoute.key ||
- hostRoute.params?.hostId !== expectedHostId
- ) {
- return null
+export function mobileTasksRouteTarget(
+ hostId: string,
+ provider?: TaskProvider
+): HostStackRouteTarget {
+ return {
+ name: '[hostId]/tasks',
+ params: provider ? { hostId, taskSource: provider } : { hostId }
}
- return { key: hostState.key, routeKey: hostRoute.key }
}
export function navigateToMobileTasks(
@@ -75,64 +39,12 @@ export function navigateToMobileTasks(
hostId: string,
provider?: TaskProvider
): MobileTasksNavigationController {
- let active = true
- let hostRouteSeen = false
- let selectedProvider = provider
- let unsubscribeState = () => {}
- const dispose = () => {
- if (!active) {
- return
- }
- active = false
- unsubscribeState()
- }
-
- // Why: cold Expo deep links resolve to index; target Tasks only after its HostStack exists.
- const onState = () => {
- if (!active) {
- return
- }
- const state = navigation.getState()
- const currentRoute = state.routes[state.index]
- if (currentRoute?.name === 'h') {
- hostRouteSeen = true
- } else if (hostRouteSeen) {
- dispose()
- return
- }
- const hostStack = mountedHostStack(state, hostId)
- if (!hostStack) {
- return
- }
- dispose()
- const action: MobileTasksHostReplaceAction = {
- type: 'REPLACE',
- target: hostStack.key,
- source: hostStack.routeKey,
- payload: {
- name: '[hostId]/tasks',
- params: selectedProvider ? { hostId, taskSource: selectedProvider } : { hostId }
- }
- }
- navigation.dispatch(action)
- }
-
- try {
- unsubscribeState = navigation.addListener('state', onState)
- router.push(mobileTasksHostRoute(hostId))
- } catch (error) {
- dispose()
- throw error
- }
- return {
- cancel: dispose,
- isActive: () => active,
- selectProvider: (nextProvider) => {
- if (active) {
- selectedProvider = nextProvider
- }
- }
- }
+ return navigateToHostStackRoute(
+ navigation,
+ router,
+ hostId,
+ mobileTasksRouteTarget(hostId, provider)
+ )
}
export function coordinateMobileTasksNavigation(
@@ -142,13 +54,11 @@ export function coordinateMobileTasksNavigation(
hostId: string,
provider?: TaskProvider
): PendingMobileTasksNavigation {
- if (current?.hostId === hostId && current.controller.isActive()) {
- current.controller.selectProvider(provider)
- return current
- }
- current?.controller.cancel()
- return {
+ return coordinateHostStackNavigation(
+ current,
+ navigation,
+ router,
hostId,
- controller: navigateToMobileTasks(navigation, router, hostId, provider)
- }
+ mobileTasksRouteTarget(hostId, provider)
+ )
}
diff --git a/mobile/src/tasks/use-open-mobile-tasks.ts b/mobile/src/tasks/use-open-mobile-tasks.ts
index 010e914d7fd..1c6cf67946b 100644
--- a/mobile/src/tasks/use-open-mobile-tasks.ts
+++ b/mobile/src/tasks/use-open-mobile-tasks.ts
@@ -1,35 +1,15 @@
-import { useCallback, useEffect, useRef } from 'react'
-import { useNavigation, useRouter } from 'expo-router'
-import {
- coordinateMobileTasksNavigation,
- type MobileTasksRootNavigation,
- type PendingMobileTasksNavigation
-} from './mobile-task-navigation'
+import { useCallback } from 'react'
+import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
+import { mobileTasksRouteTarget } from './mobile-task-navigation'
import type { TaskProvider } from './mobile-task-providers'
export function useOpenMobileTasks(): (hostId: string, provider?: TaskProvider) => void {
- const navigation = useNavigation()
- const router = useRouter()
- const pendingRef = useRef(null)
-
- useEffect(
- () => () => {
- pendingRef.current?.controller.cancel()
- pendingRef.current = null
- },
- []
- )
+ const openHostStackRoute = useOpenHostStackRoute()
return useCallback(
(hostId, provider) => {
- pendingRef.current = coordinateMobileTasksNavigation(
- pendingRef.current,
- navigation,
- router,
- hostId,
- provider
- )
+ openHostStackRoute(hostId, mobileTasksRouteTarget(hostId, provider))
},
- [navigation, router]
+ [openHostStackRoute]
)
}
From a528b689a97cfd3c128e029014889aef8a9c88d4 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:06:54 -0700
Subject: [PATCH 20/42] Prevent Command Code output from hijacking agent icons
(#12573)
* fix terminal agent icon ownership
* fix terminal output ownership gaps
---
.../command-code-output-ownership.test.ts | 45 ++++++++++
.../command-code-output-ownership.ts | 21 +++++
.../parked-terminal-byte-watcher.test.ts | 2 +-
.../parked-terminal-command-status.test.ts | 85 +++++++++++++++++-
.../parked-terminal-command-status.ts | 25 ++++++
.../terminal-pane/pty-connection.test.ts | 87 +++++++++++++++++++
.../terminal-pane/pty-connection.ts | 18 ++++
src/shared/command-code-output-status.test.ts | 59 ++++++++++++-
src/shared/command-code-output-status.ts | 23 +++--
9 files changed, 354 insertions(+), 11 deletions(-)
create mode 100644 src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/command-code-output-ownership.ts
diff --git a/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts b/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts
new file mode 100644
index 00000000000..18a3b7df437
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
+
+describe('canCommandCodeOutputOwnPane', () => {
+ it('allows the banner fallback when no stronger identity exists', () => {
+ expect(canCommandCodeOutputOwnPane({})).toBe(true)
+ expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'unknown' })).toBe(true)
+ })
+
+ it('allows Command Code ownership evidence', () => {
+ expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'command-code' })).toBe(true)
+ expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'command-code' })).toBe(true)
+ })
+
+ it('rejects another agent owner or foreground process', () => {
+ expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'claude' })).toBe(false)
+ expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'claude' })).toBe(false)
+ expect(
+ canCommandCodeOutputOwnPane({
+ paneOwnerAgent: 'unknown',
+ retainedPaneOwnerAgent: 'claude'
+ })
+ ).toBe(false)
+ })
+
+ it('prefers the current foreground process over stale pane ownership', () => {
+ expect(
+ canCommandCodeOutputOwnPane({
+ foregroundAgent: 'command-code',
+ paneOwnerAgent: 'claude',
+ retainedPaneOwnerAgent: 'claude'
+ })
+ ).toBe(true)
+ expect(
+ canCommandCodeOutputOwnPane({
+ foregroundAgent: 'claude',
+ paneOwnerAgent: 'command-code'
+ })
+ ).toBe(false)
+ })
+
+ it('rejects output observed at a confirmed shell prompt', () => {
+ expect(canCommandCodeOutputOwnPane({ shellForeground: true })).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts
new file mode 100644
index 00000000000..65f5f196ca0
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts
@@ -0,0 +1,21 @@
+import type { AgentType } from '../../../../shared/agent-status-types'
+import type { TuiAgent } from '../../../../shared/types'
+
+export function canCommandCodeOutputOwnPane(args: {
+ foregroundAgent?: TuiAgent | null
+ shellForeground?: boolean
+ paneOwnerAgent?: AgentType | null
+ retainedPaneOwnerAgent?: AgentType | null
+}): boolean {
+ if (args.foregroundAgent) {
+ return args.foregroundAgent === 'command-code'
+ }
+ if (args.shellForeground) {
+ return false
+ }
+ const paneOwnerAgent =
+ args.paneOwnerAgent && args.paneOwnerAgent !== 'unknown'
+ ? args.paneOwnerAgent
+ : (args.retainedPaneOwnerAgent ?? args.paneOwnerAgent)
+ return !paneOwnerAgent || paneOwnerAgent === 'unknown' || paneOwnerAgent === 'command-code'
+}
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
index 21d912dd7c0..959a8069db3 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts
@@ -406,7 +406,7 @@ describe('startParkedTerminalByteWatcher', () => {
it('feeds Command Code output through the parked byte detector', async () => {
const { dispose } = await startWatcher()
- emit('# Command Code v0.27.2')
+ emit('# Command Code v0.27.2\r\n')
emit('⌘ Parsing...')
expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledTimes(1)
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts
index 24991e01914..e9005fdd11f 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
+import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
const PTY_ID_LOCAL = 'pty-1'
const PTY_ID_SSH = 'ssh:target-1@@pty-9'
@@ -13,7 +14,11 @@ const DONE_SETTLE_MS = 1500
const ROUTING = { connectionId: null }
type MockStoreState = {
+ tabsByWorktree: Record
agentStatusByPaneKey: Record
+ retainedAgentsByPaneKey: Record
+ paneForegroundAgentByPaneKey: Record
+ agentLaunchConfigByPaneKey: Record
runtimePaneTitlesByTabId: Record>
setAgentStatus: ReturnType
dropAgentStatus: ReturnType
@@ -40,7 +45,11 @@ vi.mock('@/lib/connection-owner-resolution', () => ({
function makeMockStoreState(): MockStoreState {
return {
+ tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID }] },
agentStatusByPaneKey: {},
+ retainedAgentsByPaneKey: {},
+ paneForegroundAgentByPaneKey: {},
+ agentLaunchConfigByPaneKey: {},
runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } },
setAgentStatus: vi.fn(),
dropAgentStatus: vi.fn(),
@@ -110,6 +119,80 @@ describe('createParkedTerminalCommandStatusPolicy', () => {
policy.dispose()
})
+ it('rejects scrape status when a Claude hook owns the pane', async () => {
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('False prompt')
+ policy.onCommandCodeDone('False prompt')
+ vi.advanceTimersByTime(DONE_SETTLE_MS)
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it('rejects scrape status when retained Claude identity owns the pane', async () => {
+ mockStoreState.retainedAgentsByPaneKey[PANE_KEY] = { agentType: 'claude' }
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'unknown' })
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('False prompt')
+ policy.onCommandCodeDone('False prompt')
+ vi.advanceTimersByTime(DONE_SETTLE_MS)
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it('rejects scrape status when Claude launch metadata owns the pane', async () => {
+ mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }]
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('False prompt')
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it('rejects scrape status when Claude is the foreground process', async () => {
+ mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = {
+ agent: 'claude',
+ shellForeground: false
+ }
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('False prompt')
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it('lets a current Command Code process reclaim stale Claude ownership', async () => {
+ mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }]
+ mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' })
+ mockStoreState.retainedAgentsByPaneKey[PANE_KEY] = { agentType: 'claude' }
+ mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = {
+ agent: 'command-code',
+ shellForeground: false
+ }
+ const policy = await createPolicy(PTY_ID_LOCAL)
+
+ policy.onCommandCodeWorking('New Command Code prompt')
+
+ expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
+ PANE_KEY,
+ {
+ state: 'working',
+ prompt: 'New Command Code prompt',
+ agentType: 'command-code'
+ },
+ '✳ Build feature',
+ undefined,
+ ROUTING
+ )
+ policy.dispose()
+ })
+
it('leaves a settled done row alone when working repeats the same prompt', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'done',
diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts
index 781625318bd..9f54e02e87f 100644
--- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts
+++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts
@@ -5,6 +5,7 @@
* (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane.
*/
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
@@ -15,6 +16,7 @@ import {
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
+import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
export type ParkedTerminalCommandStatusPolicy = {
onCommandFinished: (bestEffortExitCode: number | null) => void
@@ -60,6 +62,23 @@ export function createParkedTerminalCommandStatusPolicy(options: {
})
}
+ const canApplyCommandCodeOutputStatus = (): boolean => {
+ const state = useAppStore.getState()
+ const tab = (state.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === tabId)
+ const foreground = state.paneForegroundAgentByPaneKey[paneKey]
+ const paneOwnerAgent = resolvePaneAgentOwner({
+ launchAgent: tab?.launchAgent,
+ startupLaunchAgent: state.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType,
+ hookAgent: state.agentStatusByPaneKey[paneKey]?.agentType
+ })
+ return canCommandCodeOutputOwnPane({
+ foregroundAgent: foreground?.agent,
+ shellForeground: foreground?.shellForeground,
+ paneOwnerAgent,
+ retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[paneKey]?.agentType
+ })
+ }
+
// Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference
// option: parked panes receive no key events, so inference never has evidence here.
const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => {
@@ -145,6 +164,9 @@ export function createParkedTerminalCommandStatusPolicy(options: {
// Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only).
onCommandCodeWorking: (prompt: string): void => {
+ if (!canApplyCommandCodeOutputStatus()) {
+ return
+ }
clearCommandCodeOutputDoneTimer()
const routing = resolveRouting()
if (!routing) {
@@ -178,6 +200,9 @@ export function createParkedTerminalCommandStatusPolicy(options: {
// Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering
// the composer while tools run, so only complete the row if no active repaint arrives.
onCommandCodeDone: (prompt: string): void => {
+ if (!canApplyCommandCodeOutputStatus()) {
+ return
+ }
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
cancelCommandCodeDoneSettle(paneKey)
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index 8bb448ed11a..5f5cf3a0afc 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -17,6 +17,7 @@ import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator'
import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter'
import type * as UseNotificationDispatchModule from './use-notification-dispatch'
import { getEagerPtyBufferHandle } from './pty-dispatcher'
+import type { AgentType } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { SshConnectionState } from '../../../../shared/ssh-types'
@@ -227,6 +228,7 @@ type StoreState = {
consumePendingSnapshot: ReturnType
runtimePaneTitlesByTabId: Record>
agentStatusByPaneKey: Record
+ retainedAgentsByPaneKey: Record
paneForegroundAgentByPaneKey: Record
sleepingAgentSessionsByPaneKey: Record
suppressedPtyExitIds: Record
@@ -928,6 +930,7 @@ describe('connectPanePty', () => {
consumePendingSnapshot: vi.fn(() => null),
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
+ retainedAgentsByPaneKey: {},
paneForegroundAgentByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
suppressedPtyExitIds: {},
@@ -18999,6 +19002,90 @@ describe('connectPanePty', () => {
})
})
+ it('rejects Command Code facts when Claude owns the pane', async () => {
+ enableMainAuthority()
+ const { connectPanePty } = await import('./pty-connection')
+ const handler = await import('./terminal-side-effect-facts-handler')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+ vi.useFakeTimers()
+ const paneKey = makePaneKey('tab-1', LEAF_1)
+ const claudeStatus = {
+ paneKey,
+ state: 'done' as const,
+ prompt: 'Previous Claude turn',
+ updatedAt: Date.now(),
+ stateStartedAt: Date.now(),
+ agentType: 'claude' as const,
+ stateHistory: []
+ }
+ mockStoreState.tabsByWorktree = {
+ 'wt-1': [{ id: 'tab-1', ptyId: null, launchAgent: 'claude' }]
+ }
+ mockStoreState.agentStatusByPaneKey[paneKey] = claudeStatus
+ mockStoreState.paneForegroundAgentByPaneKey[paneKey] = {
+ agent: 'claude',
+ shellForeground: false
+ }
+
+ connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
+ const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
+ onPtySpawn('pty-fact-false-cc')
+ mockStoreState.setAgentStatus.mockClear()
+
+ handler._dispatchTerminalSideEffectBatchForTest({
+ ptyId: 'pty-fact-false-cc',
+ seq: 1,
+ facts: [
+ { kind: 'command-code-working', prompt: 'False prompt' },
+ { kind: 'command-code-done', prompt: 'False prompt' }
+ ]
+ })
+ vi.advanceTimersByTime(2000)
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(claudeStatus)
+ })
+
+ it('rejects Command Code facts when retained Claude identity owns the pane', async () => {
+ enableMainAuthority()
+ const { connectPanePty } = await import('./pty-connection')
+ const handler = await import('./terminal-side-effect-facts-handler')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+ vi.useFakeTimers()
+ const paneKey = makePaneKey('tab-1', LEAF_1)
+ const unknownStatus = {
+ paneKey,
+ state: 'working' as const,
+ prompt: '',
+ updatedAt: Date.now(),
+ stateStartedAt: Date.now(),
+ agentType: 'unknown' as const,
+ stateHistory: []
+ }
+ mockStoreState.retainedAgentsByPaneKey[paneKey] = { agentType: 'claude' }
+ mockStoreState.agentStatusByPaneKey[paneKey] = unknownStatus
+
+ connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
+ const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
+ onPtySpawn('pty-fact-retained-false-cc')
+ mockStoreState.setAgentStatus.mockClear()
+
+ handler._dispatchTerminalSideEffectBatchForTest({
+ ptyId: 'pty-fact-retained-false-cc',
+ seq: 1,
+ facts: [
+ { kind: 'command-code-working', prompt: 'False prompt' },
+ { kind: 'command-code-done', prompt: 'False prompt' }
+ ]
+ })
+ vi.advanceTimersByTime(2000)
+
+ expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
+ expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(unknownStatus)
+ })
+
it('keeps Command Code working when a working fact lands before the done settles', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index b1eb0900865..d0b0c2a3338 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -267,6 +267,7 @@ import {
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
+import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
import {
getExecutionHostIdForWorktree,
@@ -2818,7 +2819,21 @@ export function connectPanePty(
.setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing)
}
+ const canApplyCommandCodeOutputStatus = (): boolean => {
+ const state = useAppStore.getState()
+ const foreground = state.paneForegroundAgentByPaneKey[cacheKey]
+ return canCommandCodeOutputOwnPane({
+ foregroundAgent: foreground?.agent,
+ shellForeground: foreground?.shellForeground,
+ paneOwnerAgent: getAuthoritativePaneAgent(),
+ retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[cacheKey]?.agentType
+ })
+ }
+
const seedCommandCodeOutputWorkingStatus = (prompt: string): void => {
+ if (!canApplyCommandCodeOutputStatus()) {
+ return
+ }
clearCommandCodeOutputDoneTimer()
const routing = resolveCurrentAgentStatusRouting()
if (!routing) {
@@ -2885,6 +2900,9 @@ export function connectPanePty(
)
const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey)
const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
+ if (!canApplyCommandCodeOutputStatus()) {
+ return
+ }
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
cancelCommandCodeDoneSettle(cacheKey)
diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts
index a86c89a174b..77a8005056e 100644
--- a/src/shared/command-code-output-status.test.ts
+++ b/src/shared/command-code-output-status.test.ts
@@ -29,7 +29,7 @@ describe('createCommandCodeOutputStatusDetector', () => {
})
expect(detector.observe('Thinking about unrelated shell output')).toBe(false)
- expect(detector.observe('# Command Code v0.27.2')).toBe(false)
+ expect(detector.observe('# Command Code v0.27.2\r\n')).toBe(false)
expect(detector.observe('⌘ Parsing...')).toBe(true)
expect(onWorking).toHaveBeenCalledWith('')
@@ -43,7 +43,7 @@ describe('createCommandCodeOutputStatusDetector', () => {
})
expect(detector.observe('# Command')).toBe(false)
- expect(detector.observe(' Code v0.27.2')).toBe(false)
+ expect(detector.observe(' Code v0.27.2\r\n')).toBe(false)
expect(detector.observe('⌘ Parsing...')).toBe(true)
expect(onWorking).toHaveBeenCalledWith('')
@@ -56,7 +56,7 @@ describe('createCommandCodeOutputStatusDetector', () => {
onWorking
})
- expect(detector.observe('# C\x1b[35mommand Co\x1b[0mde v0.27.2')).toBe(false)
+ expect(detector.observe('# C\x1b[35mommand Co\x1b[0mde v0.27.2\r\n')).toBe(false)
expect(detector.observe('⌘ Parsing...')).toBe(true)
expect(onWorking).toHaveBeenCalledWith('')
@@ -74,6 +74,59 @@ describe('createCommandCodeOutputStatusDetector', () => {
expect(onWorking).not.toHaveBeenCalled()
})
+ it('does not arm when another agent merely discusses Command Code', () => {
+ const onWorking = vi.fn()
+ const detector = createCommandCodeOutputStatusDetector({
+ startupCommand: null,
+ onWorking
+ })
+
+ expect(detector.observe('// Why: Command Code exposes transcript prompts')).toBe(false)
+ expect(detector.observe('\r\n✻ Thinking...')).toBe(false)
+
+ expect(onWorking).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ '# Command Code v1.2\r\n',
+ '# Command Code v1.2.3.4\r\n',
+ '# Command Code v01.2.3\r\n',
+ '# Command Code v1.2.3suffix\r\n',
+ '#\nCommand Code v1.2.3\r\n'
+ ])('does not arm for noncanonical banner %j', (banner) => {
+ const onWorking = vi.fn()
+ const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking })
+
+ expect(detector.observe(banner)).toBe(false)
+ expect(detector.observe('✻ Thinking...')).toBe(false)
+
+ expect(onWorking).not.toHaveBeenCalled()
+ })
+
+ it('does not arm from a four-part version split at a PTY chunk boundary', () => {
+ const onWorking = vi.fn()
+ const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking })
+
+ expect(detector.observe('# Command Code v1.2.3')).toBe(false)
+ expect(detector.observe('.4\r\n')).toBe(false)
+ expect(detector.observe('✻ Thinking...')).toBe(false)
+
+ expect(onWorking).not.toHaveBeenCalled()
+ })
+
+ it.each(['# Command Code v1.2.3\r\n', '# Command Code v1.2.3-beta.1+build.9\n'])(
+ 'arms for canonical banner %j',
+ (banner) => {
+ const onWorking = vi.fn()
+ const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking })
+
+ expect(detector.observe(banner)).toBe(false)
+ expect(detector.observe('✻ Thinking...')).toBe(true)
+
+ expect(onWorking).toHaveBeenCalledWith('')
+ }
+ )
+
it.each([
'Pondering',
'Contemplating',
diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts
index 41e4c9eefc1..af862b5abc7 100644
--- a/src/shared/command-code-output-status.ts
+++ b/src/shared/command-code-output-status.ts
@@ -112,7 +112,16 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp(
`(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:Executing:\\s+\\S|Running\\s*\\()`
)
const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./
-const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/
+const SEMVER_NUMBER_RE_SOURCE = '(?:0|[1-9]\\d*)'
+const SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE = '(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)'
+const SEMVER_BUILD_IDENTIFIER_RE_SOURCE = '[0-9A-Za-z-]+'
+const COMMAND_CODE_BANNER_RE = new RegExp(
+ `(?:^|[\\r\\n])[ \\t]*#[ \\t]+Command Code[ \\t]+v` +
+ `${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}` +
+ `(?:-${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE})*)?` +
+ `(?:\\+${SEMVER_BUILD_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_BUILD_IDENTIFIER_RE_SOURCE})*)?` +
+ `(?=[ \\t]*[\\r\\n])`
+)
function cleanPromptCandidate(value: string): string {
return cleanCommandCodePromptCandidate(stripTerminalControl(value))
@@ -233,17 +242,19 @@ export function createCommandCodeOutputStatusDetector(args: {
: scanRawText
if (!hasSeenCommandCodeUi) {
- if (
- !rawTextMayContainCommandCodeBanner(scanRawText) &&
- !rawTextMayContainCommandCodeBanner(scanRawTextWithChunkBoundary)
- ) {
+ if (!rawTextMayContainCommandCodeBanner(scanRawText)) {
return false
}
const scanText = stripTerminalControl(scanRawText)
const scanTextWithChunkBoundary = stripTerminalControl(scanRawTextWithChunkBoundary)
+ const previousTextWithChunkBoundaryLength = previousRawText
+ ? stripTerminalControl(`${previousRawText}\n`).length
+ : 0
if (
!COMMAND_CODE_BANNER_RE.test(scanText) &&
- !COMMAND_CODE_BANNER_RE.test(scanTextWithChunkBoundary)
+ !COMMAND_CODE_BANNER_RE.test(
+ scanTextWithChunkBoundary.slice(previousTextWithChunkBoundaryLength)
+ )
) {
return false
}
From 08abb758fa1f6f217d4ab0062d67c7ede5ac029d Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:56:43 -0700
Subject: [PATCH 21/42] perf(renderer): bail out of identity-equal terminal
layout and cache-timer writes (#12420)
* perf(renderer): bail out of identity-equal terminal layout and cache-timer writes
setCacheTimerStartedAt and setTabLayout spread a fresh object and returned it
unconditionally, so every redundant call published a new AppState and ran every
zustand subscriber's selector across all mounted panes. Both have a real
redundant cadence: parked-terminal-byte-watcher writes a null cache timer on
each agent working/exit/stale-title transition, and TerminalPane re-persists an
identical layout on pane-title churn.
Extract the existing terminalLayoutEqual comparator out of web-session-tabs-sync
into a shared module and use it to gate the layout write, and dedupe the
remote-runtime layout IPC against the last snapshot pushed per tab.
* fix(renderer): retry failed remote pane layout pushes
* test(renderer): cover stale remote layout failures
* test(e2e): cover remote pane layout retry
---
.../components/terminal-pane/TerminalPane.tsx | 17 +-
.../remote-pane-layout-push.test.ts | 183 ++++++++++++++
.../terminal-pane/remote-pane-layout-push.ts | 47 ++++
.../src/lib/terminal-layout-equality.ts | 55 +++++
.../src/runtime/web-session-tabs-sync.ts | 55 +----
.../terminal-write-identity-bailouts.test.ts | 227 ++++++++++++++++++
src/renderer/src/store/slices/terminals.ts | 47 ++--
.../paired-remote-pane-layout-retry.spec.ts | 210 ++++++++++++++++
8 files changed, 760 insertions(+), 81 deletions(-)
create mode 100644 src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts
create mode 100644 src/renderer/src/lib/terminal-layout-equality.ts
create mode 100644 src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts
create mode 100644 tests/e2e/paired-remote-pane-layout-retry.spec.ts
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index ee5ec73798e..3d3b0d07271 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -130,8 +130,7 @@ import {
} from '@/runtime/runtime-terminal-inspection'
import {
clearWebRuntimeTerminalBuffer,
- closeWebRuntimeTerminal,
- updateWebRuntimePaneLayout
+ closeWebRuntimeTerminal
} from '@/runtime/web-runtime-session'
import {
armPrimarySelectionNativePasteSuppression,
@@ -152,6 +151,10 @@ import {
planTerminalLiveLayoutInsertions
} from './terminal-live-layout-reconciliation'
import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types'
+import {
+ createRemotePaneLayoutPusher,
+ type RemotePaneLayoutPusher
+} from './remote-pane-layout-push'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
@@ -402,6 +405,8 @@ function TerminalPane(
paneTitlesRef.current = paneTitles
const removedTitleLeafIdsRef = useRef>(new Set())
const clearedScrollbackLeafIdsRef = useRef>(new Set())
+ const remotePaneLayoutPusherRef = useRef(null)
+ remotePaneLayoutPusherRef.current ??= createRemotePaneLayoutPusher()
const [paneTitleOverlayRects, setPaneTitleOverlayRects] = useState<
Record
>({})
@@ -947,13 +952,7 @@ function TerminalPane(
(ptyId) => typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId)
)
if (hasRemotePane) {
- void updateWebRuntimePaneLayout({
- worktreeId,
- tabId,
- root: layout.root,
- expandedLeafId: layout.expandedLeafId,
- ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {})
- })
+ remotePaneLayoutPusherRef.current?.push({ worktreeId, tabId, layout })
}
for (const leafId of currentLeafIds) {
clearedScrollbackLeafIds.delete(leafId)
diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts
new file mode 100644
index 00000000000..d6d777f728c
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts
@@ -0,0 +1,183 @@
+/**
+ * Perf regression: layout persists fire on pane-title churn, and every one of
+ * them used to push a remote-runtime IPC round trip regardless of whether the
+ * host-visible layout had moved. Counting invocations at the mocked IPC boundary
+ * pins the before/after: 100 persists with an unchanged layout cost 100 pushes
+ * before the dedupe and 1 after.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TerminalLayoutSnapshot } from '../../../../shared/types'
+
+const updateWebRuntimePaneLayout = vi.fn()
+vi.mock('@/runtime/web-runtime-session', () => ({
+ updateWebRuntimePaneLayout: (...args: unknown[]) => updateWebRuntimePaneLayout(...args)
+}))
+
+const { createRemotePaneLayoutPusher } = await import('./remote-pane-layout-push')
+
+const PERSISTS = 100
+
+function deferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve = (_value: T): void => undefined
+ const promise = new Promise((complete) => {
+ resolve = complete
+ })
+ return { promise, resolve }
+}
+
+function makeLayout(overrides: Partial = {}): TerminalLayoutSnapshot {
+ return {
+ root: {
+ type: 'split',
+ direction: 'vertical',
+ ratio: 0.5,
+ first: { type: 'leaf', leafId: 'leaf-a' },
+ second: { type: 'leaf', leafId: 'leaf-b' }
+ },
+ activeLeafId: 'leaf-a',
+ expandedLeafId: null,
+ ptyIdsByLeafId: { 'leaf-a': 'remote:pty-a', 'leaf-b': 'remote:pty-b' },
+ titlesByLeafId: { 'leaf-a': 'build' },
+ ...overrides
+ }
+}
+
+describe('createRemotePaneLayoutPusher', () => {
+ beforeEach(() => {
+ updateWebRuntimePaneLayout.mockReset().mockResolvedValue(true)
+ })
+
+ it('pushes once across 100 persists of an unchanged layout', () => {
+ const pusher = createRemotePaneLayoutPusher()
+ for (let i = 0; i < PERSISTS; i += 1) {
+ // Fresh object each time: persistLayoutSnapshot re-serializes on every call.
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ }
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(1)
+ })
+
+ it('sends the same payload the un-deduped path sent', () => {
+ const pusher = createRemotePaneLayoutPusher()
+ const layout = makeLayout()
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout })
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledWith({
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ root: layout.root,
+ expandedLeafId: layout.expandedLeafId,
+ titlesByLeafId: layout.titlesByLeafId
+ })
+ })
+
+ it('omits titlesByLeafId when the layout carries no titles', () => {
+ const pusher = createRemotePaneLayoutPusher()
+ pusher.push({
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ layout: makeLayout({ titlesByLeafId: undefined })
+ })
+ expect(updateWebRuntimePaneLayout.mock.calls[0][0]).not.toHaveProperty('titlesByLeafId')
+ })
+
+ it('pushes again for every host-visible change', () => {
+ const pusher = createRemotePaneLayoutPusher()
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ pusher.push({
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ layout: makeLayout({ expandedLeafId: 'leaf-a' })
+ })
+ pusher.push({
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ layout: makeLayout({
+ expandedLeafId: 'leaf-a',
+ titlesByLeafId: { 'leaf-a': 'test' }
+ })
+ })
+ pusher.push({
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ layout: makeLayout({
+ expandedLeafId: 'leaf-a',
+ titlesByLeafId: { 'leaf-a': 'test' },
+ root: {
+ type: 'split',
+ direction: 'vertical',
+ ratio: 0.7,
+ first: { type: 'leaf', leafId: 'leaf-a' },
+ second: { type: 'leaf', leafId: 'leaf-b' }
+ }
+ })
+ })
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(4)
+ })
+
+ it('pushes again when a remote pane swaps its pty', () => {
+ // ptyIdsByLeafId is not in the payload, but a swap means a different host session.
+ const pusher = createRemotePaneLayoutPusher()
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ pusher.push({
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ layout: makeLayout({ ptyIdsByLeafId: { 'leaf-a': 'remote:pty-c', 'leaf-b': 'remote:pty-b' } })
+ })
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not carry a cached layout across tabs', () => {
+ const pusher = createRemotePaneLayoutPusher()
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-2', layout: makeLayout() })
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(3)
+ })
+
+ it('does not carry a cached layout across worktrees', () => {
+ const pusher = createRemotePaneLayoutPusher()
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ pusher.push({ worktreeId: 'wt-2', tabId: 'tab-1', layout: makeLayout() })
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2)
+ })
+
+ it('retries an unchanged layout after a failed push', async () => {
+ updateWebRuntimePaneLayout.mockResolvedValueOnce(false)
+ const pusher = createRemotePaneLayoutPusher()
+ const input = { worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }
+
+ pusher.push(input)
+ await Promise.resolve()
+ pusher.push(input)
+
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not let a stale failure invalidate a newer in-flight layout', async () => {
+ const first = deferred()
+ const second = deferred()
+ updateWebRuntimePaneLayout.mockImplementationOnce(() => first.promise)
+ updateWebRuntimePaneLayout.mockImplementationOnce(() => second.promise)
+ const pusher = createRemotePaneLayoutPusher()
+ const changedLayout = makeLayout({ expandedLeafId: 'leaf-a' })
+
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: changedLayout })
+ first.resolve(false)
+ await Promise.resolve()
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: changedLayout })
+
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2)
+ second.resolve(true)
+ })
+
+ it('re-pushes after a remount re-establishes host geometry', () => {
+ const pusher = createRemotePaneLayoutPusher()
+ pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() })
+ createRemotePaneLayoutPusher().push({
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ layout: makeLayout()
+ })
+ expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts
new file mode 100644
index 00000000000..506bd7ea42f
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts
@@ -0,0 +1,47 @@
+import type { TerminalLayoutSnapshot } from '../../../../shared/types'
+import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
+import { updateWebRuntimePaneLayout } from '@/runtime/web-runtime-session'
+
+export type RemotePaneLayoutPusher = {
+ push: (input: { worktreeId: string; tabId: string; layout: TerminalLayoutSnapshot }) => void
+}
+
+/**
+ * Pane geometry is host-authoritative for remote tabs, so persists must push it — but
+ * persists also fire on pane-title churn, which leaves the host-visible layout untouched.
+ * Dedupe against the last push so unchanged layouts cost no remote round trip.
+ */
+export function createRemotePaneLayoutPusher(): RemotePaneLayoutPusher {
+ let lastAttempt: {
+ id: number
+ worktreeId: string
+ tabId: string
+ snapshot: TerminalLayoutSnapshot
+ } | null = null
+ let nextAttemptId = 0
+ return {
+ push: ({ worktreeId, tabId, layout }) => {
+ if (
+ lastAttempt?.worktreeId === worktreeId &&
+ lastAttempt.tabId === tabId &&
+ terminalLayoutEqual(lastAttempt.snapshot, layout)
+ ) {
+ return
+ }
+ const attempt = { id: ++nextAttemptId, worktreeId, tabId, snapshot: layout }
+ lastAttempt = attempt
+ void updateWebRuntimePaneLayout({
+ worktreeId,
+ tabId,
+ root: layout.root,
+ expandedLeafId: layout.expandedLeafId,
+ ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {})
+ }).then((updated) => {
+ // Why: a disconnected or timed-out push carried no information, so the next persist must retry it.
+ if (!updated && lastAttempt?.id === attempt.id) {
+ lastAttempt = null
+ }
+ })
+ }
+ }
+}
diff --git a/src/renderer/src/lib/terminal-layout-equality.ts b/src/renderer/src/lib/terminal-layout-equality.ts
new file mode 100644
index 00000000000..ce11907bed1
--- /dev/null
+++ b/src/renderer/src/lib/terminal-layout-equality.ts
@@ -0,0 +1,55 @@
+import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types'
+
+function sameStringRecord(
+ a: Readonly> | undefined,
+ b: Readonly> | undefined
+): boolean {
+ const left = a ?? {}
+ const right = b ?? {}
+ const leftKeys = Object.keys(left)
+ const rightKeys = Object.keys(right)
+ return (
+ leftKeys.length === rightKeys.length &&
+ leftKeys.every(
+ (key) => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key]
+ )
+ )
+}
+
+export function terminalLayoutNodeEqual(
+ a: TerminalPaneLayoutNode | null | undefined,
+ b: TerminalPaneLayoutNode | null | undefined
+): boolean {
+ if (!a || !b) {
+ return !a && !b
+ }
+ if (a.type !== b.type) {
+ return false
+ }
+ if (a.type === 'leaf') {
+ return b.type === 'leaf' && a.leafId === b.leafId
+ }
+ return (
+ b.type === 'split' &&
+ a.direction === b.direction &&
+ a.ratio === b.ratio &&
+ terminalLayoutNodeEqual(a.first, b.first) &&
+ terminalLayoutNodeEqual(a.second, b.second)
+ )
+}
+
+/** Structural equality over every persisted layout field; drives store/IPC write bailouts. */
+export function terminalLayoutEqual(
+ a: TerminalLayoutSnapshot | undefined,
+ b: TerminalLayoutSnapshot
+): boolean {
+ return (
+ terminalLayoutNodeEqual(a?.root, b.root) &&
+ (a?.activeLeafId ?? null) === b.activeLeafId &&
+ (a?.expandedLeafId ?? null) === b.expandedLeafId &&
+ sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) &&
+ sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) &&
+ sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) &&
+ sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId)
+ )
+}
diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts
index d944272accb..f3447e62643 100644
--- a/src/renderer/src/runtime/web-session-tabs-sync.ts
+++ b/src/renderer/src/runtime/web-session-tabs-sync.ts
@@ -25,13 +25,13 @@ import type {
TabGroup,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
- TerminalPaneLayoutNode,
TerminalTab
} from '../../../shared/types'
import type { OpenFile } from '../store/slices/editor'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
+import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets'
@@ -1424,59 +1424,6 @@ function isMirroredCommandCodeTurnBump(
)
}
-function sameStringRecord(
- a: Readonly> | undefined,
- b: Readonly> | undefined
-): boolean {
- const left = a ?? {}
- const right = b ?? {}
- const leftKeys = Object.keys(left)
- const rightKeys = Object.keys(right)
- return (
- leftKeys.length === rightKeys.length &&
- leftKeys.every(
- (key) => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key]
- )
- )
-}
-
-function terminalLayoutNodeEqual(
- a: TerminalPaneLayoutNode | null | undefined,
- b: TerminalPaneLayoutNode | null | undefined
-): boolean {
- if (!a || !b) {
- return !a && !b
- }
- if (a.type !== b.type) {
- return false
- }
- if (a.type === 'leaf') {
- return b.type === 'leaf' && a.leafId === b.leafId
- }
- return (
- b.type === 'split' &&
- a.direction === b.direction &&
- a.ratio === b.ratio &&
- terminalLayoutNodeEqual(a.first, b.first) &&
- terminalLayoutNodeEqual(a.second, b.second)
- )
-}
-
-function terminalLayoutEqual(
- a: TerminalLayoutSnapshot | undefined,
- b: TerminalLayoutSnapshot
-): boolean {
- return (
- terminalLayoutNodeEqual(a?.root, b.root) &&
- (a?.activeLeafId ?? null) === b.activeLeafId &&
- (a?.expandedLeafId ?? null) === b.expandedLeafId &&
- sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) &&
- sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) &&
- sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) &&
- sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId)
- )
-}
-
function sanitizeRecentTabIds(recent: string[] | undefined, tabOrder: string[]): string[] {
if (!recent || recent.length === 0) {
return []
diff --git a/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts b/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts
new file mode 100644
index 00000000000..bfd5ab13964
--- /dev/null
+++ b/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts
@@ -0,0 +1,227 @@
+/**
+ * Perf regression: setCacheTimerStartedAt and setTabLayout must not publish
+ * state when the write is a no-op.
+ *
+ * Both actions previously spread a fresh object and returned it unconditionally,
+ * so every redundant call produced a new AppState object and woke EVERY zustand
+ * subscriber — which, with per-pane selectors across all mounted panes, is an
+ * O(panes) sweep per write. The cadence is real: parked-terminal-byte-watcher
+ * writes a null cache timer on every agent working/exit/stale-title transition,
+ * and TerminalPane re-persists its layout on pane-title churn.
+ *
+ * These tests count subscriber wakeups on a real store; the pre-fix numbers are
+ * 1_000 (one per call), the post-fix numbers are 0.
+ */
+import { describe, expect, it } from 'vitest'
+import type { TerminalLayoutSnapshot } from '../../../../shared/types'
+import { createTestStore } from './store-test-helpers'
+
+const REPEATS = 1_000
+
+function makeLayout(overrides: Partial = {}): TerminalLayoutSnapshot {
+ return {
+ root: {
+ type: 'split',
+ direction: 'vertical',
+ ratio: 0.5,
+ first: { type: 'leaf', leafId: 'leaf-a' },
+ second: { type: 'leaf', leafId: 'leaf-b' }
+ },
+ activeLeafId: 'leaf-a',
+ expandedLeafId: null,
+ ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-b' },
+ titlesByLeafId: { 'leaf-a': 'build' },
+ ...overrides
+ }
+}
+
+describe('setCacheTimerStartedAt identity bailout', () => {
+ it('wakes zero subscribers across 1,000 null-over-null writes', () => {
+ const store = createTestStore()
+ const paneKey = 'tab-1:leaf-a'
+ store.getState().setCacheTimerStartedAt(paneKey, null)
+
+ let wakeups = 0
+ const unsubscribe = store.subscribe(() => {
+ wakeups += 1
+ })
+ for (let i = 0; i < REPEATS; i += 1) {
+ store.getState().setCacheTimerStartedAt(paneKey, null)
+ }
+ unsubscribe()
+
+ expect(wakeups).toBe(0)
+ expect(store.getState().cacheTimerByKey[paneKey]).toBeNull()
+ })
+
+ it('wakes zero subscribers across 1,000 repeats of the same timestamp', () => {
+ const store = createTestStore()
+ const paneKey = 'tab-1:leaf-a'
+ store.getState().setCacheTimerStartedAt(paneKey, 1_700_000_000_000)
+
+ let wakeups = 0
+ const unsubscribe = store.subscribe(() => {
+ wakeups += 1
+ })
+ for (let i = 0; i < REPEATS; i += 1) {
+ store.getState().setCacheTimerStartedAt(paneKey, 1_700_000_000_000)
+ }
+ unsubscribe()
+
+ expect(wakeups).toBe(0)
+ })
+
+ it('still publishes when the timestamp actually changes', () => {
+ const store = createTestStore()
+ const paneKey = 'tab-1:leaf-a'
+ store.getState().setCacheTimerStartedAt(paneKey, null)
+
+ let wakeups = 0
+ const unsubscribe = store.subscribe(() => {
+ wakeups += 1
+ })
+ store.getState().setCacheTimerStartedAt(paneKey, 123)
+ store.getState().setCacheTimerStartedAt(paneKey, null)
+ unsubscribe()
+
+ expect(wakeups).toBe(2)
+ expect(store.getState().cacheTimerByKey[paneKey]).toBeNull()
+ })
+
+ it('records the first write for a key even when the value is null', () => {
+ const store = createTestStore()
+ const paneKey = 'tab-1:leaf-a'
+
+ let wakeups = 0
+ const unsubscribe = store.subscribe(() => {
+ wakeups += 1
+ })
+ store.getState().setCacheTimerStartedAt(paneKey, null)
+ unsubscribe()
+
+ expect(wakeups).toBe(1)
+ expect(paneKey in store.getState().cacheTimerByKey).toBe(true)
+ })
+
+ it('still clears a stale :seed sentinel when the pane value is unchanged', () => {
+ // Without this carve-out the bailout would strand the sentinel and leave a phantom timer.
+ const store = createTestStore()
+ const paneKey = 'tab-1:leaf-a'
+ store.getState().setCacheTimerStartedAt(paneKey, null)
+ store.setState((s) => ({ cacheTimerByKey: { ...s.cacheTimerByKey, 'tab-1:seed': 42 } }))
+
+ store.getState().setCacheTimerStartedAt(paneKey, null)
+
+ expect('tab-1:seed' in store.getState().cacheTimerByKey).toBe(false)
+ })
+})
+
+describe('setTabLayout identity bailout', () => {
+ it('wakes zero subscribers across 1,000 structurally identical snapshots', () => {
+ const store = createTestStore()
+ store.getState().setTabLayout('tab-1', makeLayout())
+
+ let wakeups = 0
+ const unsubscribe = store.subscribe(() => {
+ wakeups += 1
+ })
+ for (let i = 0; i < REPEATS; i += 1) {
+ // Fresh object each iteration: this is what persistLayoutSnapshot produces.
+ store.getState().setTabLayout('tab-1', makeLayout())
+ }
+ unsubscribe()
+
+ expect(wakeups).toBe(0)
+ })
+
+ it('keeps the stored snapshot reference stable when nothing changed', () => {
+ const store = createTestStore()
+ store.getState().setTabLayout('tab-1', makeLayout())
+ const first = store.getState().terminalLayoutsByTabId['tab-1']
+
+ store.getState().setTabLayout('tab-1', makeLayout())
+
+ expect(store.getState().terminalLayoutsByTabId['tab-1']).toBe(first)
+ })
+
+ it('publishes when any tracked field changes', () => {
+ const store = createTestStore()
+ store.getState().setTabLayout('tab-1', makeLayout())
+
+ const mutations: Partial[] = [
+ { activeLeafId: 'leaf-b' },
+ { expandedLeafId: 'leaf-a' },
+ { titlesByLeafId: { 'leaf-a': 'test' } },
+ { ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-c' } },
+ { buffersByLeafId: { 'leaf-a': 'scrollback' } },
+ { scrollbackRefsByLeafId: { 'leaf-a': 'ref-1' } },
+ {
+ root: {
+ type: 'split',
+ direction: 'horizontal',
+ ratio: 0.5,
+ first: { type: 'leaf', leafId: 'leaf-a' },
+ second: { type: 'leaf', leafId: 'leaf-b' }
+ }
+ },
+ {
+ root: {
+ type: 'split',
+ direction: 'vertical',
+ ratio: 0.7,
+ first: { type: 'leaf', leafId: 'leaf-a' },
+ second: { type: 'leaf', leafId: 'leaf-b' }
+ }
+ }
+ ]
+
+ for (const mutation of mutations) {
+ store.getState().setTabLayout('tab-1', makeLayout())
+ let wakeups = 0
+ const unsubscribe = store.subscribe(() => {
+ wakeups += 1
+ })
+ store.getState().setTabLayout('tab-1', makeLayout(mutation))
+ unsubscribe()
+ expect(wakeups, `expected a publish for ${JSON.stringify(mutation)}`).toBe(1)
+ }
+ })
+
+ it('does not fire pane-ownership transfers for a bailed-out identical layout', () => {
+ // A duplicate-pty layout normalizes to a transfer; replaying the already-normalized
+ // snapshot must be inert, since normalization then finds nothing to move.
+ const store = createTestStore()
+ const duplicate = makeLayout({
+ ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-a' }
+ })
+ store.getState().setTabLayout('tab-1', duplicate)
+ const normalized = store.getState().terminalLayoutsByTabId['tab-1']
+ expect(normalized.ptyIdsByLeafId).not.toEqual(duplicate.ptyIdsByLeafId)
+
+ store.getState().markTerminalPaneUnread('tab-1:leaf-a')
+ const beforeUnread = { ...store.getState().unreadTerminalPanes }
+
+ for (let i = 0; i < REPEATS; i += 1) {
+ store.getState().setTabLayout('tab-1', { ...normalized })
+ }
+
+ expect(store.getState().terminalLayoutsByTabId['tab-1']).toBe(normalized)
+ expect(store.getState().unreadTerminalPanes).toEqual(beforeUnread)
+ })
+
+ it('still deletes the layout on a clearing call, and bails when already absent', () => {
+ const store = createTestStore()
+ store.getState().setTabLayout('tab-1', makeLayout())
+
+ store.getState().setTabLayout('tab-1', null)
+ expect('tab-1' in store.getState().terminalLayoutsByTabId).toBe(false)
+
+ let wakeups = 0
+ const unsubscribe = store.subscribe(() => {
+ wakeups += 1
+ })
+ store.getState().setTabLayout('tab-1', null)
+ unsubscribe()
+ expect(wakeups).toBe(0)
+ })
+})
diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts
index d947c2a5dd4..9edf537671a 100644
--- a/src/renderer/src/store/slices/terminals.ts
+++ b/src/renderer/src/store/slices/terminals.ts
@@ -54,6 +54,7 @@ import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity'
import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
+import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
import {
@@ -1266,15 +1267,18 @@ export const createTerminalSlice: StateCreator
setCacheTimerStartedAt: (key, ts) => {
set((s) => {
- const next = { ...s.cacheTimerByKey, [key]: ts }
// Why: a real pane write clears any ':seed' sentinel from seedCacheTimersForIdleTabs, avoiding phantom timers when the seed key doesn't match the real pane.
const colonIdx = key.indexOf(':')
- if (colonIdx !== -1) {
- const tabId = key.slice(0, colonIdx)
- const suffix = key.slice(colonIdx + 1)
- if (suffix !== 'seed') {
- delete next[`${tabId}:seed`]
- }
+ const suffix = colonIdx === -1 ? null : key.slice(colonIdx + 1)
+ const seedKey = colonIdx !== -1 && suffix !== 'seed' ? `${key.slice(0, colonIdx)}:seed` : null
+ const hasStaleSeed = seedKey !== null && seedKey in s.cacheTimerByKey
+ // Why: parked-pane watchers replay null-over-null on every working/exit transition; each redundant write runs every subscriber's selector.
+ if (s.cacheTimerByKey[key] === ts && !hasStaleSeed) {
+ return s
+ }
+ const next = { ...s.cacheTimerByKey, [key]: ts }
+ if (seedKey !== null) {
+ delete next[seedKey]
}
return { cacheTimerByKey: next }
})
@@ -3650,20 +3654,27 @@ export const createTerminalSlice: StateCreator
setTabLayout: (tabId, layout) => {
let ownershipTransfers: ReturnType = []
set((s) => {
- const next = { ...s.terminalLayoutsByTabId }
- if (layout) {
- const normalized = normalizeTerminalLayoutPtyOwnership(layout)
- next[tabId] = normalized.snapshot
- if (normalized.changed) {
- ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers(
- layout,
- normalized.snapshot
- )
+ if (!layout) {
+ if (!(tabId in s.terminalLayoutsByTabId)) {
+ return s
}
- } else {
+ const next = { ...s.terminalLayoutsByTabId }
delete next[tabId]
+ return { terminalLayoutsByTabId: next }
+ }
+ const normalized = normalizeTerminalLayoutPtyOwnership(layout)
+ // Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched.
+ if (normalized.changed) {
+ ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized.snapshot)
+ }
+ // Why: pane-title churn re-persists structurally identical snapshots; bailing keeps every pane selector asleep.
+ const existing = s.terminalLayoutsByTabId[tabId]
+ if (existing && terminalLayoutEqual(existing, normalized.snapshot)) {
+ return s
+ }
+ return {
+ terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot }
}
- return { terminalLayoutsByTabId: next }
})
transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers)
},
diff --git a/tests/e2e/paired-remote-pane-layout-retry.spec.ts b/tests/e2e/paired-remote-pane-layout-retry.spec.ts
new file mode 100644
index 00000000000..653cde88add
--- /dev/null
+++ b/tests/e2e/paired-remote-pane-layout-retry.spec.ts
@@ -0,0 +1,210 @@
+import type { Page } from '@stablyai/playwright-test'
+import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
+import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
+import type { TerminalLayoutSnapshot } from '../../src/shared/types'
+import {
+ launchHeadlessPairedRuntimeHost,
+ type HeadlessPairedRuntimeHost
+} from './helpers/headless-paired-runtime-host'
+import { expect, test } from './helpers/orca-app'
+import {
+ launchPairedElectronClient,
+ type PairedElectronClient
+} from './helpers/paired-electron-client'
+
+async function callEnvironment(
+ page: Page,
+ environmentId: string,
+ method: string,
+ params: unknown
+): Promise {
+ return page.evaluate(
+ async ({ environmentId, method, params }) => {
+ const response = await window.api.runtimeEnvironments.call({
+ selector: environmentId,
+ method,
+ params
+ })
+ if (!response.ok) {
+ throw new Error(`${response.error.code}: ${response.error.message}`)
+ }
+ return response.result
+ },
+ { environmentId, method, params }
+ ) as Promise
+}
+
+async function openClientTab(page: Page, worktreeId: string, tabId: string): Promise {
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ ({ tabId, worktreeId }) =>
+ (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
+ (tab) => tab.id === tabId
+ ),
+ { tabId, worktreeId }
+ ),
+ { timeout: 60_000, message: `paired client never mirrored host tab ${tabId}` }
+ )
+ .toBe(true)
+ await page.evaluate(
+ ({ tabId, worktreeId }) => {
+ const state = window.__store?.getState()
+ state?.setActiveView('terminal')
+ state?.setActiveWorktree(worktreeId)
+ state?.setActiveTab(tabId)
+ state?.setActiveTabType('terminal')
+ },
+ { tabId, worktreeId }
+ )
+ await expect
+ .poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, tabId), {
+ timeout: 60_000,
+ message: `paired client pane for ${tabId} did not mount`
+ })
+ .toBe(true)
+}
+
+async function readHostLayout(
+ host: HeadlessPairedRuntimeHost,
+ worktreeId: string,
+ hostTabId: string
+): Promise {
+ const snapshot = (
+ await host.client.call('session.tabs.list', {
+ worktree: `id:${worktreeId}`
+ })
+ ).result
+ return (
+ snapshot.tabs.find((tab) => tab.type === 'terminal' && tab.parentTabId === hostTabId)
+ ?.parentLayout ?? null
+ )
+}
+
+async function readClientLayout(page: Page, tabId: string): Promise {
+ return page.evaluate((id) => window.__store?.getState().terminalLayoutsByTabId[id] ?? null, tabId)
+}
+
+async function setPaneTitle(page: Page, title: string): Promise {
+ const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac'))
+ await page
+ .locator('.xterm:visible')
+ .first()
+ .click({
+ button: isMac ? 'left' : 'right',
+ position: { x: 40, y: 40 },
+ modifiers: isMac ? ['Control'] : []
+ })
+ await page.getByText('Set Title…', { exact: true }).click()
+ const titleInput = page.getByRole('textbox', { name: 'Pane title' })
+ await expect(titleInput).toBeVisible()
+ await titleInput.fill(title)
+ await titleInput.press('Enter')
+ await expect(titleInput).toHaveCount(0)
+ await expect(page.getByRole('button', { name: `Edit pane title: ${title}` })).toBeVisible()
+}
+
+test('retries an identical remote pane layout after reconnect', async ({
+ testRepoPath
+}, testInfo) => {
+ test.setTimeout(240_000)
+ const title = `Reconnect retry ${Date.now()}`
+ const host = await launchHeadlessPairedRuntimeHost()
+ let client: PairedElectronClient | null = null
+ let observer: PairedElectronClient | null = null
+ let terminal: string | null = null
+
+ try {
+ await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
+ client = await launchPairedElectronClient(host.offer, testInfo, 'Pane layout retry client')
+ await expect
+ .poll(
+ () =>
+ client?.page.evaluate(() => window.__store?.getState().allWorktrees().length ?? 0) ?? 0,
+ { timeout: 60_000, message: 'paired client never saw a host worktree' }
+ )
+ .toBeGreaterThan(0)
+ const worktreeId = await client.page.evaluate(
+ () => window.__store?.getState().allWorktrees()[0]?.id ?? null
+ )
+ if (!worktreeId) {
+ throw new Error('Paired client did not receive the host worktree')
+ }
+
+ const created = await callEnvironment<{
+ tab: { parentTabId: string; terminal: string | null }
+ }>(client.page, client.environmentId, 'session.tabs.createTerminal', {
+ worktree: `id:${worktreeId}`,
+ activate: false,
+ select: false,
+ navigation: 'caller'
+ })
+ terminal = created.tab.terminal
+ if (!terminal) {
+ throw new Error('Host terminal was not created')
+ }
+ const hostTabId = created.tab.parentTabId
+ const webTabId = toWebTerminalSurfaceTabId(hostTabId)
+ await openClientTab(client.page, worktreeId, webTabId)
+
+ const terminalRoot = client.page.locator(`[data-terminal-tab-id="${webTabId}"]`).first()
+ await terminalRoot.evaluate((element) => {
+ element.setAttribute('data-layout-retry-owner', 'original')
+ })
+ await client.page.evaluate(async (selector) => {
+ await window.api.runtimeEnvironments.disconnect({ selector })
+ }, client.environmentId)
+
+ const failedPush = client.page.waitForEvent('console', {
+ predicate: (message) =>
+ message.type() === 'warning' &&
+ message.text().includes('[web-runtime-session] failed to update pane layout:'),
+ timeout: 30_000
+ })
+ await setPaneTitle(client.page, title)
+ await failedPush
+ const failedLayout = await readClientLayout(client.page, webTabId)
+ expect(Object.values(failedLayout?.titlesByLeafId ?? {})).toContain(title)
+ expect(
+ Object.values((await readHostLayout(host, worktreeId, hostTabId))?.titlesByLeafId ?? {})
+ ).not.toContain(title)
+
+ await expect
+ .poll(
+ () =>
+ client.page.evaluate(async (selector) => {
+ const response = await window.api.runtimeEnvironments.connect({ selector })
+ return response.ok
+ }, client.environmentId),
+ { timeout: 60_000, message: 'paired client never reconnected to the host runtime' }
+ )
+ .toBe(true)
+ await expect(terminalRoot).toHaveAttribute('data-layout-retry-owner', 'original')
+
+ await setPaneTitle(client.page, title)
+ expect(await readClientLayout(client.page, webTabId)).toEqual(failedLayout)
+ await expect
+ .poll(
+ async () =>
+ Object.values(
+ (await readHostLayout(host, worktreeId, hostTabId))?.titlesByLeafId ?? {}
+ ).includes(title),
+ { timeout: 30_000, message: 'headless host never persisted the retried pane layout' }
+ )
+ .toBe(true)
+
+ observer = await launchPairedElectronClient(host.offer, testInfo, 'Pane layout retry observer')
+ await openClientTab(observer.page, worktreeId, webTabId)
+ await expect(
+ observer.page.getByRole('button', { name: `Edit pane title: ${title}` })
+ ).toBeVisible({ timeout: 30_000 })
+ } finally {
+ await observer?.dispose()
+ if (terminal) {
+ await host.client.call('terminal.closeTab', { terminal }).catch(() => undefined)
+ }
+ await client?.dispose()
+ await host.dispose()
+ }
+})
From 7c26cceaf176e2d9c4c8812b6dc11845f81b9ac9 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:56:56 -0700
Subject: [PATCH 22/42] fix(workspaces): add space after emoji picker selection
Add a Slack-style trailing space after selecting an emoji from the create-worktree colon picker, while preserving existing separators and caret placement.
---
.../SmartWorkspaceNameField.ime-enter.test.tsx | 2 +-
.../src/lib/workspace-emoji-shortcodes.test.ts | 15 ++++++++++++++-
.../src/lib/workspace-emoji-shortcodes.ts | 11 +++++++----
3 files changed, 22 insertions(+), 6 deletions(-)
diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx
index fd828b0b202..f3c81f5b573 100644
--- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx
+++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx
@@ -289,6 +289,6 @@ describe('SmartWorkspaceNameField emoji shortcodes', () => {
})
pressEnter(input)
- expect(onValueChange).toHaveBeenCalledWith('Launch 😉')
+ expect(onValueChange).toHaveBeenCalledWith('Launch 😉 ')
})
})
diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts
index 0f67df13987..3ca31c6580a 100644
--- a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts
+++ b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts
@@ -86,7 +86,20 @@ describe('workspace emoji shortcodes', () => {
)
).toEqual({
value: 'Ship 😉 today',
- cursor: 7
+ cursor: 8
+ })
+ })
+
+ it('adds a trailing space when a selected suggestion is at the end of the name', () => {
+ expect(
+ applyWorkspaceEmojiSuggestion(
+ 'Launch :win',
+ { start: 7, end: 11, query: 'win' },
+ { emoji: '😉', shortcode: 'wink' }
+ )
+ ).toEqual({
+ value: 'Launch 😉 ',
+ cursor: 10
})
})
})
diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts
index 3a5efc7e8d1..23feeb735e0 100644
--- a/src/renderer/src/lib/workspace-emoji-shortcodes.ts
+++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts
@@ -109,17 +109,20 @@ export function applyWorkspaceEmojiSuggestion(
active: ActiveWorkspaceEmojiShortcode,
suggestion: WorkspaceEmojiSuggestion
): WorkspaceEmojiReplacement {
- return replaceWorkspaceEmojiRange(value, active.start, active.end, suggestion.emoji)
+ return replaceWorkspaceEmojiRange(value, active.start, active.end, suggestion.emoji, true)
}
function replaceWorkspaceEmojiRange(
value: string,
start: number,
end: number,
- emoji: string
+ emoji: string,
+ addTrailingSpace = false
): WorkspaceEmojiReplacement {
+ const hasFollowingWhitespace = /\s/.test(value[end] ?? '')
+ const trailingSpace = addTrailingSpace && !hasFollowingWhitespace ? ' ' : ''
return {
- value: `${value.slice(0, start)}${emoji}${value.slice(end)}`,
- cursor: start + emoji.length
+ value: `${value.slice(0, start)}${emoji}${trailingSpace}${value.slice(end)}`,
+ cursor: start + emoji.length + (addTrailingSpace ? 1 : 0)
}
}
From 8c65dd50949a685d00435cb5811e544f458ef841 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 4 Aug 2026 17:04:51 -0700
Subject: [PATCH 23/42] perf(runtime): keep PowerShell ACL work and a second
auth off the remote command path (#12451)
* perf(runtime): keep PowerShell ACL work and a second auth off the remote command path
Two costs sat on the remote authentication path on Windows:
- The E2EE handshake persisted `lastSeenAt` inline, and every secure-file write
spawns PowerShell synchronously twice to reapply the registry ACL, so the
client's `e2ee_authenticated` waited on both spawns.
- Every remote CLI command except `status.get` opened a second full WebSocket
connection just to re-read status for the protocol-compat check, doubling the
authentications per command.
The first sighting of a device still persists inline (rotation drops entries
disk says were never scanned); later refreshes update memory now and coalesce
onto one deferred write. The compat verdict is saved against the runtime's
per-launch `runtimeId`, so a restarted or upgraded runtime retires it.
* fix(runtime): preserve compatibility on one remote auth
* fix(runtime): flush registry after transport shutdown
---
src/cli/runtime/client.ts | 70 ++----
src/cli/runtime/remote-runtime-compat-gate.ts | 79 +++++++
src/cli/runtime/websocket-transport.test.ts | 32 +++
src/cli/runtime/websocket-transport.ts | 29 ++-
src/main/runtime/device-registry.ts | 59 +++++
.../rpc/mobile-auth-acl-critical-path.test.ts | 209 ++++++++++++++++++
.../runtime/rpc/mobile-socket-wiring.test.ts | 3 +-
src/main/runtime/rpc/mobile-socket-wiring.ts | 3 +-
src/main/runtime/runtime-rpc.test.ts | 52 +++++
src/main/runtime/runtime-rpc.ts | 13 +-
src/shared/remote-runtime-client.ts | 73 +++++-
11 files changed, 557 insertions(+), 65 deletions(-)
create mode 100644 src/cli/runtime/remote-runtime-compat-gate.ts
create mode 100644 src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts
diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts
index 3a50b0c87d1..3a8951de554 100644
--- a/src/cli/runtime/client.ts
+++ b/src/cli/runtime/client.ts
@@ -11,15 +11,12 @@ import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus, resolveDesktopWindowStatus } from './status'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
-import type { sendWebSocketRequest } from './websocket-transport'
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
-import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
import {
- MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
- ORCHESTRATION_CONTRACT_VERSION,
- RUNTIME_PROTOCOL_VERSION
+ ORCHESTRATION_CONTRACT_VERSION
} from '../../shared/protocol-version'
+import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate'
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
@@ -34,8 +31,8 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000
// Why: ws + tweetnacl + the remote-runtime frame stack only matter once a
// request actually goes over a pairing offer, which local CLI calls never do.
// Both call sites already await this, so deferring the load changes no ordering.
-async function loadSendWebSocketRequest(): Promise {
- return (await import('./websocket-transport.js')).sendWebSocketRequest
+async function loadWebSocketTransport() {
+ return await import('./websocket-transport.js')
}
export class RuntimeClient {
@@ -43,7 +40,7 @@ export class RuntimeClient {
private readonly requestTimeoutMs: number
private readonly remotePairing: PairingOffer | null
private readonly environmentSelector: string | null
- private remoteCompatChecked = false
+ private readonly remoteCompat: RemoteRuntimeCompatGate
private orchestrationContractCheck: Promise | null = null
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
process.env
@@ -62,6 +59,7 @@ export class RuntimeClient {
this.requestTimeoutMs = requestTimeoutMs
this.environmentSelector = environmentSelector
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
+ this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector)
}
get isRemote(): boolean {
@@ -97,19 +95,17 @@ export class RuntimeClient {
...compatibilityEnvelope
}
if (this.remotePairing) {
- if (method !== 'status.get') {
- await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
- }
- const sendWebSocketRequest = await loadSendWebSocketRequest()
+ const transport = await loadWebSocketTransport()
let response
try {
- response = await sendWebSocketRequest(
- this.remotePairing,
+ response = await this.remoteCompat.send({
+ transport,
+ pairing: this.remotePairing,
method,
params,
- effectiveTimeoutMs,
+ timeoutMs: effectiveTimeoutMs,
envelope
- )
+ })
} catch (error) {
throw attachMutationRecovery(error, orchestrationRequestId)
}
@@ -157,8 +153,7 @@ export class RuntimeClient {
async getCliStatus(): Promise> {
if (this.remotePairing) {
const response = await this.call('status.get')
- this.assertRemoteRuntimeStatusCompatible(response.result)
- this.remoteCompatChecked = true
+ this.remoteCompat.noteVerifiedStatus(response.result)
const graphState = response.result.graphStatus
return {
id: response.id,
@@ -196,29 +191,6 @@ export class RuntimeClient {
return getCliStatus(this.userDataPath)
}
- private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise {
- if (!this.remotePairing || this.remoteCompatChecked) {
- return
- }
- const sendWebSocketRequest = await loadSendWebSocketRequest()
- const response = await sendWebSocketRequest(
- this.remotePairing,
- 'status.get',
- undefined,
- timeoutMs
- )
- if (response.ok === false) {
- throw new RuntimeRpcFailureError(response)
- }
- this.assertRemoteRuntimeStatusCompatible(response.result)
- this.remoteCompatChecked = true
- if (this.environmentSelector) {
- markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
- runtimeId: response._meta.runtimeId
- })
- }
- }
-
private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise {
if (!this.orchestrationContractCheck) {
this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(timeoutMs)
@@ -229,8 +201,7 @@ export class RuntimeClient {
private async checkOrchestrationContractCompatibility(timeoutMs: number): Promise {
const response = await this.call('status.get', undefined, { timeoutMs })
if (this.remotePairing) {
- this.assertRemoteRuntimeStatusCompatible(response.result)
- this.remoteCompatChecked = true
+ this.remoteCompat.noteVerifiedStatus(response.result)
}
if (!response.result.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
@@ -241,19 +212,6 @@ export class RuntimeClient {
}
}
- private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void {
- const verdict = evaluateRuntimeCompat({
- clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
- minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
- serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
- serverMinCompatibleClientProtocolVersion:
- status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
- })
- if (verdict.kind === 'blocked') {
- throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
- }
- }
-
async openOrca(timeoutMs = 15_000): Promise> {
const initial = await this.getCliStatus()
if (this.remotePairing) {
diff --git a/src/cli/runtime/remote-runtime-compat-gate.ts b/src/cli/runtime/remote-runtime-compat-gate.ts
new file mode 100644
index 00000000000..a711a475e39
--- /dev/null
+++ b/src/cli/runtime/remote-runtime-compat-gate.ts
@@ -0,0 +1,79 @@
+import type { PairingOffer } from '../../shared/pairing'
+import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
+import {
+ MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
+ RUNTIME_PROTOCOL_VERSION
+} from '../../shared/protocol-version'
+import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
+import type { RuntimeStatus } from '../../shared/runtime-types'
+import { markEnvironmentUsed } from './environments'
+import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcResponse } from './types'
+import type {
+ sendWebSocketRequest,
+ sendWebSocketRequestWithStatusPreflight
+} from './websocket-transport'
+
+type WebSocketTransport = {
+ sendWebSocketRequest: typeof sendWebSocketRequest
+ sendWebSocketRequestWithStatusPreflight: typeof sendWebSocketRequestWithStatusPreflight
+}
+
+export class RemoteRuntimeCompatGate {
+ private checked = false
+
+ constructor(
+ private readonly userDataPath: string,
+ private readonly environmentSelector: string | null
+ ) {}
+
+ send(args: {
+ transport: WebSocketTransport
+ pairing: PairingOffer
+ method: string
+ params: unknown
+ timeoutMs: number
+ envelope?: RuntimeOrchestrationEnvelope
+ }): Promise> {
+ if (this.checked || args.method === 'status.get') {
+ return args.transport.sendWebSocketRequest(
+ args.pairing,
+ args.method,
+ args.params,
+ args.timeoutMs,
+ args.envelope
+ )
+ }
+ return args.transport.sendWebSocketRequestWithStatusPreflight(
+ args.pairing,
+ args.method,
+ args.params,
+ args.timeoutMs,
+ (response) => {
+ if (response.ok === false) {
+ throw new RuntimeRpcFailureError(response)
+ }
+ this.noteVerifiedStatus(response.result)
+ if (this.environmentSelector) {
+ markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
+ runtimeId: response._meta.runtimeId
+ })
+ }
+ },
+ args.envelope
+ )
+ }
+
+ noteVerifiedStatus(status: RuntimeStatus): void {
+ const verdict = evaluateRuntimeCompat({
+ clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
+ minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
+ serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
+ serverMinCompatibleClientProtocolVersion:
+ status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
+ })
+ if (verdict.kind === 'blocked') {
+ throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
+ }
+ this.checked = true
+ }
+}
diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts
index 306bbe0e3d1..2764a239021 100644
--- a/src/cli/runtime/websocket-transport.test.ts
+++ b/src/cli/runtime/websocket-transport.test.ts
@@ -31,6 +31,8 @@ type TestRuntime = {
publicKeyB64: string
deviceToken: string
authFrames: Record[]
+ requestMethods: string[]
+ connectionCount: () => number
close: () => Promise
}
@@ -168,6 +170,30 @@ describe('CLI remote WebSocket transport', () => {
code: 'incompatible_runtime',
message: expect.stringContaining('server is too old')
})
+ expect(runtime.connectionCount()).toBe(1)
+ expect(runtime.authFrames).toHaveLength(1)
+ expect(runtime.requestMethods).toEqual(['status.get'])
+ })
+
+ it('preflights and dispatches through one authenticated connection', async () => {
+ const runtime = await startTestRuntime('runtime-single-auth')
+ servers.push(runtime)
+ const client = new RuntimeClient(
+ '/tmp/unused',
+ 5_000,
+ encodePairingOffer({
+ v: 2,
+ endpoint: runtime.endpoint,
+ deviceToken: runtime.deviceToken,
+ publicKeyB64: runtime.publicKeyB64
+ })
+ )
+
+ await expect(client.call('repo.list')).rejects.toMatchObject({ code: 'method_not_found' })
+
+ expect(runtime.connectionCount()).toBe(1)
+ expect(runtime.authFrames).toHaveLength(1)
+ expect(runtime.requestMethods).toEqual(['status.get', 'repo.list'])
})
it('blocks orchestration mutations when a remote runtime lacks the contract capability', async () => {
@@ -214,8 +240,11 @@ async function startTestRuntime(
const httpServer = createServer()
const wss = new WebSocketServer({ server: httpServer })
const authFrames: Record[] = []
+ const requestMethods: string[] = []
+ let connectionCount = 0
wss.on('connection', (ws) => {
+ connectionCount += 1
let sharedKey: Uint8Array | null = null
let authenticated = false
@@ -254,6 +283,7 @@ async function startTestRuntime(
}
const request = JSON.parse(plaintext) as { id: string; method: string }
+ requestMethods.push(request.method)
const response =
request.method === 'status.get'
? {
@@ -299,6 +329,8 @@ async function startTestRuntime(
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey),
deviceToken,
authFrames,
+ requestMethods,
+ connectionCount: () => connectionCount,
close: async () => {
await new Promise((resolve) => {
wss.close(() => resolve())
diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts
index e53fbd670bd..d71c1cbd671 100644
--- a/src/cli/runtime/websocket-transport.ts
+++ b/src/cli/runtime/websocket-transport.ts
@@ -2,8 +2,10 @@ import type { PairingOffer } from '../../shared/pairing'
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
import {
RemoteRuntimeClientError,
- sendRemoteRuntimeRequest
+ sendRemoteRuntimeRequest,
+ sendRemoteRuntimeRequestWithStatusPreflight
} from '../../shared/remote-runtime-client'
+import type { RuntimeStatus } from '../../shared/runtime-types'
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
export async function sendWebSocketRequest(
@@ -22,3 +24,28 @@ export async function sendWebSocketRequest(
throw error
}
}
+
+export async function sendWebSocketRequestWithStatusPreflight(
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ timeoutMs: number,
+ validateStatus: (response: RuntimeRpcResponse) => void,
+ envelope?: RuntimeOrchestrationEnvelope
+): Promise> {
+ try {
+ return await sendRemoteRuntimeRequestWithStatusPreflight(
+ pairing,
+ method,
+ params,
+ timeoutMs,
+ validateStatus,
+ envelope
+ )
+ } catch (error) {
+ if (error instanceof RemoteRuntimeClientError) {
+ throw new RuntimeClientError(error.code, error.message)
+ }
+ throw error
+ }
+}
diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts
index 7de8af08fde..88818b3cb1d 100644
--- a/src/main/runtime/device-registry.ts
+++ b/src/main/runtime/device-registry.ts
@@ -47,9 +47,14 @@ function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding
: undefined
}
+// Why: a lastSeen refresh is pure bookkeeping, so coalesce reconnect bursts into one write instead of
+// paying a secure-file rewrite (two synchronous PowerShell ACL spawns on Windows) per connection.
+const LAST_SEEN_FLUSH_DELAY_MS = 250
+
export class DeviceRegistry {
private readonly registryPath: string
private devices: DeviceEntry[] = []
+ private pendingLastSeenFlush: NodeJS.Timeout | null = null
constructor(userDataPath: string) {
this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME)
@@ -214,6 +219,58 @@ export class DeviceRegistry {
)
this.save(nextDevices)
this.devices = nextDevices
+ this.cancelPendingLastSeenFlush()
+ }
+
+ /**
+ * Marks a device seen without blocking the caller on disk — the E2EE auth handshake runs this, and on
+ * Windows every save spawns PowerShell synchronously to reapply the registry's ACL.
+ * The first-ever sighting still persists inline: rotatePendingDevice drops entries that disk says were
+ * never scanned, so only that 0 -> non-zero transition is load-bearing.
+ */
+ updateLastSeenDeferred(deviceId: string): void {
+ const index = this.devices.findIndex((d) => d.deviceId === deviceId)
+ if (index < 0) {
+ return
+ }
+ if (this.devices[index]!.lastSeenAt === 0) {
+ this.updateLastSeen(deviceId)
+ return
+ }
+ const seenAt = Date.now()
+ this.devices = this.devices.map((device, candidateIndex) =>
+ candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device
+ )
+ if (this.pendingLastSeenFlush) {
+ return
+ }
+ this.pendingLastSeenFlush = setTimeout(
+ () => this.flushPendingLastSeen(),
+ LAST_SEEN_FLUSH_DELAY_MS
+ )
+ // Why: bookkeeping must never hold the process open.
+ this.pendingLastSeenFlush.unref?.()
+ }
+
+ /** Persists a deferred lastSeen refresh now; no-op when nothing is pending. */
+ flushPendingLastSeen(): void {
+ if (!this.pendingLastSeenFlush) {
+ return
+ }
+ this.cancelPendingLastSeenFlush()
+ try {
+ this.save(this.devices)
+ } catch (error) {
+ // Why: matches the async hardening path — a failed bookkeeping write must not take down the runtime.
+ console.error('[mobile] Failed to persist device lastSeen:', error)
+ }
+ }
+
+ private cancelPendingLastSeenFlush(): void {
+ if (this.pendingLastSeenFlush) {
+ clearTimeout(this.pendingLastSeenFlush)
+ this.pendingLastSeenFlush = null
+ }
}
private load(): void {
@@ -243,5 +300,7 @@ export class DeviceRegistry {
private save(devices: DeviceEntry[]): void {
writeSecureJsonFile(this.registryPath, devices)
+ // Why: every registry save includes the latest in-memory timestamps, so a later timer would rewrite it.
+ this.cancelPendingLastSeenFlush()
}
}
diff --git a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts
new file mode 100644
index 00000000000..35225f72f9c
--- /dev/null
+++ b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts
@@ -0,0 +1,209 @@
+// Why: the E2EE auth handshake used to persist `lastSeenAt` inline, and on Windows every secure-file
+// write blocks the main thread on two synchronous PowerShell ACL spawns (~1-1.5s cold each). These tests
+// pin the spawn count on the auth critical path, not wall-clock, so they are deterministic under load.
+import { execFile, execFileSync } from 'node:child_process'
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { WebSocket } from 'ws'
+import { DEVICE_REGISTRY_FILENAME } from '../mobile-pairing-files'
+import { DeviceRegistry, type DeviceEntry } from '../device-registry'
+import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './e2ee-crypto'
+import { MobileSocketWiring, type MobileSocketTransport } from './mobile-socket-wiring'
+
+vi.mock('node:child_process', () => ({
+ execFileSync: vi.fn(),
+ execFile: vi.fn()
+}))
+
+// Why: stands in for the PowerShell cold start; long enough that a gated response would be obvious,
+// short enough that the suite stays fast. Assertions use the recorded ordering, never this number.
+const INJECTED_SPAWN_LATENCY_MS = 5
+const POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
+
+type TimelineEntry = 'acl-spawn' | 'e2ee_ready' | 'e2ee_authenticated' | 'other-frame'
+
+class FakeSocket {
+ readonly OPEN = 1
+ readyState = this.OPEN
+ bufferedAmount = 0
+ readonly sent: (string | Buffer)[] = []
+ readonly close = vi.fn()
+ send: (data: string | Buffer) => void = (data) => {
+ this.sent.push(data)
+ }
+}
+
+class FakeTransport implements MobileSocketTransport {
+ private messageHandler: Parameters[0] | null = null
+ readonly setClientId = vi.fn()
+ readonly terminateClientConnections = vi.fn(() => 0)
+
+ onMessage(handler: Parameters[0]): void {
+ this.messageHandler = handler
+ }
+
+ onConnectionClose(): void {}
+
+ receive(ws: FakeSocket, message: string): void {
+ this.messageHandler?.(message, vi.fn(), ws as unknown as WebSocket)
+ }
+}
+
+describe('mobile auth critical path', () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ const originalSystemRoot = process.env.SystemRoot
+ const timeline: TimelineEntry[] = []
+ let userDataPath = ''
+
+ beforeEach(() => {
+ timeline.length = 0
+ process.env.SystemRoot = 'C:\\Windows'
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ userDataPath = mkdtempSync(join(tmpdir(), 'orca-auth-acl-'))
+ vi.mocked(execFileSync).mockReset()
+ vi.mocked(execFile).mockReset()
+ vi.mocked(execFileSync).mockImplementation((file) => {
+ if (String(file).endsWith('whoami.exe')) {
+ return '"USER","S-1-5-21-1000"'
+ }
+ timeline.push('acl-spawn')
+ // Why: the real spawn blocks the main thread, so the fake must too — and via Atomics, not a
+ // Date.now() spin, which would never terminate under fake timers.
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, INJECTED_SPAWN_LATENCY_MS)
+ return ''
+ })
+ vi.mocked(execFile).mockImplementation((_file, _args, _options, callback) => {
+ if (typeof callback === 'function') {
+ callback(null, '', '')
+ }
+ return {} as ReturnType
+ })
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ if (originalPlatform) {
+ Object.defineProperty(process, 'platform', originalPlatform)
+ }
+ if (originalSystemRoot === undefined) {
+ delete process.env.SystemRoot
+ } else {
+ process.env.SystemRoot = originalSystemRoot
+ }
+ rmSync(userDataPath, { recursive: true, force: true })
+ })
+
+ function authenticate(
+ registry: DeviceRegistry,
+ device: DeviceEntry
+ ): { ws: FakeSocket; sharedKey: Uint8Array } {
+ const desktop = generateKeyPair()
+ const phone = generateKeyPair()
+ const ws = new FakeSocket()
+ const sharedKey = deriveSharedKey(phone.secretKey, desktop.publicKey)
+ ws.send = vi.fn((data: string | Buffer) => {
+ ws.sent.push(data)
+ const text = data.toString()
+ const plaintext = text.startsWith('{') ? text : (decrypt(text, sharedKey) ?? '')
+ const type = (JSON.parse(plaintext || '{}') as { type?: string }).type
+ timeline.push(type === 'e2ee_ready' || type === 'e2ee_authenticated' ? type : 'other-frame')
+ })
+ const transport = new FakeTransport()
+ const wiring = new MobileSocketWiring({
+ deviceRegistry: registry,
+ e2eeKeypair: {
+ publicKey: desktop.publicKey,
+ secretKey: desktop.secretKey,
+ publicKeyB64: Buffer.from(desktop.publicKey).toString('base64')
+ },
+ onText: vi.fn(),
+ onBinary: vi.fn(),
+ onClose: vi.fn()
+ })
+ wiring.attachTransport(transport)
+ transport.receive(
+ ws,
+ JSON.stringify({
+ type: 'e2ee_hello',
+ publicKeyB64: Buffer.from(phone.publicKey).toString('base64')
+ })
+ )
+ transport.receive(
+ ws,
+ encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: device.token }), sharedKey)
+ )
+ return { ws, sharedKey }
+ }
+
+ function readPersistedDevices(): DeviceEntry[] {
+ return JSON.parse(
+ readFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), 'utf-8')
+ ) as DeviceEntry[]
+ }
+
+ it('emits e2ee_authenticated without spawning a single ACL process', () => {
+ const registry = new DeviceRegistry(userDataPath)
+ const device = registry.addDevice('Phone', 'runtime')
+ // Why: a re-connecting device is the measured case; the first sighting is covered below.
+ registry.updateLastSeen(device.deviceId)
+ timeline.length = 0
+
+ authenticate(registry, device)
+
+ expect(timeline).toEqual(['e2ee_ready', 'e2ee_authenticated'])
+ expect(timeline.indexOf('acl-spawn')).toBe(-1)
+
+ registry.flushPendingLastSeen()
+ // Hardening is deferred, never dropped: tmp file + published file, exactly as the inline path did.
+ expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2)
+ expect(vi.mocked(execFileSync).mock.lastCall?.[0]).toBe(POWERSHELL)
+ expect(readPersistedDevices()[0]?.lastSeenAt).toBe(
+ registry.getDevice(device.deviceId)?.lastSeenAt
+ )
+ })
+
+ it('still persists the first sighting before authenticating', () => {
+ const registry = new DeviceRegistry(userDataPath)
+ const device = registry.addDevice('Phone', 'runtime')
+ timeline.length = 0
+
+ authenticate(registry, device)
+
+ // Why: rotatePendingDevice drops entries disk says were never scanned, so this write stays inline.
+ expect(timeline).toEqual(['e2ee_ready', 'acl-spawn', 'acl-spawn', 'e2ee_authenticated'])
+ expect(readPersistedDevices()[0]?.lastSeenAt).toBeGreaterThan(0)
+ })
+
+ it('coalesces a reconnect burst into one deferred write', () => {
+ vi.useFakeTimers()
+ const registry = new DeviceRegistry(userDataPath)
+ const device = registry.addDevice('Phone', 'runtime')
+ registry.updateLastSeen(device.deviceId)
+ timeline.length = 0
+
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ authenticate(registry, device)
+ }
+
+ expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(0)
+ vi.advanceTimersByTime(250)
+ expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2)
+ })
+
+ it('cancels the deferred rewrite when another registry save persists the timestamp', () => {
+ vi.useFakeTimers()
+ const registry = new DeviceRegistry(userDataPath)
+ const device = registry.addDevice('Phone', 'runtime')
+ registry.updateLastSeen(device.deviceId)
+ timeline.length = 0
+
+ registry.updateLastSeenDeferred(device.deviceId)
+ registry.addDevice('Other client', 'runtime')
+ expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2)
+
+ vi.advanceTimersByTime(250)
+ expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2)
+ })
+})
diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts
index ac2d925093d..505cf63feb6 100644
--- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts
+++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts
@@ -66,7 +66,8 @@ function registryFor(
lastSeenAt: 0
}
: null,
- updateLastSeen: vi.fn()
+ updateLastSeen: vi.fn(),
+ updateLastSeenDeferred: vi.fn()
} as unknown as DeviceRegistry
}
diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts
index 0ca490c1a56..43004be4582 100644
--- a/src/main/runtime/rpc/mobile-socket-wiring.ts
+++ b/src/main/runtime/rpc/mobile-socket-wiring.ts
@@ -170,7 +170,8 @@ export class MobileSocketWiring {
}
this.authenticatedSockets.set(ws, socket)
transport.setClientId(ws, device.deviceToken)
- this.deviceRegistry.updateLastSeen(device.deviceId)
+ // Why: deferred — the client's e2ee_authenticated must not wait on a secure-file rewrite.
+ this.deviceRegistry.updateLastSeenDeferred(device.deviceId)
this.onReady?.(socket)
},
onError: (code, reason) => {
diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts
index 55d03efbd50..4056d0afd88 100644
--- a/src/main/runtime/runtime-rpc.test.ts
+++ b/src/main/runtime/runtime-rpc.test.ts
@@ -438,6 +438,58 @@ describe('OrcaRuntimeRpcServer', () => {
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_second_instance' })
})
+ it('flushes a lastSeen refresh scheduled while transports stop', async () => {
+ const server = new OrcaRuntimeRpcServer({
+ runtime: new OrcaRuntimeService(),
+ userDataPath: mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')),
+ enableWebSocket: false
+ })
+ let pending = false
+ const timeline: string[] = []
+ server['deviceRegistry'] = {
+ flushPendingLastSeen: vi.fn(() => {
+ timeline.push(pending ? 'flush-pending' : 'flush-empty')
+ pending = false
+ })
+ } as unknown as DeviceRegistry
+ let finishSecondStop: () => void = () => {}
+ const secondStop = new Promise((resolve) => {
+ finishSecondStop = resolve
+ })
+ server['activeTransports'] = [
+ {
+ start: vi.fn(async () => {}),
+ stop: vi.fn(async () => {
+ timeline.push('failed-transport-stop')
+ throw new Error('transport stop failed')
+ })
+ },
+ {
+ start: vi.fn(async () => {}),
+ stop: vi.fn(async () => {
+ timeline.push('second-transport-started')
+ await secondStop
+ timeline.push('second-transport-stopped')
+ pending = true
+ })
+ }
+ ]
+
+ const stopping = server.stop()
+ await vi.waitFor(() => expect(timeline).toContain('second-transport-started'))
+ expect(timeline).not.toContain('flush-empty')
+ finishSecondStop()
+ await expect(stopping).rejects.toThrow('transport stop failed')
+
+ expect(timeline).toEqual([
+ 'failed-transport-stop',
+ 'second-transport-started',
+ 'second-transport-stopped',
+ 'flush-pending'
+ ])
+ expect(pending).toBe(false)
+ })
+
it('creates a pairing offer for the active WebSocket transport', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts
index 7056f4bfe8f..b6cb7ecf259 100644
--- a/src/main/runtime/runtime-rpc.ts
+++ b/src/main/runtime/runtime-rpc.ts
@@ -1166,6 +1166,8 @@ export class OrcaRuntimeRpcServer {
// Why: WebSocket uses per-device tokens + E2EE (tweetnacl) instead of TLS since React Native can't pin self-signed certs.
if (this.enableWebSocket) {
+ // Why: land any deferred lastSeen write before a replacement registry reads the same file.
+ this.deviceRegistry?.flushPendingLastSeen()
const pairingIdentity = this.initializePairingIdentity()
if (!pairingIdentity.ok) {
this.deviceRegistry = null
@@ -1497,10 +1499,15 @@ export class OrcaRuntimeRpcServer {
this.metadataOwnershipWatch = null
this.mobileSocketWiring = null
this.detachWebSocketWiring = null
- if (transports.length === 0) {
- return
+ const stopResults = await Promise.allSettled(
+ transports.map(async (transport) => transport.stop())
+ )
+ // Why: before-quit fences relay input; direct auth can still refresh lastSeen while these transports close.
+ this.deviceRegistry?.flushPendingLastSeen()
+ const failedStop = stopResults.find((result) => result.status === 'rejected')
+ if (failedStop?.status === 'rejected') {
+ throw failedStop.reason
}
- await Promise.all(transports.map((t) => t.stop()))
// Why: leave the metadata file on shutdown — shared userData may host another live runtime whose bootstrap file we'd erase.
}
diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts
index 8cc5c14046e..bd424cd0852 100644
--- a/src/shared/remote-runtime-client.ts
+++ b/src/shared/remote-runtime-client.ts
@@ -21,6 +21,7 @@ import {
type RuntimeOrchestrationEnvelope,
type RuntimeRpcResponse
} from './runtime-rpc-envelope'
+import type { RuntimeStatus } from './runtime-types'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version'
// Re-export so existing value importers of `RemoteRuntimeClientError` are
// unaffected; the class lives in a ws-free module so type-only consumers
@@ -79,12 +80,41 @@ export type RemoteRuntimeSubscriptionCallbacks = {
onClose?: () => void
}
-export async function sendRemoteRuntimeRequest(
+export function sendRemoteRuntimeRequest(
pairing: PairingOffer,
method: string,
params: unknown,
timeoutMs: number,
envelope?: RuntimeOrchestrationEnvelope
+): Promise> {
+ return sendRemoteRuntimeRequestOnSocket(pairing, method, params, timeoutMs, envelope)
+}
+
+export function sendRemoteRuntimeRequestWithStatusPreflight(
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ timeoutMs: number,
+ validateStatus: (response: RuntimeRpcResponse) => void,
+ envelope?: RuntimeOrchestrationEnvelope
+): Promise> {
+ return sendRemoteRuntimeRequestOnSocket(
+ pairing,
+ method,
+ params,
+ timeoutMs,
+ envelope,
+ validateStatus
+ )
+}
+
+async function sendRemoteRuntimeRequestOnSocket(
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ timeoutMs: number,
+ envelope?: RuntimeOrchestrationEnvelope,
+ validateStatus?: (response: RuntimeRpcResponse) => void
): Promise> {
if (!isSafeTimerDelayMs(timeoutMs)) {
throw new RemoteRuntimeClientError(
@@ -93,6 +123,14 @@ export async function sendRemoteRuntimeRequest(
)
}
const requestId = randomUUID()
+ const statusRequestId = validateStatus ? randomUUID() : null
+ const serializedStatusRequest = statusRequestId
+ ? serializeRemoteRuntimePayload({
+ id: statusRequestId,
+ deviceToken: pairing.deviceToken,
+ method: 'status.get'
+ })
+ : null
const serializedAuth = serializeRemoteRuntimePayload({
type: 'e2ee_auth',
deviceToken: pairing.deviceToken,
@@ -114,6 +152,8 @@ export async function sendRemoteRuntimeRequest(
)
}
let serializedRequest = takeRemoteRuntimePreparedRequest(pendingRequest)
+ let awaitingRequestId = statusRequestId ?? requestId
+ let awaitingStatus = statusRequestId !== null
return await new Promise>((resolve, reject) => {
const keyPair = generateKeyPair()
const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64)
@@ -359,6 +399,14 @@ export async function sendRemoteRuntimeRequest(
return
}
state = 'ready'
+ if (serializedStatusRequest) {
+ ws?.send(encrypt(serializedStatusRequest, sharedKey))
+ return
+ }
+ sendRequestedRpc()
+ }
+
+ function sendRequestedRpc(): void {
const request = serializedRequest
serializedRequest = null
if (request === null) {
@@ -405,8 +453,7 @@ export async function sendRemoteRuntimeRequest(
})
return
}
- const response = parsed.data as RuntimeRpcResponse
- if (response.id !== requestId) {
+ if (parsed.data.id !== awaitingRequestId) {
finish({
ok: false,
error: new RemoteRuntimeClientError(
@@ -417,6 +464,26 @@ export async function sendRemoteRuntimeRequest(
})
return
}
+ if (awaitingStatus && validateStatus) {
+ try {
+ validateStatus(parsed.data as RuntimeRpcResponse)
+ } catch (error) {
+ finish({
+ ok: false,
+ error:
+ error instanceof Error
+ ? error
+ : new RemoteRuntimeClientError('runtime_error', String(error))
+ })
+ return
+ }
+ awaitingStatus = false
+ awaitingRequestId = requestId
+ refreshTimeout()
+ sendRequestedRpc()
+ return
+ }
+ const response = parsed.data as RuntimeRpcResponse