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} + + + + + +
+
+ ) +} + +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} + + + + +
+
+ ) +} 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 ( + + ) + } + 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 ( + + + + + + 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 ( + + + + + + { + 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} + +
+ ) : 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 ? ( + + ) : 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 ? ( + + ) : null} + {selectedAutomationRunPageViewState ? ( + + ) : 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) => ( + + ))} + +
+ ) : 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} - - - - - -
-
+ 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} - - - - -
-
+ 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 ( - - - - - - { - 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 ( - + void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + requestExternalAction={requestExternalAction} + openEditExternalDialog={openEditExternalDialog} + openCreateDialog={openCreateDialog} + /> + + - - - - - 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) => ( - - ))} - -
- ) : null} -
-
- -
- {selectedExternal ? ( -
- {selectedExternalRunPage ? ( - setSelectedExternalRunPage(null)} - > - - - ) : selectedExternal.kind === 'job' ? ( - - ) : ( -
-
-
-
- {selectedExternal.manager.targetLabel} -
-
- {selectedExternalSourceAvailability?.summary} -
-
- {selectedExternalSshSource ? ( - - ) : 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 ? ( - - ) : null} - {selectedAutomationRunPageViewState ? ( - - ) : 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. */} 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 ? ( - - ) : ( - - )} -
- - - - {showReconnect && ( - - )} - -
-
- ) -} 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 ( + + + + + + {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) ? ( ) : status === 'connected' ? ( )} 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 ( +

+ +
+ 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. */} + + + + + + + {ISSUE_LINK_PROVIDERS.map((item) => ( + + + {providerLabel(item)} + + ))} + + + + + + + + + {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 ( + + ) + } + } +}) + +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() {

-
- -
- setIssueInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder={translate( - 'auto.components.sidebar.WorktreeMetaDialog.741279e7b7', - 'Issue # or GitHub URL' - )} - className="h-8 pr-9 text-xs" - /> - - - - - - {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.' - )} -

-
+
+ {saveError ? ( +

+ {saveError} +

+ ) : null} +