From e69cafd4dd4782fe42a67e7ea4a6e33abfb69b72 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 21:39:43 -0800 Subject: [PATCH] refactor: organize dashboard and popout modules by owner --- config/direct-link-open-baseline.txt | 2 +- config/react-compiler-bailout-baseline.txt | 6 +- config/reliability-gates.jsonc | 14 +- config/renderer-layout-writer-baseline.txt | 2 +- config/workspace-record-read-baseline.txt | 10 +- .../src/app-shell/AppBackgroundServices.tsx | 6 +- .../src/app-shell/use-global-keybindings.ts | 2 +- .../activity/activity-thread-child-agent.ts | 2 +- .../AgentKanbanBoard.test.tsx | 116 ----- .../AgentKanbanLaneCardList.test.tsx | 126 ------ .../AgentTerminalDialog.test.tsx | 125 ------ ...tTerminalPreview.clipboard-routes.test.tsx | 234 ----------- ...ntTerminalPreview.frame-lifecycle.test.tsx | 229 ---------- ...TerminalPreview.settings-geometry.test.tsx | 167 -------- ...TerminalPreview.settings-lifetime.test.tsx | 178 -------- .../dashboard-popout/DashboardPopoutRoot.tsx | 6 +- .../dashboard-preview-dom-test-fixture.ts | 123 ------ ...ashboard-preview-link-composition.test.tsx | 322 -------------- ...preview-terminal-box-fit-lifecycle.test.ts | 104 ----- ...ew-terminal-ime-bridge-kitty-bytes.test.ts | 168 -------- .../dashboard-agent-status-patch.ts | 14 +- .../{ => snapshot}/useDashboardSnapshot.ts | 4 +- .../AgentDashboardDrawer.focus.test.tsx | 192 --------- .../dashboard/AgentDashboardDrawer.test.tsx | 101 ----- .../dashboard/AgentDashboardDrawer.tsx | 12 +- .../dashboard/DashboardAgentRow.tsx | 14 +- ...estination-navigation.integration.test.tsx | 396 ------------------ .../board}/AgentCardLastMessages.tsx | 4 +- .../board}/AgentDashboardFilterChips.tsx | 0 .../board}/AgentDashboardToolbar.tsx | 7 +- .../board}/AgentKanbanBoard.tsx | 9 +- .../board}/AgentKanbanCard.tsx | 4 +- .../board}/AgentKanbanLaneCardList.tsx | 4 +- .../board}/DashboardHostBadge.tsx | 4 +- .../board}/FilterOptionCount.tsx | 0 .../board}/agent-board-filtering.ts | 2 +- .../board}/agent-board-transitions.css | 0 .../board}/agent-dashboard-filter-options.ts | 2 +- .../board}/agent-kanban-lane-tab-bridge.ts | 0 .../board}/use-agent-dashboard-telemetry.ts | 2 +- .../use-agent-kanban-lane-virtualizer.ts | 2 +- ...ashboard-bucket-counts.equivalence.test.ts | 187 +++++++++ .../build-dashboard-bucket-counts.ts | 18 +- .../useAgentBucketCounts.ts | 4 +- ...ashboard-bucket-counts.equivalence.test.ts | 319 -------------- .../build-dashboard-snapshot.test.ts | 391 ----------------- ...estination-navigation.integration.test.tsx | 163 +++++++ .../reveal-dashboard-agent.test.ts | 21 +- .../reveal-dashboard-agent.ts | 4 +- .../DashboardPopoutBridge.tsx | 0 .../useDashboardPopoutBridge.ts | 13 +- .../RetainedAgentsSyncGate.tsx | 0 .../retention/useRetainedAgents.test.ts | 230 ++++++++++ .../{ => retention}/useRetainedAgents.ts | 24 +- .../DashboardAgentChildDisclosure.tsx | 0 .../{ => rows}/DashboardAgentRowMessage.tsx | 0 .../{ => rows}/DashboardAgentRowToolStep.tsx | 0 .../DashboardAgentRowTrailingControls.tsx | 0 .../{ => rows}/agent-finished-timestamp.ts | 6 +- .../agent-row-lineage-model.test.ts | 16 +- .../{ => rows}/agent-row-lineage-model.ts | 2 +- .../dashboard/{ => rows}/agent-row-lineage.ts | 2 +- .../{ => rows}/agent-row-pane-live-title.ts | 4 +- .../dashboard-agent-row.ts} | 8 +- .../{ => rows}/dashboard-row-bucket.ts | 8 +- .../use-agent-row-conversation-name.ts | 8 +- .../{ => rows}/use-claude-agent-row-view.ts | 4 +- .../{ => rows}/worktree-agent-rows-cache.ts | 14 +- .../AgentDashboardExperimentalSetting.tsx | 10 +- .../AgentDashboardSettingsMenu.tsx | 6 +- .../agent-dashboard-open-mode.test.ts | 8 +- .../agent-dashboard-open-mode.ts | 4 +- .../snapshot/build-dashboard-snapshot.test.ts | 196 +++++++++ .../build-dashboard-snapshot.ts | 28 +- .../{ => snapshot}/dashboard-card-bucket.ts | 2 +- .../{ => snapshot}/dashboard-card-context.ts | 20 +- .../{ => snapshot}/dashboard-card-labels.ts | 12 +- .../dashboard-card-terminal-input.ts | 11 +- .../{ => snapshot}/dashboard-client-host.ts | 0 .../dashboard-orchestration-selection.ts | 6 +- .../dashboard-snapshot-filter-options.ts | 4 +- .../dashboard-snapshot-workspaces.ts | 14 +- .../dashboard-subagent-cards.ts | 6 +- .../useLiveDashboardSnapshot.ts | 6 +- .../terminal-preview}/AgentTerminalDialog.tsx | 11 +- .../AgentTerminalPreview.tsx | 28 +- .../agent-terminal-preview-target.ts | 0 .../geometry}/preview-grid-claim.ts | 4 +- .../geometry}/preview-terminal-box-fit.ts | 0 .../links}/dashboard-preview-http-actions.ts | 8 +- .../dashboard-preview-link-interaction.ts | 6 +- .../terminal-preview-unavailable-message.ts | 6 +- .../preview-terminal-snapshot-replay.ts | 8 +- .../remote-runtime-terminal-preview-api.ts | 6 +- .../transport}/terminal-preview-api.ts | 2 +- .../dashboard/useRetainedAgents.test.ts | 389 ----------------- .../ReviewNotesSendMenuContent.tsx | 6 +- .../review-notes-send-target-order.ts | 2 +- ...native-chat-agent-message-sender-source.ts | 2 +- .../PlainSshTerminalView.tsx | 4 +- .../settings/advanced/ExperimentalPane.tsx | 2 +- .../sidebar/AgentDashboardSidebarEntry.tsx | 4 +- .../activity/WorkerLineage.fixture.tsx | 2 +- .../activity/WorktreeCardAgents.tsx | 2 +- ...mirrored-terminal-lineage.test-fixture.tsx | 359 ---------------- .../activity/use-worktree-agent-list-model.ts | 4 +- ...worktree-agent-rows-subscriptions.test.tsx | 2 +- .../activity/worktree-agent-row-order.ts | 2 +- .../worktree-agent-rows-projection.ts | 4 +- .../activity/worktree-agent-rows.ts | 2 +- .../worktree-card-agent-ack-inputs.ts | 4 +- .../worktree-card-agent-summary.test.ts | 2 +- .../activity/worktree-card-agent-summary.ts | 2 +- .../worktree-card-compact-agent-line-order.ts | 2 +- ...-compact-agent-row.stable-message.test.tsx | 4 +- .../worktree-card-compact-agent-row.tsx | 10 +- .../activity/worktree-card-compact-agents.tsx | 2 +- .../worktree-card-idle-child-agents.test.ts | 2 +- .../worktree-card-idle-child-agents.tsx | 2 +- .../activity/worktree-subagent-child-rows.ts | 2 +- .../worktree-title-derived-agent-rows.ts | 2 +- .../surface/worktree-card-title-status.tsx | 2 +- .../worktree-card-slot-agent-signals.ts | 4 +- .../sidebar/worktree-card-slot-rollup.ts | 2 +- .../sidebar/worktree-card-slot-signal.ts | 4 +- .../preview-terminal-app-menu-clipboard.ts | 0 .../input}/preview-terminal-ime-bridge.ts | 0 .../input}/preview-terminal-key-handler.ts | 4 +- .../input}/preview-terminal-paste.ts | 2 +- .../preview-terminal-right-click-paste.ts | 0 .../input}/preview-terminal-shortcuts.ts | 12 +- .../open-standalone-terminal.ts | 14 +- .../preview-terminal-compatibility.ts | 4 +- .../rendering}/preview-terminal-ligatures.ts | 4 +- .../rendering}/preview-terminal-links.ts | 2 +- .../rendering}/preview-terminal-options.ts | 12 +- .../components/terminal-workspace-keydown.ts | 2 +- .../ipc-events/agent-dashboard-command.ts | 2 +- .../navigation/claude-agent-row-view.test.ts | 2 +- .../navigation/claude-agent-row-view.ts | 2 +- ...ync-host-tab-retraction-ghost-rows.test.ts | 349 --------------- 141 files changed, 1086 insertions(+), 4730 deletions(-) delete mode 100644 src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentKanbanLaneCardList.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/dashboard-preview-dom-test-fixture.ts delete mode 100644 src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts rename src/renderer/src/components/dashboard-popout/{ => snapshot}/dashboard-agent-status-patch.ts (83%) rename src/renderer/src/components/dashboard-popout/{ => snapshot}/useDashboardSnapshot.ts (97%) delete mode 100644 src/renderer/src/components/dashboard/AgentDashboardDrawer.focus.test.tsx delete mode 100644 src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx delete mode 100644 src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx rename src/renderer/src/components/{dashboard-popout => dashboard/board}/AgentCardLastMessages.tsx (88%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/AgentDashboardFilterChips.tsx (100%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/AgentDashboardToolbar.tsx (97%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/AgentKanbanBoard.tsx (97%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/AgentKanbanCard.tsx (99%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/AgentKanbanLaneCardList.tsx (97%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/DashboardHostBadge.tsx (95%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/FilterOptionCount.tsx (100%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/agent-board-filtering.ts (98%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/agent-board-transitions.css (100%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/agent-dashboard-filter-options.ts (98%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/agent-kanban-lane-tab-bridge.ts (100%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/use-agent-dashboard-telemetry.ts (92%) rename src/renderer/src/components/{dashboard-popout => dashboard/board}/use-agent-kanban-lane-virtualizer.ts (97%) create mode 100644 src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.equivalence.test.ts rename src/renderer/src/components/dashboard/{ => bucket-counts}/build-dashboard-bucket-counts.ts (89%) rename src/renderer/src/components/dashboard/{ => bucket-counts}/useAgentBucketCounts.ts (97%) delete mode 100644 src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts delete mode 100644 src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts create mode 100644 src/renderer/src/components/dashboard/navigation/agent-destination-navigation.integration.test.tsx rename src/renderer/src/components/dashboard/{ => navigation}/reveal-dashboard-agent.test.ts (72%) rename src/renderer/src/components/dashboard/{ => navigation}/reveal-dashboard-agent.ts (91%) rename src/renderer/src/components/dashboard/{ => popout-bridge}/DashboardPopoutBridge.tsx (100%) rename src/renderer/src/components/dashboard/{ => popout-bridge}/useDashboardPopoutBridge.ts (95%) rename src/renderer/src/components/dashboard/{ => retention}/RetainedAgentsSyncGate.tsx (100%) create mode 100644 src/renderer/src/components/dashboard/retention/useRetainedAgents.test.ts rename src/renderer/src/components/dashboard/{ => retention}/useRetainedAgents.ts (91%) rename src/renderer/src/components/dashboard/{ => rows}/DashboardAgentChildDisclosure.tsx (100%) rename src/renderer/src/components/dashboard/{ => rows}/DashboardAgentRowMessage.tsx (100%) rename src/renderer/src/components/dashboard/{ => rows}/DashboardAgentRowToolStep.tsx (100%) rename src/renderer/src/components/dashboard/{ => rows}/DashboardAgentRowTrailingControls.tsx (100%) rename src/renderer/src/components/dashboard/{ => rows}/agent-finished-timestamp.ts (84%) rename src/renderer/src/components/dashboard/{ => rows}/agent-row-lineage-model.test.ts (83%) rename src/renderer/src/components/dashboard/{ => rows}/agent-row-lineage-model.ts (97%) rename src/renderer/src/components/dashboard/{ => rows}/agent-row-lineage.ts (97%) rename src/renderer/src/components/dashboard/{ => rows}/agent-row-pane-live-title.ts (72%) rename src/renderer/src/components/dashboard/{useDashboardData.ts => rows/dashboard-agent-row.ts} (68%) rename src/renderer/src/components/dashboard/{ => rows}/dashboard-row-bucket.ts (87%) rename src/renderer/src/components/dashboard/{ => rows}/use-agent-row-conversation-name.ts (89%) rename src/renderer/src/components/dashboard/{ => rows}/use-claude-agent-row-view.ts (95%) rename src/renderer/src/components/dashboard/{ => rows}/worktree-agent-rows-cache.ts (92%) rename src/renderer/src/components/dashboard/{ => settings}/AgentDashboardExperimentalSetting.tsx (90%) rename src/renderer/src/components/dashboard/{ => settings}/AgentDashboardSettingsMenu.tsx (96%) rename src/renderer/src/components/dashboard/{ => settings}/agent-dashboard-open-mode.test.ts (64%) rename src/renderer/src/components/dashboard/{ => settings}/agent-dashboard-open-mode.ts (77%) create mode 100644 src/renderer/src/components/dashboard/snapshot/build-dashboard-snapshot.test.ts rename src/renderer/src/components/dashboard/{ => snapshot}/build-dashboard-snapshot.ts (89%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-card-bucket.ts (88%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-card-context.ts (77%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-card-labels.ts (80%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-card-terminal-input.ts (94%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-client-host.ts (100%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-orchestration-selection.ts (83%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-snapshot-filter-options.ts (85%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-snapshot-workspaces.ts (92%) rename src/renderer/src/components/dashboard/{ => snapshot}/dashboard-subagent-cards.ts (81%) rename src/renderer/src/components/dashboard/{ => snapshot}/useLiveDashboardSnapshot.ts (96%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview}/AgentTerminalDialog.tsx (95%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview}/AgentTerminalPreview.tsx (91%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview}/agent-terminal-preview-target.ts (100%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview/geometry}/preview-grid-claim.ts (92%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview/geometry}/preview-terminal-box-fit.ts (100%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview/links}/dashboard-preview-http-actions.ts (94%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview/links}/dashboard-preview-link-interaction.ts (96%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview}/terminal-preview-unavailable-message.ts (79%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview/transport}/preview-terminal-snapshot-replay.ts (80%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview/transport}/remote-runtime-terminal-preview-api.ts (98%) rename src/renderer/src/components/{dashboard-popout => dashboard/terminal-preview/transport}/terminal-preview-api.ts (79%) delete mode 100644 src/renderer/src/components/dashboard/useRetainedAgents.test.ts delete mode 100644 src/renderer/src/components/sidebar/workspace-card/activity/mirrored-terminal-lineage.test-fixture.tsx rename src/renderer/src/components/{dashboard-popout => standalone-terminal/input}/preview-terminal-app-menu-clipboard.ts (100%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/input}/preview-terminal-ime-bridge.ts (100%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/input}/preview-terminal-key-handler.ts (98%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/input}/preview-terminal-paste.ts (97%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/input}/preview-terminal-right-click-paste.ts (100%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/input}/preview-terminal-shortcuts.ts (89%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/rendering}/preview-terminal-compatibility.ts (94%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/rendering}/preview-terminal-ligatures.ts (85%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/rendering}/preview-terminal-links.ts (95%) rename src/renderer/src/components/{dashboard-popout => standalone-terminal/rendering}/preview-terminal-options.ts (87%) delete mode 100644 src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts diff --git a/config/direct-link-open-baseline.txt b/config/direct-link-open-baseline.txt index 2676e9c9328..02f0bc88674 100644 --- a/config/direct-link-open-baseline.txt +++ b/config/direct-link-open-baseline.txt @@ -15,7 +15,7 @@ 1 src/renderer/src/components/browser-pane/client-hosted-browser-load-failure.tsx 2 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx 1 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx -1 src/renderer/src/components/dashboard-popout/preview-terminal-links.ts +1 src/renderer/src/components/standalone-terminal/rendering/preview-terminal-links.ts 1 src/renderer/src/components/jira-connect-credential-guide.tsx 2 src/renderer/src/components/linear-api-key-dialog.tsx 1 src/renderer/src/components/native-chat/media/images/NativeChatMarkdownImage.tsx diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index 923bd4d2bde..8e4782ddcb6 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -76,9 +76,9 @@ 1 src/renderer/src/components/crash-report/CrashReportDialog.tsx 1 src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx 3 src/renderer/src/components/crash-report/use-crash-report-copy.ts -1 src/renderer/src/components/dashboard-popout/use-agent-kanban-lane-virtualizer.ts -1 src/renderer/src/components/dashboard/useAgentBucketCounts.ts -1 src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +1 src/renderer/src/components/dashboard/board/use-agent-kanban-lane-virtualizer.ts +1 src/renderer/src/components/dashboard/bucket-counts/useAgentBucketCounts.ts +1 src/renderer/src/components/dashboard/snapshot/useLiveDashboardSnapshot.ts 2 src/renderer/src/components/diff-comments/DiffCommentCard.tsx 5 src/renderer/src/components/diff-comments/DiffCommentPopover.tsx 2 src/renderer/src/components/diff-comments/diff-comment-draft-zone.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 17cf1049340..371aedfe5ae 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -9775,7 +9775,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-macos-keybinding-dict-trace.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-linux-fcitx4-trace-replay.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-xterm-resumed-preedit-visibility.test.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-composer-placeholder-mask.test.ts src/shared/terminal/input/terminal-composer-draft.test.ts src/main/daemon/emulation/testing/terminal-cursor-line-context.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/main/daemon/providers/adapter/testing/daemon-pty-adapter.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/main/daemon/providers/adapter/testing/daemon-pty-adapter.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/keyboard/terminal-option-kitty-release.test.ts src/main/ipc/macos-keyboard-layout-change-notifications.test.ts src/renderer/src/web/web-preload-api-ui.test.ts", "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts", "pnpm run test:e2e -- tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts", @@ -9793,7 +9793,6 @@ "src/renderer/src/components/terminal-pane/input/keyboard/terminal-option-kitty-release.test.ts", "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts", "src/renderer/src/web/web-preload-api-ui.test.ts", - "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts", "src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts", "src/main/ipc/terminal-preview-output-stream.test.ts", "src/main/daemon/providers/adapter/testing/daemon-pty-adapter.test.ts", @@ -9835,17 +9834,6 @@ "rapid input-source changes coalesce to one latest-generation refresh signal" ] }, - { - "file": "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts", - "assertions": [ - "the real forwarder behind the Preview bridge encodes the commit from the snapshot-proven flags before any live output", - "flags 0/1 commit raw text, flags 2 add one CSI-u release, flags 8 emit one CSI-u press, and flags 10 emit a CSI-u press followed by exactly one release", - "both keydown->insertText->keyup and keydown->keyup->insertText orderings produce the same bytes", - "the release is emitted by the forwarder while xterm's internal kitty flags stay 0", - "the mutable flags getter is read exactly once, at commit time, never on keydown or keyup", - "auto-repeat, two-key rollover, a swallowed commit, a retired claim, blur, and disposal each emit at most one release and never synthesize one" - ] - }, { "file": "src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts", "assertions": [ diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index ae7525844d7..a37b85ca88d 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -5,7 +5,7 @@ 3 src/renderer/src/attention/notification-subject-owner.ts 1 src/renderer/src/components/activity/use-agent-pane-threads.ts 3 src/renderer/src/components/cmd-j/palette-live-status.tsx -2 src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts +2 src/renderer/src/components/dashboard/rows/worktree-agent-rows-cache.ts 1 src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-activity-status.ts 1 src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-activity-statuses.ts 4 src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-send-target-inputs.ts diff --git a/config/workspace-record-read-baseline.txt b/config/workspace-record-read-baseline.txt index ff68d13d62d..7d01f621795 100644 --- a/config/workspace-record-read-baseline.txt +++ b/config/workspace-record-read-baseline.txt @@ -5,11 +5,11 @@ # fields leave the store they read undefined and silently fall back, so P5b-2 must move them. # Prune after a fix: pnpm check:workspace-record-read-ratchet --prune 3 src/renderer/src/attention/notification-subject-owner.ts # P5b-2 must clear: loosely typed state param -4 src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts # P5b-2 must clear: optional state fields; memo compare goes stale when absent -3 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts # P5b-2 must clear: optional state fields default to empty -1 src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts # P5b-2 must clear: optional state field defaults to empty -4 src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts # P5b-2 must clear: change detection on optional fields never fires when absent -2 src/renderer/src/components/dashboard/useRetainedAgents.ts # reads a passed-in copy +4 src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.ts # P5b-2 must clear: optional state fields; memo compare goes stale when absent +3 src/renderer/src/components/dashboard/snapshot/dashboard-card-terminal-input.ts # P5b-2 must clear: optional state fields default to empty +1 src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-workspaces.ts # P5b-2 must clear: optional state field defaults to empty +4 src/renderer/src/components/dashboard/popout-bridge/useDashboardPopoutBridge.ts # P5b-2 must clear: change detection on optional fields never fires when absent +2 src/renderer/src/components/dashboard/retention/useRetainedAgents.ts # reads a passed-in copy 1 src/renderer/src/components/editor/markdown/documents/markdown-document-worktree-path-selector.ts # P5b-2 must clear: optional state field 2 src/renderer/src/components/right-sidebar/file-explorer/watching/useFileExplorerWatch.ts # P5b-2 must clear: optional state fields default to empty 1 src/renderer/src/components/sidebar/project-admission/completion/add-repo-skip-finalization.ts # P5b-2 must clear: loosely typed state param diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index 6bb876961c5..6a8294aafe7 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -2,7 +2,7 @@ import { Suspense } from 'react' import { lazyWithRetry as lazy } from '@/app-shell/startup/lazy-with-retry' import { AgentHibernationGate } from '../components/AgentHibernationGate' import { AiVaultTabTitleSyncGate } from '../components/AiVaultTabTitleSyncGate' -import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGate' +import RetainedAgentsSyncGate from '../components/dashboard/retention/RetainedAgentsSyncGate' import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner' import { SavedHostScopeWidenGate } from '../components/sidebar/worktree-list/listing/SavedHostScopeWidenGate' import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost' @@ -14,7 +14,9 @@ import { StructuredAgentSessionStatusBridge } from '../components/native-chat/se import { NativeChatVisualGuestThemePublisher } from '../components/native-chat/media/visuals/NativeChatVisualGuestThemePublisher' import { usePairedMobileDevices } from '../components/mobile/paired-mobile-devices' -const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge')) +const DashboardPopoutBridge = lazy( + () => import('../components/dashboard/popout-bridge/DashboardPopoutBridge') +) const AgentListVerdictPublisher = lazy( () => import('../components/activity/agent-list-verdict-publisher') ) diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index 1155f49dd98..0a23cd094c8 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -40,7 +40,7 @@ import type { AppChromeLayout } from './use-app-chrome-layout' import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel' import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router' import { textInputOwnsComposition } from '../../../shared/text-input-shortcut-policy' -import { isAgentTerminalPreviewTarget } from '../components/dashboard-popout/agent-terminal-preview-target' +import { isAgentTerminalPreviewTarget } from '../components/dashboard/terminal-preview/agent-terminal-preview-target' /** Register window listeners once; each key event reads the latest committed shortcut state through a ref. */ export function useGlobalKeybindings(args: { diff --git a/src/renderer/src/components/activity/activity-thread-child-agent.ts b/src/renderer/src/components/activity/activity-thread-child-agent.ts index b04bdbf5e04..352c1041671 100644 --- a/src/renderer/src/components/activity/activity-thread-child-agent.ts +++ b/src/renderer/src/components/activity/activity-thread-child-agent.ts @@ -3,7 +3,7 @@ import { buildAgentRowLineageTree, resolveAgentRowParentPaneKey, type AgentLineageSourceRow -} from '../dashboard/agent-row-lineage-model' +} from '../dashboard/rows/agent-row-lineage-model' import { isRunningActivityState } from './activity-event-state' import type { ActivityLiveAgentState } from './activity-thread-types' diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx deleted file mode 100644 index 942d101997c..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import type { RepoIcon } from '../../../../shared/repo-icon' -import { i18n } from '@/i18n/i18n' -import { AgentKanbanBoard } from './AgentKanbanBoard' - -// Stub card/dialog internals while retaining the board’s dialog wiring. -vi.mock('./AgentKanbanCard', () => ({ - AgentKanbanCard: ({ - card, - repoIcon, - onOpenTerminal - }: { - card: DashboardCard - repoIcon?: RepoIcon | null - onOpenTerminal: (card: DashboardCard) => void - }) => ( -
onOpenTerminal(card)} - > - {card.worktreeName} -
- ) -})) -vi.mock('./AgentTerminalDialog', () => ({ - AgentTerminalDialog: ({ - card, - onOpenChange - }: { - card: DashboardCard | null - onOpenChange: (open: boolean) => void - }) => ( -
-
- ) -})) - -function card(overrides: Partial): DashboardCard { - return { - paneKey: Math.random().toString(36), - ptyId: 'p1', - agentType: 'claude', - bucket: 'working', - dotState: 'working', - task: 't', - repoId: 'r1', - worktreeId: 'w1', - tabId: 'tab1', - leafId: 'l1', - repoName: 'Repo', - worktreeName: 'wt', - startedAt: 0, - finishedAt: null, - stateChangedAt: 0, - unseen: false, - ...overrides - } -} - -const ackAgent = vi.fn(async () => {}) - -describe('AgentKanbanBoard', () => { - beforeEach(async () => { - await i18n.changeLanguage('en') - localStorage.clear() - vi.stubGlobal( - 'matchMedia', - vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) - ) - ;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } } - // Give virtual lanes a viewport; happy-dom has no layout. - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) - }) - afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it('keeps the terminal dialog open across bucket moves and card removal', () => { - const agent = card({ paneKey: 'pk-1', bucket: 'done', worktreeName: 'wt1' }) - const { rerender } = render( - - ) - expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('false') - - fireEvent.click(screen.getByTestId('card')) - expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true') - - // Replying moves done agents to working; the dialog must survive the bucket change. - const moved = { ...agent, bucket: 'working' as const, dotState: 'working' as const } - rerender() - expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true') - expect(screen.getByTestId('terminal-dialog').dataset.bucket).toBe('working') - - // Keep vanished-card dialogs open until dismissed, but clear stale live routing. - rerender() - expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true') - expect(screen.getByTestId('terminal-dialog').dataset.ptyId).toBeUndefined() - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanLaneCardList.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanLaneCardList.test.tsx deleted file mode 100644 index 5e30d2f446c..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanLaneCardList.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import { useState } from 'react' -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import { TooltipProvider } from '@/components/ui/tooltip' -import { i18n } from '@/i18n/i18n' -import { AgentKanbanLaneCardList } from './AgentKanbanLaneCardList' - -function laneCards(count: number): DashboardCard[] { - return Array.from({ length: count }, (_, index) => ({ - paneKey: `pane-${index}`, - ptyId: `pty-${index}`, - agentType: 'claude', - bucket: 'working', - dotState: 'working', - task: 'task', - repoId: 'r1', - worktreeId: `w${index}`, - tabId: 'tab1', - leafId: 'l1', - repoName: 'Repo', - worktreeName: `card-${String(index).padStart(2, '0')}`, - startedAt: 0, - finishedAt: null, - stateChangedAt: 0, - unseen: false, - subagents: index === 1 ? [{ id: 's1', name: 'helper', dotState: 'working' }] : undefined - })) -} - -function Lane({ cards }: { cards: DashboardCard[] }): React.JSX.Element { - const [scrollElement, setScrollElement] = useState(null) - return ( - -
- -
-
- ) -} - -function card(position: number): HTMLElement { - const item = screen - .getAllByRole('listitem') - .find((row) => row.getAttribute('aria-posinset') === String(position)) - if (!item) { - throw new Error(`Expected card ${position} to be mounted`) - } - return item -} - -function scrollTo(top: number): void { - const scroller = screen.getByTestId('scroller') - act(() => { - scroller.scrollTop = top - fireEvent.scroll(scroller) - }) -} - -describe('AgentKanbanLaneCardList', () => { - beforeEach(async () => { - await i18n.changeLanguage('en') - // Layout stub: a 300px lane of 100px cards. - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( - this: HTMLElement - ) { - return this.dataset.testid === 'scroller' ? 300 : this.hasAttribute('data-index') ? 100 : 0 - }) - }) - afterEach(() => { - cleanup() - vi.restoreAllMocks() - }) - - it('tabs to the logical neighbour card instead of the next mounted card', () => { - render() - expect(screen.queryByText('card-20')).toBeNull() - - // Card 9 is offscreen while the last card is mounted next in DOM order. - const lastControl = within(card(9)).getAllByRole('button').at(-1)! - lastControl.focus() - fireEvent.keyDown(lastControl, { key: 'Tab' }) - expect(document.activeElement).toHaveTextContent('card-09') - - const lastCardFirstControl = within(card(40)).getAllByRole('button')[0] - lastCardFirstControl.focus() - fireEvent.keyDown(lastCardFirstControl, { key: 'Tab', shiftKey: true }) - expect(document.activeElement).toBe(within(card(39)).getAllByRole('button').at(-1)) - - // Mounted logical neighbours keep native traversal. - const firstCardLastControl = within(card(1)).getAllByRole('button').at(-1)! - expect(fireEvent.keyDown(firstCardLastControl, { key: 'Tab' })).toBe(true) - }) - - it('keeps subagent disclosure while scrolled away and resets it when the card leaves the lane', () => { - const cards = laneCards(40) - const { rerender } = render() - fireEvent.click(screen.getByRole('button', { name: '1 subagent' })) - expect(screen.getByText('helper')).toBeInTheDocument() - - scrollTo(2000) - expect(screen.queryByText('card-01')).toBeNull() - scrollTo(0) - expect(screen.getByRole('button', { name: '1 subagent' })).toHaveAttribute( - 'aria-expanded', - 'true' - ) - - rerender( laneCard.paneKey !== 'pane-1')} />) - rerender() - expect(screen.getByRole('button', { name: '1 subagent' })).toHaveAttribute( - 'aria-expanded', - 'false' - ) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx deleted file mode 100644 index bffc3de2dd0..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import { useState } from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { - DashboardCard, - DashboardCardTerminalInput -} from '../../../../shared/dashboard-snapshot' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { AgentTerminalDialog, AgentTerminalPanel } from './AgentTerminalDialog' - -// Stub xterm/IPC to inspect the routing props passed to the preview. -vi.mock('./AgentTerminalPreview', () => ({ - AgentTerminalPreview: ({ - ptyId, - terminalInput, - className, - onBrowserForeground - }: { - ptyId: string - terminalInput?: DashboardCardTerminalInput | null - className?: string - onBrowserForeground?: () => void - }) => ( -
- {onBrowserForeground ? ( - - ) : null} -
- ) -})) - -function card(overrides: Partial = {}): DashboardCard { - return { - paneKey: 'tab1:leaf1', - ptyId: 'pty-1', - agentType: 'claude', - bucket: 'working', - dotState: 'working', - task: 'task', - repoId: 'r1', - worktreeId: 'w1', - tabId: 'tab1', - leafId: 'leaf1', - repoName: 'Repo', - worktreeName: 'wt', - startedAt: 0, - finishedAt: null, - stateChangedAt: 0, - unseen: false, - ...overrides - } -} - -afterEach(() => { - cleanup() -}) - -// This prop is the only path carrying relayed host-input facts into the preview; omission silently falls back to client-OS routing. -describe('AgentTerminalDialog', () => { - it('lets a nested Radix layer consume Escape before closing the panel', () => { - const onOpenChange = vi.fn() - render( - <> - {}} /> - - Details - Worktree details - - - ) - - fireEvent.keyDown(screen.getByText('Worktree details'), { key: 'Escape' }) - - expect(screen.queryByText('Worktree details')).not.toBeInTheDocument() - expect(onOpenChange).not.toHaveBeenCalled() - - fireEvent.keyDown(document.body, { key: 'Escape' }) - expect(onOpenChange).toHaveBeenCalledWith(false) - }) -}) - -it.each([false, true])( - 'restores dialog focus only without a confirmed host handoff (%s)', - async (handoff) => { - function Host() { - const [open, setOpen] = useState(false) - return ( - <> - - - {}} - onBrowserForeground={ - handoff ? () => document.getElementById('browser-destination')?.focus() : undefined - } - /> - - ) - } - render() - const launch = screen.getByRole('button', { name: 'Launch preview' }) - launch.focus() - fireEvent.click(launch) - await waitFor(() => expect(screen.getByTestId('preview')).toBeInTheDocument()) - fireEvent.click( - screen.getByRole('button', { name: handoff ? 'Completed browser handoff' : 'Close' }) - ) - await waitFor(() => expect(screen.queryByTestId('preview')).not.toBeInTheDocument()) - await waitFor(() => - expect(document.activeElement).toBe( - handoff ? document.getElementById('browser-destination') : launch - ) - ) - } -) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx deleted file mode 100644 index 78f8cafbaf4..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import type { ITerminalOptions } from '@xterm/xterm' -import { act, cleanup, render, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import '@/components/terminal-pane/input/paste/terminal-bracketed-paste' - -import { dispatchAppMenuPasteEvent } from '@/lib/clipboard/menu/app-menu-paste' - -const terminalHarness = vi.hoisted(() => ({ - instances: [] as { - write: ReturnType - writeCallbacks: (() => void)[] - onDataListener: ((data: string) => void) | null - dispose: ReturnType - resize: ReturnType - reset: ReturnType - paste: ReturnType - input: ReturnType - scrollToTop: ReturnType - scrollToBottom: ReturnType - selectAll: ReturnType - modes: { bracketedPasteMode: boolean } - selectionText: string - customKeyHandler: ((event: KeyboardEvent) => boolean) | null - }[], - userInputListener: null as (() => void) | null, - userInputDispose: vi.fn() -})) - -const platformState = vi.hoisted(() => ({ value: 'linux' })) -const storeState = vi.hoisted(() => ({ - settings: null as { terminalRightClickToPaste?: boolean } | null, - keybindings: {} as Record -})) - -const imeHarness = vi.hoisted(() => ({ - forwarders: [] as { - claimKeyEvent: ReturnType - dispose: ReturnType - sendInput: (data: string) => void - getKittyKeyboardFlags: () => number - }[], - trackers: [] as { dispose: ReturnType }[], - claimResult: false -})) - -vi.mock('@xterm/xterm', () => ({ - Terminal: class { - cols = 80 - rows = 24 - buffer = { active: { cursorY: 0 } } - writeCallbacks: (() => void)[] = [] - onDataListener: ((data: string) => void) | null = null - customKeyHandler: ((event: KeyboardEvent) => boolean) | null = null - selectionText = '' - write = vi.fn((_data: string, callback?: () => void) => { - if (callback) { - this.writeCallbacks.push(callback) - } - }) - open = vi.fn() - focus = vi.fn() - dispose = vi.fn() - resize = vi.fn() - reset = vi.fn() - modes = { bracketedPasteMode: false } - paste = vi.fn((data: string) => { - terminalHarness.userInputListener?.() - this.onDataListener?.(data) - }) - input = vi.fn((data: string) => { - terminalHarness.userInputListener?.() - this.onDataListener?.(data) - }) - element = document.createElement('div') - unicode = { activeVersion: '6', versions: ['6', '11'], register: vi.fn() } - loadAddon = vi.fn() - options: ITerminalOptions = {} - attachCustomWheelEventHandler = vi.fn() - scrollToTop = vi.fn() - scrollToBottom = vi.fn() - selectAll = vi.fn() - getSelection = vi.fn(() => this.selectionText) - hasSelection = vi.fn(() => this.selectionText !== '') - attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { - this.customKeyHandler = handler - }) - onData = vi.fn((listener: (data: string) => void) => { - this.onDataListener = listener - return { dispose: vi.fn() } - }) - - constructor() { - terminalHarness.instances.push(this) - } - } -})) -vi.mock(import('@/lib/pane-manager/pane-terminal-options'), async (importOriginal) => ({ - ...(await importOriginal()), - buildDefaultTerminalOptions: () => ({}) -})) -vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({ - subscribeToTerminalUserInput: (_terminal: unknown, listener: () => void) => { - terminalHarness.userInputListener = listener - return { dispose: terminalHarness.userInputDispose } - } -})) -vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({ - useSystemPrefersDark: () => false -})) -vi.mock('@/lib/keyboard/shortcut-platform', () => ({ - getShortcutPlatform: () => platformState.value -})) -vi.mock('@/components/terminal-pane/input/ime/terminal-ime-native-text-forwarder', () => ({ - installTerminalImeNativeTextForwarder: (args: { - sendInput: (data: string) => void - getKittyKeyboardFlags?: () => number - }) => { - const forwarder = { - claimKeyEvent: vi.fn(() => imeHarness.claimResult), - dispose: vi.fn(), - sendInput: args.sendInput, - // Read the same live keyboard-mode mirror the real commit forwarder receives. - getKittyKeyboardFlags: args.getKittyKeyboardFlags ?? ((): number => 0) - } - imeHarness.forwarders.push(forwarder) - return forwarder - } -})) -vi.mock('@/components/terminal-pane/input/ime/terminal-ime-composition-tracker', () => ({ - installTerminalImeCompositionTracker: () => { - const tracker = { isActive: () => false, dispose: vi.fn() } - imeHarness.trackers.push(tracker) - return tracker - } -})) -vi.mock('@/store', () => { - const useAppStore = (selector: (s: typeof storeState) => unknown): unknown => selector(storeState) - useAppStore.getState = (): typeof storeState => storeState - return { useAppStore } -}) - -vi.mock('./terminal-preview-api', () => ({ - terminalPreviewApiFor: () => window.api.terminalPreview -})) - -import { AgentTerminalPreview } from './AgentTerminalPreview' - -describe('AgentTerminalPreview clipboard routes', () => { - const input = vi.fn(async (_ptyId: string, _data: string) => true) - const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows })) - const ack = vi.fn(async () => {}) - const unsubscribe = vi.fn(async () => {}) - const connect = vi.fn() - const readClipboardText = vi.fn(async () => 'clip-text') - const writeClipboardText = vi.fn(async () => {}) - const writeTerminalClipboardText = vi.fn(async () => {}) - const performNativeSelectionAction = vi.fn() - - /** Focuses a stand-in for xterm's helper textarea inside the preview container. */ - const focusInsidePreview = (container: HTMLElement, tagName = 'input'): HTMLElement => { - const host = container.querySelector('.origin-bottom-left')! - const focusTarget = document.createElement(tagName) - host.appendChild(focusTarget) - focusTarget.focus() - return focusTarget - } - - beforeEach(() => { - terminalHarness.instances.length = 0 - terminalHarness.userInputListener = null - platformState.value = 'linux' - storeState.keybindings = {} - imeHarness.forwarders.length = 0 - imeHarness.trackers.length = 0 - imeHarness.claimResult = false - storeState.settings = null - connect.mockResolvedValue({ - snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, - replay: [] - }) - readClipboardText.mockResolvedValue('clip-text') - Object.assign(window, { - api: { - terminalPreview: { - connect, - input, - fit, - ack, - unsubscribe, - onData: () => vi.fn() - }, - ui: { - readClipboardText, - writeClipboardText, - writeTerminalClipboardText, - performNativeSelectionAction - } - } - }) - }) - - afterEach(() => { - cleanup() - vi.clearAllMocks() - }) - - it('cancels an async paste when the preview loses focus', async () => { - let resolveClipboard!: (text: string) => void - readClipboardText.mockReturnValueOnce( - new Promise((resolve) => { - resolveClipboard = resolve - }) - ) - const view = render() - await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) - const terminal = terminalHarness.instances[0]! - focusInsidePreview(view.container) - const outsideInput = document.createElement('input') - view.container.appendChild(outsideInput) - - act(() => { - dispatchAppMenuPasteEvent() - }) - outsideInput.focus() - await act(async () => resolveClipboard('stale text')) - - expect(terminal.paste).not.toHaveBeenCalled() - expect(input).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx deleted file mode 100644 index 0b1c68a49b8..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx +++ /dev/null @@ -1,229 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, render } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { ITerminalOptions } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' -import { useAppStore } from '@/store' -import { AgentTerminalPreview } from './AgentTerminalPreview' - -type PreviewInstance = { - options: ITerminalOptions - container: HTMLElement | null - dispose: ReturnType - writeCallbacks: (() => void)[] -} -const harness = vi.hoisted(() => ({ - instances: new Array(), - deferWrites: false -})) - -vi.mock('@xterm/xterm', () => ({ - Terminal: class { - cols = 80 - rows = 24 - buffer = { active: { cursorY: 14 } } - container: HTMLElement | null = null - screen = document.createElement('div') - writeCallbacks: (() => void)[] = [] - write = vi.fn((_data: string, callback?: () => void) => { - if (!callback) { - return - } - if (harness.deferWrites) { - this.writeCallbacks.push(callback) - } else { - callback() - } - }) - focus = vi.fn() - resize = vi.fn() - reset = vi.fn() - onData = vi.fn(() => ({ dispose: vi.fn() })) - dispose = vi.fn(() => this.screen.remove()) - constructor(public options: ITerminalOptions) { - harness.instances.push(this) - } - open(container: HTMLElement): void { - this.container = container - this.screen.className = 'xterm-screen' - Object.defineProperties(this.screen, { - offsetWidth: { - get: () => - this.cols * - (this.options.fontSize === 18 || - this.options.fontFamily?.includes('Fira Code') || - this.options.fontWeight === 900 || - this.options.fontWeightBold === 900 - ? 12 - : 10) - }, - offsetHeight: { get: () => this.rows * 16 * (this.options.lineHeight ?? 1) } - }) - const box = container.parentElement - if (!box) { - throw new Error('Missing preview box') - } - Object.defineProperties(box, { - clientWidth: { configurable: true, value: 600 }, - clientHeight: { configurable: true, value: 240 } - }) - container.append(this.screen) - } - } -})) -vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({ - subscribeToTerminalUserInput: () => ({ dispose: vi.fn() }) -})) -vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({ - useSystemPrefersDark: () => false -})) -vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ - useEffectiveMacOptionAsAlt: (value: string) => value -})) -vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) -vi.mock('./preview-terminal-compatibility', () => ({ - installPreviewTerminalCompatibility: () => vi.fn() -})) -vi.mock('./preview-terminal-ime-bridge', () => ({ - installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-key-handler', () => ({ - installPreviewTerminalKeyHandler: () => vi.fn() -})) -vi.mock('@/components/terminal-pane/clipboard/terminal-native-copy-gutter', () => ({ - installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-app-menu-clipboard', () => ({ - installPreviewTerminalAppMenuClipboard: () => vi.fn() -})) -vi.mock('./preview-terminal-right-click-paste', () => ({ - installPreviewTerminalRightClickPaste: () => vi.fn() -})) - -const initial = useAppStore.getInitialState() -const connect = vi.fn() -const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows })) -const unsubscribe = vi.fn(async () => {}) -let settings: GlobalSettings -let originalApi: PropertyDescriptor | undefined -const frames = new Map() -let nextFrame = 0 -const cancelFrame = vi.fn((id: number) => frames.delete(id)) -function flushFrames(): void { - for (const [id, callback] of frames) { - frames.delete(id) - callback(16) - } -} - -beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - harness.instances.length = 0 - harness.deferWrites = false - frames.clear() - nextFrame = 0 - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - const id = nextFrame++ - frames.set(id, callback) - return id - }) - vi.stubGlobal('cancelAnimationFrame', cancelFrame) - // No resize notification or later output: replay must perform its own fit and grid claim. - vi.stubGlobal( - 'ResizeObserver', - class { - observe = vi.fn() - disconnect = vi.fn() - } - ) - settings = createGlobalSettingsFixture({ - theme: 'dark', - activeRuntimeEnvironmentId: null, - terminalFontSize: 14, - terminalFontFamily: 'JetBrains Mono', - terminalFontWeight: 500, - terminalFontWeightBold: 700, - terminalLineHeight: 1, - terminalLigatures: 'off' - }) - connect - .mockReset() - .mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) - originalApi = Object.getOwnPropertyDescriptor(window, 'api') - Object.defineProperty(window, 'api', { - configurable: true, - value: { - settings: { - set: async (updates: Partial) => { - settings = structuredClone({ ...settings, ...updates }) - return settings - } - }, - terminalPreview: { connect, fit, unsubscribe, onData: () => vi.fn() } - } - }) - useAppStore.setState({ ...initial, settings }, true) -}) - -afterEach(() => { - cleanup() - for (const instance of harness.instances) { - expect(instance.dispose).toHaveBeenCalledOnce() - } - useAppStore.setState(initial, true) - if (originalApi) { - Object.defineProperty(window, 'api', originalApi) - } else { - Reflect.deleteProperty(window, 'api') - } - frames.clear() - vi.unstubAllGlobals() - vi.useRealTimers() -}) - -it('does not schedule an old parsed write after actual unmount', async () => { - harness.deferWrites = true - const view = render() - await act(async () => {}) - const terminal = harness.instances[0] - expect(terminal).toBeDefined() - flushFrames() - view.unmount() - for (const callback of terminal?.writeCallbacks.splice(0) ?? []) { - callback() - } - expect(connect).toHaveBeenCalledOnce() - expect(unsubscribe).toHaveBeenCalledOnce() - expect(terminal?.dispose).toHaveBeenCalledOnce() - expect(frames.size).toBe(0) -}) - -it('retains the replacement frame on the same DOM and ignores retired parsed writes', async () => { - harness.deferWrites = true - render() - await act(async () => {}) - const old = harness.instances[0] - await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 })) - const replacement = harness.instances[1] - const pending = frames.size - for (const callback of old?.writeCallbacks.splice(0) ?? []) { - callback() - } - const afterOldWrites = frames.size - flushFrames() - await act(async () => { - await vi.advanceTimersByTimeAsync(200) - }) - expect(connect).toHaveBeenCalledTimes(2) - expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(old?.dispose).toHaveBeenCalledOnce() - expect(replacement?.container).toBe(old?.container) - expect(replacement?.container?.style.transform).toBe('scale(0.625)') - expect(replacement?.container?.style.transformOrigin).toBe('top left') - expect(fit).toHaveBeenCalledExactlyOnceWith('pty-1', 50, 15) - expect(frames.size).toBe(0) - expect([pending, afterOldWrites]).toEqual([1, 1]) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx deleted file mode 100644 index 3f188c2aefb..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, render } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { ITerminalOptions } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { TerminalPreviewConnectResult } from '../../../../shared/terminal/replay/terminal-preview' -import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' -import { useAppStore } from '@/store' -import { AgentTerminalPreview } from './AgentTerminalPreview' - -type PreviewInstance = { - options: ITerminalOptions - container: HTMLElement | null - dispose: ReturnType -} -const harness = vi.hoisted(() => ({ instances: new Array() })) - -vi.mock('@xterm/xterm', () => ({ - Terminal: class { - cols = 80 - rows = 24 - buffer = { active: { cursorY: 14 } } - container: HTMLElement | null = null - screen = document.createElement('div') - write = vi.fn((_data: string, callback?: () => void) => callback?.()) - focus = vi.fn() - resize = vi.fn() - reset = vi.fn() - onData = vi.fn(() => ({ dispose: vi.fn() })) - dispose = vi.fn(() => this.screen.remove()) - constructor(public options: ITerminalOptions) { - harness.instances.push(this) - } - open(container: HTMLElement): void { - this.container = container - this.screen.className = 'xterm-screen' - Object.defineProperties(this.screen, { - offsetWidth: { - get: () => this.cols * (this.options.fontSize === 18 ? 12 : 10) - }, - offsetHeight: { get: () => this.rows * 16 * (this.options.lineHeight ?? 1) } - }) - const box = container.parentElement - if (!box) { - throw new Error('Missing preview box') - } - Object.defineProperties(box, { - clientWidth: { configurable: true, value: 600 }, - clientHeight: { configurable: true, value: 240 } - }) - container.append(this.screen) - } - } -})) -vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({ - subscribeToTerminalUserInput: () => ({ dispose: vi.fn() }) -})) -vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({ - useSystemPrefersDark: () => false -})) -vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ - useEffectiveMacOptionAsAlt: (value: string) => value -})) -vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) -vi.mock('./preview-terminal-compatibility', () => ({ - installPreviewTerminalCompatibility: () => vi.fn() -})) -vi.mock('./preview-terminal-ime-bridge', () => ({ - installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-key-handler', () => ({ - installPreviewTerminalKeyHandler: () => vi.fn() -})) -vi.mock('@/components/terminal-pane/clipboard/terminal-native-copy-gutter', () => ({ - installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-app-menu-clipboard', () => ({ - installPreviewTerminalAppMenuClipboard: () => vi.fn() -})) -vi.mock('./preview-terminal-right-click-paste', () => ({ - installPreviewTerminalRightClickPaste: () => vi.fn() -})) - -const initial = useAppStore.getInitialState() -const connect = vi.fn() -const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows })) -const unsubscribe = vi.fn(async () => {}) -let settings: GlobalSettings -let originalApi: PropertyDescriptor | undefined - -beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - harness.instances.length = 0 - // No resize notification or later output: replay must perform its own fit and grid claim. - vi.stubGlobal( - 'ResizeObserver', - class { - observe = vi.fn() - disconnect = vi.fn() - } - ) - settings = createGlobalSettingsFixture({ - theme: 'dark', - activeRuntimeEnvironmentId: null, - terminalFontSize: 14, - terminalFontFamily: 'JetBrains Mono', - terminalFontWeight: 500, - terminalFontWeightBold: 700, - terminalLineHeight: 1, - terminalLigatures: 'off' - }) - connect - .mockReset() - .mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) - originalApi = Object.getOwnPropertyDescriptor(window, 'api') - Object.defineProperty(window, 'api', { - configurable: true, - value: { - settings: { - set: async (updates: Partial) => { - settings = structuredClone({ ...settings, ...updates }) - return settings - } - }, - terminalPreview: { connect, fit, unsubscribe, onData: () => vi.fn() } - } - }) - useAppStore.setState({ ...initial, settings }, true) -}) - -afterEach(() => { - cleanup() - for (const instance of harness.instances) { - expect(instance.dispose).toHaveBeenCalledOnce() - } - useAppStore.setState(initial, true) - if (originalApi) { - Object.defineProperty(window, 'api', originalApi) - } else { - Reflect.deleteProperty(window, 'api') - } - vi.unstubAllGlobals() - vi.useRealTimers() -}) - -async function settleGeometry(): Promise { - await act(async () => { - await vi.advanceTimersByTimeAsync(250) - }) -} - -it('ignores a pending connection retired by a metric change', async () => { - const gate = Promise.withResolvers() - connect.mockReturnValueOnce(gate.promise) - render() - await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 })) - await settleGeometry() - await act(async () => - gate.resolve({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) - ) - await settleGeometry() - expect(connect).toHaveBeenCalledTimes(2) - expect(harness.instances).toHaveLength(1) - expect(harness.instances[0]?.options.fontSize).toBe(18) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx deleted file mode 100644 index d692dfec4b9..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ITerminalOptions } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { TerminalPreviewConnectResult } from '../../../../shared/terminal/replay/terminal-preview' -import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' - -import { useAppStore } from '@/store' -import { AgentTerminalPreview } from './AgentTerminalPreview' - -type PreviewInstance = { - options: ITerminalOptions - dispose: ReturnType - reset: ReturnType -} - -const harness = vi.hoisted(() => ({ - instances: new Array(), - systemPrefersDark: false -})) - -vi.mock('@xterm/xterm', () => ({ - Terminal: class { - cols = 80 - rows = 24 - buffer = { active: { cursorY: 0 } } - options: ITerminalOptions - write = vi.fn((_data: string, callback?: () => void) => callback?.()) - open = vi.fn() - focus = vi.fn() - dispose = vi.fn() - resize = vi.fn() - reset = vi.fn() - onData = vi.fn(() => ({ dispose: vi.fn() })) - constructor(options: ITerminalOptions) { - this.options = options - harness.instances.push(this) - } - } -})) -vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({ - subscribeToTerminalUserInput: () => ({ dispose: vi.fn() }) -})) -vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({ - useSystemPrefersDark: () => harness.systemPrefersDark -})) -vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ - useEffectiveMacOptionAsAlt: (value: string) => value -})) -vi.mock('./preview-grid-claim', () => ({ - createPreviewGridClaim: () => ({ schedule: vi.fn(), dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-box-fit', () => ({ - createPreviewBoxFit: () => ({ schedule: vi.fn(), dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) -vi.mock('./preview-terminal-compatibility', () => ({ - installPreviewTerminalCompatibility: () => vi.fn() -})) -vi.mock('./preview-terminal-ime-bridge', () => ({ - installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-key-handler', () => ({ - installPreviewTerminalKeyHandler: () => vi.fn() -})) -vi.mock('@/components/terminal-pane/clipboard/terminal-native-copy-gutter', () => ({ - installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() }) -})) -vi.mock('./preview-terminal-app-menu-clipboard', () => ({ - installPreviewTerminalAppMenuClipboard: () => vi.fn() -})) -vi.mock('./preview-terminal-right-click-paste', () => ({ - installPreviewTerminalRightClickPaste: () => vi.fn() -})) - -const initial = useAppStore.getInitialState() -const connect = vi.fn() -const unsubscribe = vi.fn(async () => {}) -const offData = vi.fn() -const onData = vi.fn(() => offData) -const connection: TerminalPreviewConnectResult = { - snapshot: { data: 'agent prompt', cols: 80, rows: 24, seq: 1 }, - replay: [] -} -let settings: GlobalSettings -let originalApi: PropertyDescriptor | undefined - -async function updateSettings(updates: Partial): Promise { - await act(async () => useAppStore.getState().updateSettings(updates)) -} - -function terminal(index = 0): PreviewInstance { - const instance = harness.instances[index] - if (!instance) { - throw new Error(`Missing terminal ${index}`) - } - return instance -} - -beforeEach(() => { - vi.clearAllMocks() - harness.instances.length = 0 - harness.systemPrefersDark = false - settings = createGlobalSettingsFixture({ theme: 'dark', activeRuntimeEnvironmentId: null }) - connect.mockReset().mockResolvedValue(connection) - originalApi = Object.getOwnPropertyDescriptor(window, 'api') - Object.defineProperty(window, 'api', { - configurable: true, - value: { - settings: { - set: async (updates: Partial) => { - settings = structuredClone({ ...settings, ...updates }) - return settings - } - }, - terminalPreview: { - connect, - unsubscribe, - onData - } - } - }) - useAppStore.setState({ ...initial, settings }, true) -}) - -afterEach(() => { - cleanup() - for (const instance of harness.instances) { - expect(instance.dispose).toHaveBeenCalledOnce() - } - expect(offData).toHaveBeenCalledTimes(onData.mock.calls.length) - useAppStore.setState(initial, true) - if (originalApi) { - Object.defineProperty(window, 'api', originalApi) - } else { - Reflect.deleteProperty(window, 'api') - } -}) - -describe('preview terminal settings lifetime', () => { - it('keeps one preview connection across ten unrelated settings snapshots', async () => { - render() - await act(async () => {}) - const theme = terminal().options.theme - for (let index = 1; index <= 10; index += 1) { - await updateSettings({ editorAutoSaveDelayMs: 1000 + index }) - } - expect(connect).toHaveBeenCalledExactlyOnceWith('pty-1', { scrollbackRows: 24 }) - expect(harness.instances).toHaveLength(1) - expect(terminal().options.theme).toBe(theme) - expect(unsubscribe).not.toHaveBeenCalled() - }) - - it('uses current live options when an unrelated update arrives during connect', async () => { - const gate = Promise.withResolvers() - connect.mockReturnValueOnce(gate.promise) - render() - await updateSettings({ terminalCursorStyle: 'bar' }) - expect(connect).toHaveBeenCalledOnce() - await act(async () => gate.resolve(connection)) - expect(harness.instances).toHaveLength(1) - expect(terminal().options.cursorStyle).toBe('bar') - }) - - it('does not install a terminal after unmounting a pending connection', async () => { - const gate = Promise.withResolvers() - connect.mockReturnValueOnce(gate.promise) - const view = render() - await updateSettings({ editorAutoSaveDelayMs: 1234 }) - view.unmount() - await act(async () => gate.resolve(connection)) - expect(harness.instances).toHaveLength(0) - expect(connect).toHaveBeenCalledOnce() - expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('ssh:host@@pty-1') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx index 21826639e31..9a51da4b151 100644 --- a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx +++ b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx @@ -1,8 +1,8 @@ import { useAppMenuPaste } from '@/hooks/clipboard/menu/useAppMenuPaste' import { useAppMenuSelectionActions } from '@/hooks/app-commands/useAppMenuSelectionActions' -import { AgentKanbanBoard } from './AgentKanbanBoard' -import { useDashboardSnapshot } from './useDashboardSnapshot' -import { useAgentDashboardTelemetry } from './use-agent-dashboard-telemetry' +import { AgentKanbanBoard } from '../dashboard/board/AgentKanbanBoard' +import { useDashboardSnapshot } from './snapshot/useDashboardSnapshot' +import { useAgentDashboardTelemetry } from '../dashboard/board/use-agent-dashboard-telemetry' export function DashboardPopoutRoot(): React.JSX.Element { // The pop-out has no App shell to translate Edit-menu IPC into terminal ownership events. diff --git a/src/renderer/src/components/dashboard-popout/dashboard-preview-dom-test-fixture.ts b/src/renderer/src/components/dashboard-popout/dashboard-preview-dom-test-fixture.ts deleted file mode 100644 index 563b9c0589a..00000000000 --- a/src/renderer/src/components/dashboard-popout/dashboard-preview-dom-test-fixture.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { PreviewTerminalLinkHandler } from './preview-terminal-links' -import { vi, type Mock } from 'vitest' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker' -import { - openStandaloneTerminal, - type StandaloneTerminal -} from '../standalone-terminal/open-standalone-terminal' -import { installDashboardPreviewLinkInteraction } from './dashboard-preview-link-interaction' -import type { DashboardPreviewLinkSource } from './dashboard-preview-http-actions' - -/** Model the browser layout boundary; xterm still measures, renders and hit-tests its own cells. */ -export function installPreviewDomFixture(): () => void { - const style = document.createElement('style') - style.textContent = '.xterm, .xterm-screen { padding: 0px; }' - document.head.appendChild(style) - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => { - const context: CanvasRenderingContext2D = Object.create(null) - context.font = '13px monospace' - context.measureText = (text) => Object.assign(Object.create(null), { width: text.length * 10 }) - context.clearRect = vi.fn() - context.fillRect = vi.fn() - return context - }) - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( - this: HTMLElement - ) { - return new DOMRect(0, 0, this.offsetWidth, this.offsetHeight) - }) - vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( - this: HTMLElement - ) { - return this.classList.contains('xterm-char-measure-element') - ? 320 - : Number.parseFloat(this.style.width) || 800 - }) - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( - this: HTMLElement - ) { - return this.classList.contains('xterm-char-measure-element') - ? 20 - : Number.parseFloat(this.style.height) || 160 - }) - vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(800) - vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(160) - vi.stubGlobal( - 'ResizeObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - } - ) - return () => style.remove() -} - -export function mountPreviewLinkInteraction( - source: DashboardPreviewLinkSource, - readSettings: () => GlobalSettings | null, - trackInstance: (instance: ReturnType) => void, - withInteraction = true -): { - instance: StandaloneTerminal - install: () => ReturnType - sink: Mock<(data: string) => boolean | Promise> - getSource: Mock<() => DashboardPreviewLinkSource | undefined> - getSettings: Mock<() => GlobalSettings | null> - request: Mock[1]['request']> - container: HTMLDivElement -} { - const container = document.createElement('div') - document.body.appendChild(container) - let interaction: ReturnType | undefined - let defaultHandlers: PreviewTerminalLinkHandler | undefined - const sink = vi.fn<(data: string) => boolean | Promise>(() => true) - const instance = openStandaloneTerminal({ - container, - options: { cols: 80, rows: 8, allowProposedApi: true }, - kittyKeyboardModes: new TerminalKittyKeyboardModeTracker({ kittyKeyboard: true }), - sessionKey: source.ptyId, - sendInput: (data) => (interaction ? interaction.sendInput(data) : sink(data)), - answersQueries: false, - getSettings: readSettings, - getMacOptionAsAlt: () => 'false', - getTerminalInput: () => null, - ...(withInteraction - ? { - linkHandlers: (defaults: PreviewTerminalLinkHandler): PreviewTerminalLinkHandler => { - defaultHandlers = defaults - return { - activate: (e, uri, range) => interaction?.handlers.activate(e, uri, range), - hover: (e, uri, range) => interaction?.handlers.hover?.(e, uri, range), - leave: (e, uri, range) => interaction?.handlers.leave?.(e, uri, range) - } - } - } - : {}) - }) - if (!instance) { - throw new Error('Actual standalone constructor failed') - } - const request = vi.fn[1]['request']>() - const getSource = vi.fn<() => DashboardPreviewLinkSource | undefined>(() => source) - const getSettings = vi.fn(readSettings) - const install = () => { - interaction = installDashboardPreviewLinkInteraction(instance.terminal, { - getSource, - getSettings, - defaultHandlers, - onForegroundOpen: () => undefined, - request, - sendInput: sink - }) - return interaction - } - const dispose = () => { - interaction?.dispose() - instance.dispose() - container.remove() - } - trackInstance({ ...instance, dispose }) - return { instance, install, sink, getSource, getSettings, request, container } -} diff --git a/src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx b/src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx deleted file mode 100644 index bf688a8de9d..00000000000 --- a/src/renderer/src/components/dashboard-popout/dashboard-preview-link-composition.test.tsx +++ /dev/null @@ -1,322 +0,0 @@ -// @vitest-environment happy-dom -import { - act, - cleanup, - fireEvent, - render as renderWithoutApp, - waitFor -} from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { RemoteRuntimeMultiplexedTerminalCallbacks } from '@/runtime/hosted-terminals/streams/remote-terminal-stream-contracts' - -import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' -import { resetRemoteRuntimeTerminalPreviewSessionsForTests } from './remote-runtime-terminal-preview-api' -import { - installPreviewDomFixture, - mountPreviewLinkInteraction -} from './dashboard-preview-dom-test-fixture' - -import type { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal' - -import { getDefaultSettings } from '../../../../shared/constants' -import { createStoreCascadesMockApi } from '@/store/testing/app-store-cascade-api.test-fixture' -import { - createBrowserLinkTestStore, - WORKSPACE, - URL_TO_OPEN -} from '@/lib/browser/navigation/browser-link-open.test-fixture' -import { - registerHttpLinkStoreAccessor, - registerWorkspaceHttpLinkBrowserOpener -} from '@/lib/browser/navigation/http-link-routing' -import { openWorkspaceBrowserTabForLink } from '@/lib/browser/workspace/workspace-browser-tab-open' - -import { terminalPreviewApiFor } from './terminal-preview-api' -import { AgentKanbanBoard } from './AgentKanbanBoard' - -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import type { GlobalSettings } from '../../../../shared/global-settings-types' - -const mocks = vi.hoisted(() => ({ - getState: vi.fn(), - subscribe: vi.fn(), - remote: new Map() -})) -vi.mock( - '@/runtime/hosted-terminals/streams/registry/remote-runtime-terminal-multiplexer-registry', - () => ({ - getRemoteRuntimeTerminalMultiplexer: (environmentId: string) => ({ - subscribeTerminal: async ({ - terminal, - callbacks - }: { - terminal: string - callbacks: RemoteRuntimeMultiplexedTerminalCallbacks - }) => { - const ptyId = `remote:${environmentId}@@${terminal}` - mocks.remote.set(ptyId, callbacks) - queueMicrotask(() => callbacks.onSnapshot('', { cols: 80, rows: 8, seq: 1 })) - return { - sendInput: (data: string) => input(ptyId, data), - claimViewport: () => true, - serializeBufferOutcome: async () => ({ - availability: { kind: 'snapshot' }, - snapshot: { data: '', cols: 80, rows: 8, seq: 1 } - }), - close: () => { - mocks.remote.delete(ptyId) - } - } - } - }) - }) -) - -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: ReturnType) => unknown) => selector(mocks.getState()), - { getState: mocks.getState } - ) -})) -vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({ - useSystemPrefersDark: () => false -})) -vi.mock('@/lib/keyboard/shortcut-platform', () => ({ getShortcutPlatform: () => 'linux' })) -vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() } -})) -const domWindow = window -const api = createStoreCascadesMockApi() -Object.assign(globalThis, { window: domWindow }) -Object.assign(window, { api }) -let store: ReturnType -const instances: ReturnType[] = [] -const input = vi.fn(async (_pty: string, _data: string) => true) -const openUrl = vi.fn(async (_url: string) => {}) -let onData: ((payload: unknown) => void) | undefined -let disposeDom: () => void -const source = { - worktreeId: WORKSPACE, - executionHostId: 'local', - tabId: 'source', - ptyId: 'pty-1' -} as const - -beforeEach(() => { - store = createBrowserLinkTestStore() - store.setState((state) => ({ - settings: { - ...state.settings, - ...getDefaultSettings('/home/test'), - terminalLigatures: 'auto', - terminalLinkClickBehavior: 'actions', - terminalRightClickToPaste: false, - openLinksInApp: false - } satisfies GlobalSettings - })) - mocks.getState.mockImplementation(() => store.getState()) - mocks.subscribe.mockImplementation((listener) => vi.fn(store.subscribe(listener))) - registerHttpLinkStoreAccessor(() => store.getState(), mocks.subscribe) - registerWorkspaceHttpLinkBrowserOpener(async (request) => { - if (!request.linkContext) { - throw new Error('Expected a genuine link request') - } - const outcome = await openWorkspaceBrowserTabForLink({ - ...request, - linkContext: request.linkContext - }) - return outcome.status === 'opened' ? outcome.result.browserPageId : null - }) - disposeDom = installPreviewDomFixture() - Object.assign(window.api, { - shell: { ...window.api.shell, openUrl }, - dashboard: { - ...window.api.dashboard, - ackAgent: vi.fn(async (_pane: string, _intent: 'view' | 'explicit') => {}) - }, - terminalPreview: { - connect: vi.fn(async () => ({ - snapshot: { data: '', cols: 80, rows: 8, seq: 1 }, - replay: [] - })), - onData: (listener: typeof onData) => { - onData = listener - return () => { - onData = undefined - } - }, - input, - ack: vi.fn(async () => {}), - fit: vi.fn(async () => ({ cols: 80, rows: 8 })), - unsubscribe: vi.fn(async () => {}) - }, - ui: { - ...window.api.ui, - readClipboardText: vi.fn(async () => 'clipboard'), - writeClipboardText: vi.fn(async () => {}), - writeTerminalClipboardText: vi.fn(async () => {}) - } - }) -}) -afterEach(() => { - cleanup() - for (const instance of instances.splice(0)) { - instance?.dispose() - } - registerWorkspaceHttpLinkBrowserOpener(null) - resetRemoteRuntimeTerminalPreviewSessionsForTests() - disposeDom() - vi.restoreAllMocks() - vi.clearAllMocks() - vi.unstubAllGlobals() -}) - -function screenFor(container: HTMLElement): HTMLElement { - const screen = container.querySelector('.xterm-screen') - if (!screen) { - throw new Error('Actual xterm screen did not mount') - } - return screen -} -function pointer(screen: HTMLElement, type: string, options: MouseEventInit = {}): MouseEvent { - const event = new MouseEvent(type, { - bubbles: true, - cancelable: true, - button: 0, - buttons: type === 'mouseup' ? 0 : 1, - clientX: 7, - clientY: 7, - ...options - }) - screen.dispatchEvent(event) - return event -} -async function output(data: string, ptyId = 'pty-1') { - const ack = vi.spyOn(terminalPreviewApiFor(ptyId), 'ack').mockClear() - act(() => { - if (parseRemoteRuntimePtyId(ptyId)) { - const callbacks = mocks.remote.get(ptyId) - if (!callbacks) { - throw new Error('Missing selected remote preview subscription') - } - callbacks.onData(data) - } else { - onData?.({ type: 'data', ptyId, data, bytes: data.length }) - } - }) - await waitFor(() => expect(ack).toHaveBeenCalledExactlyOnceWith(ptyId, data.length)) -} -const osc = `\x1b]8;;${URL_TO_OPEN}\x07hidden label\x1b]8;;\x07` - -function mountedInteraction() { - return mountPreviewLinkInteraction( - source, - () => store.getState().settings, - (instance) => instances.push(instance) - ) -} - -it('retains actual boolean/Promise forwarding results and flushes unclaimed reports in order, with finite overflow and cleanup', async () => { - const preview = mountedInteraction() - const interaction = preview.install() - preview.sink.mockReturnValueOnce(false) - expect(interaction.sendInput('refused-key')).toBe(false) - const promise = Promise.resolve(true) - preview.sink.mockReturnValueOnce(promise) - expect(interaction.sendInput('async-key')).toBe(promise) - await new Promise((resolve) => preview.instance.write(osc, { live: true, onDone: resolve })) - const screen = screenFor(preview.container) - const reports = Array.from({ length: 65 }, (_, i) => `\x1b[<0;${i + 1};1M`) - // Protocol fixture feeds qualified input during a real event dispatch; native timing remains QA's separate obligation. - const emitReports = () => { - preview.instance.terminal.input(reports[0]!) - expect(preview.sink).toHaveBeenCalledTimes(2) - for (const report of reports.slice(1)) { - preview.instance.terminal.input(report) - } - } - screen.addEventListener('mousedown', emitReports) - pointer(screen, 'mousedown') - screen.removeEventListener('mousedown', emitReports) - pointer(screen, 'mouseup') - expect(preview.sink.mock.calls.slice(2).map(([data]) => data)).toEqual(reports) - expect(preview.request).not.toHaveBeenCalled() - interaction.dispose() - expect(preview.sink.mock.calls.slice(2).map(([data]) => data)).toEqual(reports) -}) - -function cards(count: number): DashboardCard[] { - return Array.from({ length: count }, (_, i) => ({ - paneKey: `pane-${i}`, - ptyId: `pty-${i}`, - agentType: 'claude', - bucket: 'working', - dotState: 'working', - task: 'task', - repoId: 'repo1', - worktreeId: WORKSPACE, - tabId: `tab-${i}`, - leafId: `leaf-${i}`, - repoName: 'Repo', - worktreeName: `Workspace ${i}`, - startedAt: 0, - finishedAt: null, - stateChangedAt: 0, - unseen: false - })) -} - -it('refuses real selected/drag/nonlink reports and retains the real in-window Board chooser and Settings handoff', async () => { - const preview = mountedInteraction() - const interaction = preview.install() - await new Promise((resolve) => - preview.instance.write(`\x1b[?1000h\x1b[?1006h${URL_TO_OPEN}`, { live: true, onDone: resolve }) - ) - const terminal = preview.instance.terminal - const screen = screenFor(preview.container) - terminal.select(0, 0, 5) - pointer(screen, 'mousedown') - pointer(screen, 'mouseup') - expect(preview.request).not.toHaveBeenCalled() - terminal.clearSelection() - pointer(screen, 'mousedown') - pointer(screen, 'mousemove', { clientX: 30 }) - pointer(screen, 'mouseup', { clientX: 30 }) - expect(preview.request).not.toHaveBeenCalled() - pointer(screen, 'mousedown', { clientX: 700 }) - pointer(screen, 'mouseup', { clientX: 700 }) - expect(preview.request).not.toHaveBeenCalled() - const reports = expect.poll(() => preview.sink.mock.calls.map(([data]) => data)) - await reports.toEqual([ - '\x1b[<0;1;1M', - '\x1b[<0;1;1m', - '\x1b[<0;1;1M', - '\x1b[<0;4;1m', - '\x1b[<0;71;1M', - '\x1b[<0;71;1m' - ]) - interaction.dispose() - preview.instance.dispose() - preview.container.remove() - - const foreground = vi.fn() - const view = renderWithoutApp( - - ) - fireEvent.click(view.getByRole('button', { name: /Workspace 0/ })) - await waitFor(() => screenFor(document.body)) - await output(osc) - const realScreen = screenFor(document.body) - pointer(realScreen, 'mousedown') - pointer(realScreen, 'mouseup') - const settings = await view.findByRole('button', { name: 'Terminal link settings' }) - expect(document.querySelector('[data-terminal-link-destination]')?.textContent).toBe(URL_TO_OPEN) - expect(view.getByRole('button', { name: /System Browser/ })).toBeTruthy() - fireEvent.click(settings) - expect(foreground).toHaveBeenCalledOnce() - expect(store.getState().activeView).toBe('settings') - await waitFor(() => expect(document.querySelector('.xterm')).toBeNull()) -}) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts deleted file mode 100644 index bbfd1bf39e0..00000000000 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { Terminal } from '@xterm/xterm' -import { createPreviewBoxFit } from './preview-terminal-box-fit' - -const frames = new Map() -const cancelFrame = vi.fn((id: number) => frames.delete(id)) -let nextFrame = 0 -const terminals: Terminal[] = [] - -beforeEach(() => { - nextFrame = 0 - frames.clear() - vi.clearAllMocks() - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - const id = nextFrame++ - frames.set(id, callback) - return id - }) - vi.stubGlobal('cancelAnimationFrame', cancelFrame) -}) - -afterEach(() => { - for (const terminal of terminals.splice(0)) { - terminal.dispose() - } - vi.unstubAllGlobals() -}) - -function harness(getTerminal?: () => null) { - const box = document.createElement('div') - const container = document.createElement('div') - const screen = document.createElement('div') - screen.className = 'xterm-screen' - box.append(container) - container.append(screen) - Object.defineProperties(box, { - clientWidth: { value: 600 }, - clientHeight: { value: 240 } - }) - Object.defineProperties(screen, { - offsetWidth: { value: 800 }, - offsetHeight: { value: 384 } - }) - const terminal = new Terminal({ rows: 24, cols: 80, allowProposedApi: true }) - terminals.push(terminal) - const read = vi.fn(getTerminal ?? (() => terminal)) - return { ...createPreviewBoxFit({ container, getTerminal: read }), box, container, read } -} - -function flush(id = 0): void { - const callback = frames.get(id) - frames.delete(id) - callback?.(16) -} - -function thrown(operation: () => void): unknown { - try { - operation() - } catch (error) { - return error - } - throw new Error('Expected an error') -} - -it('cancels frame zero once and suppresses captured or later callbacks after disposal', () => { - const owner = harness() - owner.schedule() - const captured = frames.get(0) - owner.dispose() - owner.dispose() - captured?.(16) - owner.schedule() - expect(owner.read).not.toHaveBeenCalled() - expect(owner.container.style.transform).toBe('') - expect(frames.size).toBe(0) - expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(0) -}) - -it('preserves a fit error and allows the next live request after completion', () => { - const error = new Error('terminal read failed') - const owner = harness() - owner.read.mockImplementationOnce(() => { - throw error - }) - owner.schedule() - expect(thrown(() => flush())).toBe(error) - owner.schedule() - flush(1) - expect(owner.read).toHaveBeenCalledTimes(2) - owner.dispose() - expect(cancelFrame).not.toHaveBeenCalled() -}) - -it('does not cancel another owner when both use the same container', () => { - const old = harness() - const current = createPreviewBoxFit({ container: old.container, getTerminal: () => null }) - old.schedule() - current.schedule() - old.dispose() - expect([...frames.keys()]).toEqual([1]) - current.dispose() - expect(cancelFrame.mock.calls).toEqual([[0], [1]]) -}) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts deleted file mode 100644 index 99642873d34..00000000000 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -// @vitest-environment happy-dom -import { Terminal } from '@xterm/xterm' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installPreviewImeBridge } from './preview-terminal-ime-bridge' - -vi.mock('@/lib/keyboard/shortcut-platform', () => ({ - getShortcutPlatform: () => 'darwin' -})) - -const COMMA = { key: ',', code: 'Comma', keyCode: 188 } - -type Session = { - emitted: string[] - setFlags: (flags: number) => void - keydown: (init?: { shiftKey?: boolean; repeat?: boolean; key?: string; code?: string }) => void - keyup: (init?: { shiftKey?: boolean; key?: string; code?: string }) => void - commit: (text: string) => void - dispose: () => void -} - -function open(initialFlags: number): Session { - const container = document.createElement('div') - document.body.appendChild(container) - const terminal = new Terminal() - terminal.open(container) - const textarea = terminal.textarea! - let flags = initialFlags - const bridge = installPreviewImeBridge(terminal, { - getKittyKeyboardFlags: () => flags - })! - terminal.attachCustomKeyEventHandler((event) => !bridge.claimKeyEvent(event)) - const emitted: string[] = [] - terminal.onData((data) => emitted.push(data)) - - const key = ( - type: string, - init: { key?: string; code?: string; shiftKey?: boolean; repeat?: boolean } - ): void => { - const event = new KeyboardEvent(type, { - key: init.key ?? COMMA.key, - code: init.code ?? COMMA.code, - shiftKey: init.shiftKey === true, - repeat: init.repeat === true, - bubbles: true, - cancelable: true - }) - Object.defineProperty(event, 'keyCode', { value: COMMA.keyCode }) - textarea.dispatchEvent(event) - } - - const input = (data: string | null): void => { - for (const type of ['beforeinput', 'input']) { - const event = new InputEvent(type, { bubbles: true }) - Object.defineProperty(event, 'inputType', { value: 'insertText' }) - Object.defineProperty(event, 'data', { value: data }) - textarea.dispatchEvent(event) - } - } - - return { - emitted, - setFlags: (next) => { - flags = next - }, - keydown: (init = {}) => key('keydown', init), - keyup: (init = {}) => key('keyup', init), - commit: (text) => input(text), - dispose: () => { - bridge.dispose() - terminal.dispose() - } - } -} - -describe('preview IME bridge outbound bytes', () => { - beforeEach(() => { - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ - measureText: () => ({ width: 10 }) - } as unknown as CanvasRenderingContext2D) - }) - afterEach(() => { - vi.restoreAllMocks() - document.body.replaceChildren() - }) - - it('encodes the release from the keyup state when Shift is released first', () => { - const session = open(10) - // `!` on Digit1: the press reports the base key with the Shift modifier. - session.keydown({ key: '!', code: 'Digit1', shiftKey: true }) - session.commit('!') - session.keyup({ key: '1', code: 'Digit1', shiftKey: false }) - // 49 is `1`; the release carries no modifier because none was held at release. - expect(session.emitted.join('')).toBe('\x1b[49;2u\x1b[49;1:3u') - session.dispose() - }) - - it('emits one release for a held key across auto-repeat', () => { - const session = open(10) - session.keydown() - session.commit(',') - session.keydown({ repeat: true }) - session.commit(',') - session.keyup() - // Press, repeat, then exactly one release for the single physical press. - expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44;1:2u\x1b[44;1:3u') - session.dispose() - }) - - it('keeps an owed release when a later repeat commits under flags that owe none', () => { - const session = open(10) - session.keydown() - session.commit(',') - session.setFlags(8) - session.keydown({ repeat: true }) - session.commit(',') - session.setFlags(10) - session.keyup() - // A repeat’s new flags cannot erase the release owed by the originally delivered press. - expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44u\x1b[44;1:3u') - session.dispose() - }) - - it('suppresses the owed release when event types are no longer negotiated at keyup', () => { - // Match xterm: once event reporting is popped, do not send CSI-u releases to an app or successor shell that no longer negotiates them. - const session = open(10) - session.keydown() - session.commit(',') - session.setFlags(0) - session.keyup() - expect(session.emitted.join('')).toBe('\x1b[44u') - session.dispose() - }) - - it('releases each key of a two-key rollover exactly once', () => { - const session = open(10) - session.keydown() - session.commit(',') - session.keydown({ key: '.', code: 'Period' }) - session.commit('。') - session.keyup() - session.keyup({ key: '.', code: 'Period' }) - expect(session.emitted.join('')).toBe('\x1b[44u\x1b[46u\x1b[44;1:3u\x1b[46;1:3u') - session.dispose() - }) - - it('leaves no release behind when a claim is retired without committing', () => { - const session = open(10) - session.keydown() - // The input source ate the first press; the next keydown retires it. - session.keydown({ key: '.', code: 'Period' }) - session.commit('。') - session.keyup() - session.keyup({ key: '.', code: 'Period' }) - expect(session.emitted.join('')).toBe('\x1b[46u\x1b[46;1:3u') - session.dispose() - }) - - it('synthesizes nothing on blur or disposal', () => { - const session = open(10) - session.keydown() - session.commit(',') - session.emitted.length = 0 - document.querySelector('.xterm')?.dispatchEvent(new FocusEvent('blur', { bubbles: true })) - session.keyup() - session.dispose() - expect(session.emitted.join('')).toBe('') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/snapshot/dashboard-agent-status-patch.ts similarity index 83% rename from src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts rename to src/renderer/src/components/dashboard-popout/snapshot/dashboard-agent-status-patch.ts index e97e46f3995..fb39706d783 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/snapshot/dashboard-agent-status-patch.ts @@ -1,15 +1,15 @@ -import { agentVerdictDisplayMark } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' -import { interactivePromptLabel } from '../../../../shared/native-chat/input/native-chat-interactive-prompt' -import { sayNativeChatApprovalTranslated } from '../native-chat/prompts/native-chat-interactive-prompt' -import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload' +import { agentVerdictDisplayMark } from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' +import { interactivePromptLabel } from '../../../../../shared/native-chat/input/native-chat-interactive-prompt' +import { sayNativeChatApprovalTranslated } from '../../native-chat/prompts/native-chat-interactive-prompt' +import type { AgentStatusIpcPayload } from '../../../../../shared/agent-status/contracts/agent-status-ipc-payload' import { dashboardCardBaseDisplayState, type DashboardCard, type DashboardCardSubagent, type DashboardSnapshot -} from '../../../../shared/dashboard-snapshot' -import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket' -import { dashboardCardDotState } from '../dashboard/dashboard-row-bucket' +} from '../../../../../shared/dashboard-snapshot' +import { dashboardBucketForDotState } from '../../dashboard/snapshot/dashboard-card-bucket' +import { dashboardCardDotState } from '../../dashboard/rows/dashboard-row-bucket' import { isAgentTurnUnacknowledged } from '@/attention/agent-attention-acknowledgement' export type DashboardAgentStatusPatchResult = { diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/snapshot/useDashboardSnapshot.ts similarity index 97% rename from src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts rename to src/renderer/src/components/dashboard-popout/snapshot/useDashboardSnapshot.ts index d5332c4d99b..9902fe66fb7 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard-popout/snapshot/useDashboardSnapshot.ts @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from 'react' import { flushSync } from 'react-dom' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' import { EMPTY_DASHBOARD_SNAPSHOT, type DashboardSnapshot -} from '../../../../shared/dashboard-snapshot' +} from '../../../../../shared/dashboard-snapshot' import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' const TOPOLOGY_REFRESH_DEBOUNCE_MS = 250 diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.focus.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.focus.test.tsx deleted file mode 100644 index 5ed42ac97d4..00000000000 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.focus.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import type { ComponentProps } from 'react' -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import type { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' - -const mocks = vi.hoisted(() => ({ - activateAndRevealWorkspace: vi.fn(() => true), - activateTabAndFocusPane: vi.fn(), - openPopout: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('@/lib/workspace/activation/worktree-activation', () => ({ - activateAndRevealWorkspace: mocks.activateAndRevealWorkspace -})) -vi.mock('@/lib/workspace/tabs/activate-tab-and-focus-pane', () => ({ - activateTabAndFocusPane: mocks.activateTabAndFocusPane -})) -vi.mock('./useLiveDashboardSnapshot', () => ({ - useLiveDashboardSnapshot: () => ({ generatedAt: 1, cards: [] }) -})) -vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({ - AgentKanbanBoard: ({ - onClose, - onRevealAgent, - headerActions - }: ComponentProps) => ( - <> - - - - {headerActions} - - ) -})) -vi.mock('./AgentDashboardSettingsMenu', () => ({ - AgentDashboardSettingsMenu: ({ onSwitchToPopout }: { onSwitchToPopout: () => void }) => ( - - ) -})) - -import { AgentDashboardDrawer } from './AgentDashboardDrawer' - -const initialState = useAppStore.getInitialState() - -beforeEach(() => { - vi.clearAllMocks() - mocks.activateAndRevealWorkspace.mockReturnValue(true) - useAppStore.setState({ agentDashboardDrawerOpen: false, sidebarOpen: true, sidebarWidth: 320 }) - Object.defineProperty(window, 'api', { - configurable: true, - value: { dashboard: { openPopout: mocks.openPopout } } - }) -}) - -afterEach(async () => { - cleanup() - // Radix schedules close autofocus after unmount, including cleanup. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - }) - useAppStore.setState(initialState, true) - vi.restoreAllMocks() -}) - -function fixture(): ReturnType { - const open = (): void => useAppStore.getState().setAgentDashboardDrawerOpen(true) - return render( - <> - - - - - - ) -} - -async function openFrom(name = 'Opener A'): Promise { - const opener = screen.getByRole('button', { name }) - opener.focus() - await userEvent.keyboard('{Enter}') - expect(document.activeElement).toBe(opener) - screen.getByRole('textbox', { name: 'Search agents' }).focus() - return opener -} - -async function closed(): Promise { - await waitFor(() => expect(screen.queryByRole('textbox', { name: 'Search agents' })).toBeNull()) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - }) -} - -describe('AgentDashboardDrawer focus return', () => { - it.each(['Escape', 'Close dashboard'])('returns to the opener after %s', async (dismiss) => { - fixture() - const opener = await openFrom() - await (dismiss === 'Escape' - ? userEvent.keyboard('{Escape}') - : userEvent.click(screen.getByRole('button', { name: dismiss }))) - await closed() - expect(document.activeElement).toBe(opener) - }) - - it('keeps outside focus when the store closes the drawer', async () => { - fixture() - await openFrom() - const outside = screen.getByRole('textbox', { name: 'Outside input' }) - outside.focus() - act(() => useAppStore.getState().setAgentDashboardDrawerOpen(false)) - await closed() - expect(document.activeElement).toBe(outside) - }) - - it('lets the real outside pointerdown dismissal hand focus to its target', async () => { - fixture() - const opener = await openFrom() - const sheet = screen.getByRole('dialog', { name: 'Agents' }) - vi.spyOn(sheet, 'getBoundingClientRect').mockReturnValue(new DOMRect(320, 40, 600, 600)) - const outside = screen.getByRole('textbox', { name: 'Outside input' }) - const returned: Event[] = [] - opener.addEventListener('focus', (event) => returned.push(event)) - await userEvent.pointer({ - target: outside, - keys: '[MouseLeft]', - coords: { clientX: 1000, clientY: 100 } - }) - await closed() - expect(document.activeElement).toBe(outside) - expect(returned).toHaveLength(0) - await userEvent.keyboard('focus-test') - expect(outside).toHaveValue('focus-test') - }) - - it('restores after store dismissal and captures the next opener', async () => { - fixture() - const first = await openFrom() - act(() => useAppStore.getState().setAgentDashboardDrawerOpen(false)) - await closed() - expect(document.activeElement).toBe(first) - const second = await openFrom('Opener B') - await userEvent.keyboard('{Escape}') - await closed() - expect(document.activeElement).toBe(second) - }) - - it('does not let an old close callback take focus from a reopened drawer', async () => { - fixture() - await openFrom() - act(() => useAppStore.getState().setAgentDashboardDrawerOpen(false)) - const second = screen.getByRole('button', { name: 'Opener B' }) - second.focus() - act(() => useAppStore.getState().setAgentDashboardDrawerOpen(true)) - const search = screen.getByRole('textbox', { name: 'Search agents' }) - search.focus() - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - }) - expect(document.activeElement).toBe(search) - await userEvent.keyboard('{Escape}') - await closed() - expect(document.activeElement).toBe(second) - }) - - it.each([true, false])('restores only when agent reveal fails (success: %s)', async (success) => { - fixture() - const opener = await openFrom() - mocks.activateAndRevealWorkspace.mockReturnValue(success) - await userEvent.click(screen.getByRole('button', { name: 'Reveal agent' })) - await closed() - expect(document.activeElement === opener).toBe(!success) - }) - - it('leaves focus handoff to the popout', async () => { - fixture() - const opener = await openFrom() - await userEvent.click(screen.getByRole('button', { name: 'Pop out' })) - await closed() - expect(mocks.openPopout).toHaveBeenCalledOnce() - expect(document.activeElement).not.toBe(opener) - }) -}) diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx deleted file mode 100644 index 1094ff92a0f..00000000000 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' - -const mocks = vi.hoisted(() => ({ - useLiveDashboardSnapshot: vi.fn(() => ({ generatedAt: 1, cards: [] })), - useAgentDashboardTelemetry: vi.fn(), - blockingOverlay: false, - boardProps: null as Record | null, - activateTabAndFocusPane: vi.fn(), - activateAndRevealWorkspace: vi.fn(() => ({ primaryTabId: null }) as unknown) -})) - -vi.mock('@/lib/workspace/tabs/activate-tab-and-focus-pane', () => ({ - activateTabAndFocusPane: mocks.activateTabAndFocusPane -})) - -vi.mock('@/lib/workspace/activation/worktree-activation', () => ({ - activateAndRevealWorkspace: mocks.activateAndRevealWorkspace -})) - -vi.mock('./useLiveDashboardSnapshot', () => ({ - useLiveDashboardSnapshot: mocks.useLiveDashboardSnapshot -})) - -vi.mock('../dashboard-popout/use-agent-dashboard-telemetry', () => ({ - useAgentDashboardTelemetry: mocks.useAgentDashboardTelemetry -})) - -vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({ - AgentKanbanBoard: (props: Record) => { - mocks.boardProps = props - return mocks.blockingOverlay ?
: null - } -})) - -vi.mock('./AgentDashboardSettingsMenu', () => ({ - AgentDashboardSettingsMenu: () => null -})) - -vi.mock('../sidebar/workspace-board/surface/use-workspace-kanban-outside-dismiss', () => ({ - isWorkspaceBoardKeepOpenTarget: () => false, - useWorkspaceKanbanOutsideDismiss: () => undefined -})) - -import { AgentDashboardDrawer } from './AgentDashboardDrawer' - -const initialState = useAppStore.getInitialState() - -beforeEach(() => { - useAppStore.setState( - { - agentDashboardDrawerOpen: false, - sidebarOpen: true, - sidebarWidth: 320 - }, - false - ) - mocks.useLiveDashboardSnapshot.mockClear() - mocks.useAgentDashboardTelemetry.mockClear() - mocks.activateTabAndFocusPane.mockClear() - mocks.activateAndRevealWorkspace.mockClear() - mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) - mocks.blockingOverlay = false - mocks.boardProps = null - ;(window as unknown as { api: unknown }).api = { - dashboard: { openPopout: vi.fn().mockResolvedValue(undefined) } - } -}) - -afterEach(() => { - cleanup() - document.body.replaceChildren() - useAppStore.setState(initialState, true) -}) - -describe('AgentDashboardDrawer', () => { - it('keeps the dismissal Escape from a focused terminal but not from other fields', () => { - const terminalInput = document.createElement('textarea') - terminalInput.className = 'xterm-helper-textarea' - const field = document.createElement('input') - document.body.append(terminalInput, field) - const terminalKeyDown = vi.fn() - const fieldKeyDown = vi.fn() - terminalInput.addEventListener('keydown', terminalKeyDown) - field.addEventListener('keydown', fieldKeyDown) - render() - - act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) - fireEvent.keyDown(terminalInput, { key: 'Escape' }) - expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) - expect(terminalKeyDown).not.toHaveBeenCalled() - - act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) - fireEvent.keyDown(field, { key: 'Escape' }) - expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) - expect(fieldKeyDown).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx index 157d9b99e8a..5f6502ba077 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -2,10 +2,10 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { useAppStore } from '@/store' import type { AgentSubjectReadIntent } from '@/attention/agent-subject-read-actions' import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' -import { revealDashboardAgent } from './reveal-dashboard-agent' -import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' +import { revealDashboardAgent } from './navigation/reveal-dashboard-agent' +import { AgentKanbanBoard } from './board/AgentKanbanBoard' import { isXtermHelperTextarea } from '../terminal-pane/input/focus/regular-terminal-focus-ownership' -import type { AgentRevealArgs } from '../dashboard-popout/AgentTerminalDialog' +import type { DashboardRevealAgentArgs as AgentRevealArgs } from '../../../../shared/dashboard-snapshot' import { isWorkspaceBoardKeepOpenTarget, useWorkspaceKanbanOutsideDismiss @@ -14,9 +14,9 @@ import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from '../sidebar/workspace-chrome-metrics' -import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu' -import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot' -import { useAgentDashboardTelemetry } from '../dashboard-popout/use-agent-dashboard-telemetry' +import { AgentDashboardSettingsMenu } from './settings/AgentDashboardSettingsMenu' +import { useLiveDashboardSnapshot } from './snapshot/useLiveDashboardSnapshot' +import { useAgentDashboardTelemetry } from './board/use-agent-dashboard-telemetry' import { useTranslate } from '@/i18n/use-translate' import { trackSidebarLiveWidth } from '@/components/sidebar/sidebar-live-width' diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 77578e0d987..a86997c77f4 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -4,7 +4,7 @@ import { opensClaudeParentConversation, viewClaudeAgentFromRow } from '@/lib/agent-session/navigation/claude-agent-row-view' -import { useClaudeAgentRowView } from './use-claude-agent-row-view' +import { useClaudeAgentRowView } from './rows/use-claude-agent-row-view' import { AgentStateDot, agentStateLabel, @@ -14,9 +14,9 @@ import { import { AgentTypeIcon } from '@/components/agent/icons/agent-type-icon' import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { DashboardAgentRowMessage } from './DashboardAgentRowMessage' -import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls' -import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' +import { DashboardAgentRowMessage } from './rows/DashboardAgentRowMessage' +import { DashboardAgentRowTrailingControls } from './rows/DashboardAgentRowTrailingControls' +import { DashboardAgentRowToolStep } from './rows/DashboardAgentRowToolStep' import { programStatusAttentionLine } from '../../../../shared/program-status/presentation' import { agentRowToolInput, @@ -37,10 +37,10 @@ import { agentVerdictDisplayMark } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' import { agentVerdictStatusLine } from '@/lib/agent-status/presentation/agent-verdict-status-line' -import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from './rows/dashboard-agent-row' import { getAgentRowPrimaryText } from '../../../../shared/agent-status/presentation/agent-row-primary-text' -import { useAgentRowConversationName } from './use-agent-row-conversation-name' -import { lastEnteredDoneAt } from './agent-finished-timestamp' +import { useAgentRowConversationName } from './rows/use-agent-row-conversation-name' +import { lastEnteredDoneAt } from './rows/agent-finished-timestamp' import { agentChildRowMessageLine, agentChildRowNoUpdateLabel diff --git a/src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx b/src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx deleted file mode 100644 index f75b08695cb..00000000000 --- a/src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import type * as RuntimeRpcCallModule from '@/runtime/rpc/calls/runtime-rpc-call' -// @vitest-environment happy-dom - -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' -import { - makeCreatedAgentWorktree, - seedAlreadyActiveWorktree -} from '@/lib/workspace/activation/worktree-activation-created-agent.test-fixture' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import type { Tab } from '../../../../shared/tab-types' - -import type * as WebRuntimeSessionEnvironment from '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment' -import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' -import WorktreeCardAgents from '../sidebar/workspace-card/activity/WorktreeCardAgents' -import { TooltipProvider } from '@/components/ui/tooltip' -import { buildDashboardSnapshot } from './build-dashboard-snapshot' -import { revealDashboardAgent } from './reveal-dashboard-agent' -import { activateRetainedAgentRow } from '../sidebar/workspace-card/activity/activate-retained-agent-row' -import { activateWorktreeAgentPane } from '../sidebar/workspace-card/activity/use-worktree-agent-activation' -import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection' - -const effects = vi.hoisted(() => ({ - rpc: vi.fn(async () => ({ ok: true })), - composerFocus: vi.fn(), - environmentCall: vi.fn(async (_environmentId: string, _args: unknown) => ({ - id: 'navigation-test', - ok: false as const, - error: { code: 'unavailable', message: 'Unit-test transport boundary' } - })) -})) -vi.mock('@/runtime/rpc/calls/runtime-rpc-call', async (importOriginal) => ({ - ...(await importOriginal()), - callRuntimeRpc: effects.rpc -})) -vi.mock('@/components/native-chat/composer/focus/native-chat-composer-focus-intent', () => ({ - requestNativeChatComposerFocus: effects.composerFocus -})) -vi.mock('../dashboard-popout/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null })) -vi.mock( - '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment', - async (importOriginal) => ({ - ...(await importOriginal()), - captureRuntimeEnvironmentCall: (environmentId: string) => (args: unknown) => - effects.environmentCall(environmentId, args) - }) -) - -const initialState = useAppStore.getState() -const LEAF = '11111111-1111-4111-8111-111111111111' -const WORKSPACE_HOST = 'runtime:host-A' as const -const TAB_HOST = 'runtime:host-B' as const -const NOW = 1_000_000_000 - -afterEach(() => { - cleanup() - useAppStore.setState(initialState, true) - vi.clearAllMocks() - vi.restoreAllMocks() -}) - -describe('Dashboard and retained sidebar destination contract', () => { - it.each([ - ['worktree', 'native'], - ['folder', 'native'], - ['worktree', 'terminal'], - ['folder', 'terminal'] - ] as const)( - 'opens the exact %s %s destination and preserves reveal failure', - async (scope, surface) => { - const native = surface === 'native' - const owner = native ? TAB_HOST : WORKSPACE_HOST - const worktree = { - ...makeCreatedAgentWorktree(), - hostId: WORKSPACE_HOST, - ...(scope === 'folder' ? { id: folderWorkspaceKey('folder-1') } : {}) - } - seedAlreadyActiveWorktree(worktree) - const seeded = useAppStore.getState() - const terminal = { ...seeded.tabsByWorktree[worktree.id][0], executionHostId: owner } - const tabId = native ? 'native-B' : terminal.id - const paneKey = makePaneKey(tabId, LEAF) - const group = seeded.groupsByWorktree[worktree.id][0] - const nativeTab: Tab = { - id: tabId, - worktreeId: worktree.id, - executionHostId: TAB_HOST, - contentType: 'agent-session', - entityId: 'session-B', - groupId: group.id, - label: 'Codex', - customLabel: null, - color: null, - sortOrder: 1, - createdAt: NOW - } - const folder = { - id: 'folder-1', - projectGroupId: 'project-1', - name: 'Notes', - folderPath: '/workspace/notes', - executionHostId: WORKSPACE_HOST, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: NOW, - createdAt: NOW, - updatedAt: NOW - } - useAppStore.setState({ - activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - activeTabType: 'editor', - agentActivityDisplayMode: 'compact', - repos: scope === 'folder' ? [] : seeded.repos, - worktreesByRepo: - scope === 'folder' - ? {} - : { - [worktree.repoId]: [ - worktree, - ...(native ? [{ ...worktree, hostId: TAB_HOST }] : []) - ] - }, - folderWorkspaces: - scope === 'folder' - ? [folder, ...(native ? [{ ...folder, executionHostId: TAB_HOST }] : [])] - : [], - getFreshFolderWorkspacePathStatus: () => ({ path: folder.folderPath, exists: true }), - tabsByWorktree: { [worktree.id]: [terminal] }, - unifiedTabsByWorktree: { - [worktree.id]: [ - ...seeded.unifiedTabsByWorktree[worktree.id], - ...(native ? [nativeTab] : []) - ] - }, - groupsByWorktree: { - [worktree.id]: [{ ...group, tabOrder: [...group.tabOrder, ...(native ? [tabId] : [])] }] - }, - terminalLayoutsByTabId: native - ? {} - : { - [tabId]: { - root: { type: 'leaf', leafId: LEAF }, - activeLeafId: LEAF, - expandedLeafId: null - } - }, - retainedAgentsByPaneKey: { - [paneKey]: { - entry: { - paneKey, - tabId, - worktreeId: worktree.id, - state: 'done', - agentType: 'codex', - prompt: 'Open this conversation', - updatedAt: NOW, - stateStartedAt: NOW, - stateHistory: [] - }, - worktreeId: worktree.id, - tab: { ...terminal, id: tabId, ptyId: native ? null : terminal.ptyId }, - agentType: 'codex', - startedAt: NOW - } - } - }) - - const snapshot = buildDashboardSnapshot( - { ...useAppStore.getState(), chatNames: new Map() }, - NOW - ) - const card = snapshot.cards.find((candidate) => candidate.executionHostId === WORKSPACE_HOST) - expect(card).toMatchObject({ worktreeId: worktree.id, tabId, ptyId: null }) - if (!card) { - throw new Error('Expected a card from the production snapshot builder') - } - // Give the virtual lane a viewport; happy-dom has no layout. - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) - const view = render( - - {}} - onRevealAgent={revealDashboardAgent} - /> - - ) - fireEvent.click(screen.getByRole('button', { name: /Open this conversation/ })) - fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) - - const assertDestination = async (): Promise => { - const state = useAppStore.getState() - expect(state.activeWorktreeId).toBe(worktree.id) - expect(getExecutionHostIdForWorktree(state, worktree.id)).toBe(owner) - expect(state.activeTabType).toBe(native ? 'agent-session' : 'terminal') - if (!native) { - expect(state.activeTabId).toBe(tabId) - } - expect(state.getActiveTab(worktree.id)?.id).toBe(tabId) - expect(state.groupsByWorktree[worktree.id][0].activeTabId).toBe(tabId) - if (native) { - expect(effects.composerFocus).toHaveBeenLastCalledWith(tabId) - expect(effects.rpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'host-B' }, - 'session.tabs.activate', - expect.objectContaining({ tabId: 'agent-session:session-B' }) - ) - } else { - await vi.waitFor(() => - expect(effects.environmentCall).toHaveBeenCalledWith( - 'host-A', - expect.objectContaining({ - method: 'session.tabs.activate', - params: expect.objectContaining({ leafId: LEAF }) - }) - ) - ) - } - } - await assertDestination() - view.unmount() - - useAppStore.setState({ - activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - activeTabType: 'editor', - activeTabId: null - }) - const sidebar = render( - - - - ) - const row = sidebar.container.querySelector('.compact-agent-row') - if (!row) { - throw new Error('Expected the retained sidebar row') - } - effects.environmentCall.mockClear() - fireEvent.click(row) - await assertDestination() - - // Keep the exact tab alive while its workspace disappears: neither entry may focus or notify it. - cleanup() - useAppStore.setState({ - worktreesByRepo: {}, - folderWorkspaces: [], - activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - activeTabType: 'editor', - activeTabId: null - }) - effects.rpc.mockClear() - effects.composerFocus.mockClear() - effects.environmentCall.mockClear() - expect(revealDashboardAgent(card)).toBe(false) - const missing = render( - - - - ) - const missingRow = missing.container.querySelector('.compact-agent-row') - if (!missingRow) { - throw new Error('Expected the retained row after workspace removal') - } - fireEvent.click(missingRow) - expect(useAppStore.getState()).toMatchObject({ - activeWorktreeId: null, - activeTabId: null, - activeTabType: 'editor' - }) - expect(effects.composerFocus).not.toHaveBeenCalled() - expect(effects.rpc).not.toHaveBeenCalled() - expect(effects.environmentCall).not.toHaveBeenCalled() - } - ) - - it('notifies the exact live retained terminal owner instead of the workspace default', async () => { - const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST } - seedAlreadyActiveWorktree(worktree, { - worktreesByRepo: { [worktree.repoId]: [worktree, { ...worktree, hostId: TAB_HOST }] }, - activeWorkspaceExecutionHostId: WORKSPACE_HOST, - ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF }, - activeLeafId: LEAF, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' } - } - } - }) - - activateRetainedAgentRow(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF)) - expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(TAB_HOST) - await vi.waitFor(() => - expect(effects.environmentCall).toHaveBeenCalledWith( - 'host-B', - expect.objectContaining({ - method: 'session.tabs.activate', - params: expect.objectContaining({ leafId: LEAF }) - }) - ) - ) - expect( - effects.environmentCall.mock.calls.every(([environmentId]) => environmentId === 'host-B') - ).toBe(true) - expect(useAppStore.getState().getActiveTab(worktree.id)?.id).toBe('tab-1') - }) -}) - -it('rejects a live terminal row on a different host before workspace activation', () => { - const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST } - seedAlreadyActiveWorktree(worktree, { - activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - activeTabId: null, - ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF }, - activeLeafId: LEAF, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' } - } - } - }) - - activateWorktreeAgentPane(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF), WORKSPACE_HOST) - - expect(useAppStore.getState()).toMatchObject({ - activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - activeTabId: null - }) -}) - -it.each(['live', 'retained'] as const)( - 'opens a %s paired chat from its physical SSH workspace', - (source) => { - const worktree = { - ...makeCreatedAgentWorktree(), - hostId: 'ssh:server' as const, - runtimeOwnerEnvironmentId: 'host-B' - } - seedAlreadyActiveWorktree(worktree, { - activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - activeTabType: 'editor', - unifiedTabsByWorktree: { - [worktree.id]: [ - { - id: 'native-B', - worktreeId: worktree.id, - executionHostId: TAB_HOST, - contentType: 'agent-session', - agentSessionAgent: 'claude', - entityId: 'session-B', - groupId: 'group-1', - label: 'Codex', - customLabel: null, - color: null, - sortOrder: 1, - createdAt: NOW - } - ] - } - }) - const activate = source === 'live' ? activateWorktreeAgentPane : activateRetainedAgentRow - activate( - worktree.id, - 'native-B', - structuredAgentSessionPaneKey('native-B', 'session-B'), - 'ssh:server' - ) - - expect(useAppStore.getState()).toMatchObject({ - activeWorktreeId: worktree.id, - activeWorkspaceExecutionHostId: 'ssh:server', - activeTabType: 'agent-session' - }) - expect(effects.composerFocus).toHaveBeenCalledWith('native-B') - expect(effects.rpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'host-B' }, - 'session.tabs.activate', - expect.objectContaining({ tabId: 'agent-session:session-B' }) - ) - } -) diff --git a/src/renderer/src/components/dashboard-popout/AgentCardLastMessages.tsx b/src/renderer/src/components/dashboard/board/AgentCardLastMessages.tsx similarity index 88% rename from src/renderer/src/components/dashboard-popout/AgentCardLastMessages.tsx rename to src/renderer/src/components/dashboard/board/AgentCardLastMessages.tsx index 61680753967..8199948ea4f 100644 --- a/src/renderer/src/components/dashboard-popout/AgentCardLastMessages.tsx +++ b/src/renderer/src/components/dashboard/board/AgentCardLastMessages.tsx @@ -1,5 +1,5 @@ -import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label' +import type { DashboardCard } from '../../../../../shared/dashboard-snapshot' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx b/src/renderer/src/components/dashboard/board/AgentDashboardFilterChips.tsx similarity index 100% rename from src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx rename to src/renderer/src/components/dashboard/board/AgentDashboardFilterChips.tsx diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard/board/AgentDashboardToolbar.tsx similarity index 97% rename from src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx rename to src/renderer/src/components/dashboard/board/AgentDashboardToolbar.tsx index f225ad2fd67..0b20e6ad6e8 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx +++ b/src/renderer/src/components/dashboard/board/AgentDashboardToolbar.tsx @@ -12,8 +12,11 @@ import { import { Input } from '@/components/ui/input' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' -import { getWorkspaceStatusVisualMeta } from '../sidebar/workspace-status/presentation/workspace-status' -import type { DashboardCard, DashboardFilterOptions } from '../../../../shared/dashboard-snapshot' +import { getWorkspaceStatusVisualMeta } from '../../sidebar/workspace-status/presentation/workspace-status' +import type { + DashboardCard, + DashboardFilterOptions +} from '../../../../../shared/dashboard-snapshot' import { activeDashboardFilterCount, type DashboardFilters, diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard/board/AgentKanbanBoard.tsx similarity index 97% rename from src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx rename to src/renderer/src/components/dashboard/board/AgentKanbanBoard.tsx index 38bfa9609d6..cd96cc478c3 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard/board/AgentKanbanBoard.tsx @@ -4,15 +4,16 @@ import { DASHBOARD_BUCKET_ORDER, type DashboardBucket, type DashboardCard, - type DashboardSnapshot -} from '../../../../shared/dashboard-snapshot' -import type { RepoIcon } from '../../../../shared/repo-icon' + type DashboardSnapshot, + type DashboardRevealAgentArgs as AgentRevealArgs +} from '../../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../../shared/repo-icon' import { cn } from '@/lib/class-names' import { TooltipProvider } from '@/components/ui/tooltip' import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval' import { AgentKanbanLaneCardList } from './AgentKanbanLaneCardList' import { AgentDashboardToolbar } from './AgentDashboardToolbar' -import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog' +import { AgentTerminalDialog } from '../terminal-preview/AgentTerminalDialog' import { EMPTY_DASHBOARD_FILTERS, filterDashboardCards, diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard/board/AgentKanbanCard.tsx similarity index 99% rename from src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx rename to src/renderer/src/components/dashboard/board/AgentKanbanCard.tsx index 7bdc989994f..fff4d97dcdb 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard/board/AgentKanbanCard.tsx @@ -16,8 +16,8 @@ import { cn } from '@/lib/class-names' import { dashboardCardDisplayState, type DashboardCard -} from '../../../../shared/dashboard-snapshot' -import type { RepoIcon } from '../../../../shared/repo-icon' +} from '../../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' import { useTranslate, type Translate } from '@/i18n/use-translate' import { DashboardHostBadge } from './DashboardHostBadge' diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanLaneCardList.tsx b/src/renderer/src/components/dashboard/board/AgentKanbanLaneCardList.tsx similarity index 97% rename from src/renderer/src/components/dashboard-popout/AgentKanbanLaneCardList.tsx rename to src/renderer/src/components/dashboard/board/AgentKanbanLaneCardList.tsx index b967d32caf4..a890956a334 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanLaneCardList.tsx +++ b/src/renderer/src/components/dashboard/board/AgentKanbanLaneCardList.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import type { RepoIcon } from '../../../../shared/repo-icon' +import type { DashboardCard } from '../../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../../shared/repo-icon' import { AgentKanbanCard } from './AgentKanbanCard' import { focusAgentKanbanCardEdge, diff --git a/src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx b/src/renderer/src/components/dashboard/board/DashboardHostBadge.tsx similarity index 95% rename from src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx rename to src/renderer/src/components/dashboard/board/DashboardHostBadge.tsx index 0e95bb2ee15..e5f5fb25f45 100644 --- a/src/renderer/src/components/dashboard-popout/DashboardHostBadge.tsx +++ b/src/renderer/src/components/dashboard/board/DashboardHostBadge.tsx @@ -3,8 +3,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { translate } from '@/i18n/i18n' import { useTranslate, type Translate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' -import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot' -import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { DashboardCardHostKind } from '../../../../../shared/dashboard-snapshot' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host' type DashboardHostBadgeProps = { hostKind?: DashboardCardHostKind diff --git a/src/renderer/src/components/dashboard-popout/FilterOptionCount.tsx b/src/renderer/src/components/dashboard/board/FilterOptionCount.tsx similarity index 100% rename from src/renderer/src/components/dashboard-popout/FilterOptionCount.tsx rename to src/renderer/src/components/dashboard/board/FilterOptionCount.tsx diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts b/src/renderer/src/components/dashboard/board/agent-board-filtering.ts similarity index 98% rename from src/renderer/src/components/dashboard-popout/agent-board-filtering.ts rename to src/renderer/src/components/dashboard/board/agent-board-filtering.ts index b7e245f766d..0062c35cc54 100644 --- a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts +++ b/src/renderer/src/components/dashboard/board/agent-board-filtering.ts @@ -1,4 +1,4 @@ -import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardCardReview } from '../../../../../shared/dashboard-snapshot' export type DashboardReviewFilter = DashboardCardReview['state'] | 'none' diff --git a/src/renderer/src/components/dashboard-popout/agent-board-transitions.css b/src/renderer/src/components/dashboard/board/agent-board-transitions.css similarity index 100% rename from src/renderer/src/components/dashboard-popout/agent-board-transitions.css rename to src/renderer/src/components/dashboard/board/agent-board-transitions.css diff --git a/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts b/src/renderer/src/components/dashboard/board/agent-dashboard-filter-options.ts similarity index 98% rename from src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts rename to src/renderer/src/components/dashboard/board/agent-dashboard-filter-options.ts index 1ec983eb14e..ff0f9ad90b2 100644 --- a/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts +++ b/src/renderer/src/components/dashboard/board/agent-dashboard-filter-options.ts @@ -1,7 +1,7 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' import { translateWorkspaceBoardStatusLabel } from '@/components/sidebar/workspace-board-status-label' -import type { DashboardCard, DashboardFilterOption } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardFilterOption } from '../../../../../shared/dashboard-snapshot' import type { DashboardReviewFilter } from './agent-board-filtering' export type FilterOption = { id: string; label: string; count: number; color?: string } diff --git a/src/renderer/src/components/dashboard-popout/agent-kanban-lane-tab-bridge.ts b/src/renderer/src/components/dashboard/board/agent-kanban-lane-tab-bridge.ts similarity index 100% rename from src/renderer/src/components/dashboard-popout/agent-kanban-lane-tab-bridge.ts rename to src/renderer/src/components/dashboard/board/agent-kanban-lane-tab-bridge.ts diff --git a/src/renderer/src/components/dashboard-popout/use-agent-dashboard-telemetry.ts b/src/renderer/src/components/dashboard/board/use-agent-dashboard-telemetry.ts similarity index 92% rename from src/renderer/src/components/dashboard-popout/use-agent-dashboard-telemetry.ts rename to src/renderer/src/components/dashboard/board/use-agent-dashboard-telemetry.ts index 9fd788ea8cc..61d6f293dcf 100644 --- a/src/renderer/src/components/dashboard-popout/use-agent-dashboard-telemetry.ts +++ b/src/renderer/src/components/dashboard/board/use-agent-dashboard-telemetry.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import type { AgentDashboardSurface } from '../../../../shared/telemetry-agent-dashboard-event-schemas' +import type { AgentDashboardSurface } from '../../../../../shared/telemetry-agent-dashboard-event-schemas' import { getConsentState, track, trackWithResult } from '@/diagnostics/telemetry/telemetry' import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval' import { useAppStore } from '@/store/index' diff --git a/src/renderer/src/components/dashboard-popout/use-agent-kanban-lane-virtualizer.ts b/src/renderer/src/components/dashboard/board/use-agent-kanban-lane-virtualizer.ts similarity index 97% rename from src/renderer/src/components/dashboard-popout/use-agent-kanban-lane-virtualizer.ts rename to src/renderer/src/components/dashboard/board/use-agent-kanban-lane-virtualizer.ts index 80ce45a05d1..15af8b6d43a 100644 --- a/src/renderer/src/components/dashboard-popout/use-agent-kanban-lane-virtualizer.ts +++ b/src/renderer/src/components/dashboard/board/use-agent-kanban-lane-virtualizer.ts @@ -1,6 +1,6 @@ import { useCallback, useLayoutEffect, useMemo, useState } from 'react' import { useVirtualizer, type Range } from '@tanstack/react-virtual' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard } from '../../../../../shared/dashboard-snapshot' import { extractPinnedVirtualRange } from '@/lib/list-interaction/pinned-virtual-range' // Estimates follow the card's fixed rows; measurement replaces them for message/question variants. diff --git a/src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.equivalence.test.ts b/src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.equivalence.test.ts new file mode 100644 index 00000000000..0881db2e2e4 --- /dev/null +++ b/src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.equivalence.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' +import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types' + +import type { FolderWorkspace } from '../../../../../shared/folder-workspaces/folder-workspace-types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' + +import type { DashboardSnapshotState } from '../snapshot/build-dashboard-snapshot' +import { + buildDashboardBucketCounts, + createDashboardBucketCountsCache +} from './build-dashboard-bucket-counts' + +const BASE = 1_700_000_000_000 +const STALE = AGENT_STATUS_STALE_AFTER_MS +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' +const PANE_1 = makePaneKey('tab1', LEAF_1) +const PANE_2 = makePaneKey('tab2', LEAF_2) +const FOLDER_WORKSPACE_ID = folderWorkspaceKey('folder-1') +const PANE_3 = makePaneKey('tab3', LEAF_3) + +function worktree(id: string): Worktree { + return { + id, + repoId: 'r1', + path: `/r1/${id}`, + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: BASE + } +} + +function tab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: BASE + } +} + +function entry( + paneKey: string, + tabId: string, + worktreeId: string, + overrides: Partial = {} +): AgentStatusEntry { + return { + paneKey, + state: 'working', + prompt: 'do the thing', + updatedAt: BASE, + stateStartedAt: BASE - 5_000, + stateHistory: [], + agentType: 'claude', + tabId, + worktreeId, + ...overrides + } +} + +function leafLayout(tabId: string, leafId: string) { + return { + root: { type: 'leaf', leafId } as const, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: `pty-${tabId}` } + } +} + +function folderWorkspace(): FolderWorkspace { + return { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Docs workspace', + folderPath: '/workspace/docs', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: BASE, + createdAt: BASE, + updatedAt: BASE + } +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Documentation', + parentPath: '/workspace', + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: BASE, + updatedAt: BASE + } +} + +function baseState(): DashboardSnapshotState { + return { + repos: [ + { + id: 'r1', + path: '/r1', + displayName: 'Repo One', + badgeColor: '#000', + addedAt: 1 + } + ], + worktreesByRepo: { r1: [worktree('w1'), worktree('w2')] }, + folderWorkspaces: [folderWorkspace()], + projectGroups: [projectGroup()], + tabsByWorktree: { + w1: [tab('tab1', 'w1')], + w2: [tab('tab2', 'w2')], + [FOLDER_WORKSPACE_ID]: [tab('tab3', FOLDER_WORKSPACE_ID)] + }, + agentStatusByPaneKey: { + [PANE_1]: entry(PANE_1, 'tab1', 'w1'), + [PANE_2]: entry(PANE_2, 'tab2', 'w2', { state: 'done' }), + [PANE_3]: entry(PANE_3, 'tab3', FOLDER_WORKSPACE_ID, { + state: 'waiting' + }) + }, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: { + tab1: leafLayout('tab1', LEAF_1), + tab2: leafLayout('tab2', LEAF_2), + tab3: leafLayout('tab3', LEAF_3) + }, + ptyIdsByTabId: { + tab1: ['pty-tab1'], + tab2: ['pty-tab2'], + tab3: ['pty-tab3'] + }, + runtimePaneTitlesByTabId: { tab1: { 0: 'shell' } }, + acknowledgedAgentsByPaneKey: {}, + settings: null + } as unknown as DashboardSnapshotState +} + +describe('buildDashboardBucketCounts equivalence with the unmemoized walk', () => { + it('keeps the last-fresh instant working and decays one ms later', () => { + const cache = createDashboardBucketCountsCache() + const state = baseState() + + expect(buildDashboardBucketCounts(state, BASE, cache, 1).working).toBe(1) + + // At now-observedAt === STALE the cache still hits and the entry remains fresh. + const atBoundary = buildDashboardBucketCounts(state, BASE + STALE, cache, 1) + expect(atBoundary.working).toBe(1) + + const pastBoundary = buildDashboardBucketCounts(state, BASE + STALE + 1, cache, 2) + expect(pastBoundary.working).toBe(0) + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts b/src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.ts similarity index 89% rename from src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts rename to src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.ts index ae817a14b5f..7868b30733a 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts +++ b/src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.ts @@ -1,26 +1,26 @@ -import type { DashboardBucket } from '../../../../shared/dashboard-snapshot' -import type { DashboardSnapshotState } from './build-dashboard-snapshot' +import type { DashboardBucket } from '../../../../../shared/dashboard-snapshot' +import type { DashboardSnapshotState } from '../snapshot/build-dashboard-snapshot' import { collectActiveDashboardWorkspaces, type ActiveDashboardWorkspace, type DashboardWorkspaceState -} from './dashboard-snapshot-workspaces' -import { selectDashboardOrchestration } from './dashboard-orchestration-selection' +} from '../snapshot/dashboard-snapshot-workspaces' +import { selectDashboardOrchestration } from '../snapshot/dashboard-orchestration-selection' import { dashboardRowBucketProjection, readAgentRowTurnRecords, type AgentRowUnreadState -} from './dashboard-row-bucket' -import type { DashboardAgentRowWithLineage } from './agent-row-lineage' -import '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' -import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index' +} from '../rows/dashboard-row-bucket' +import type { DashboardAgentRowWithLineage } from '../rows/agent-row-lineage' +import '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' +import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-index' import { createWorktreeAgentRowsCache, finishWorktreeAgentRowsCachePass, selectWorktreeAgentRowsCached, startWorktreeAgentRowsCachePass, type WorktreeAgentRowsCache -} from './worktree-agent-rows-cache' +} from '../rows/worktree-agent-rows-cache' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' const EMPTY_COUNTS: Record = { diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/bucket-counts/useAgentBucketCounts.ts similarity index 97% rename from src/renderer/src/components/dashboard/useAgentBucketCounts.ts rename to src/renderer/src/components/dashboard/bucket-counts/useAgentBucketCounts.ts index 4beec47fcf6..dc93a7c6833 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/bucket-counts/useAgentBucketCounts.ts @@ -1,7 +1,7 @@ import { useMemo, useRef } from 'react' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import type { AppState } from '@/store/types' -import type { DashboardBucket } from '../../../../shared/dashboard-snapshot' +import type { DashboardBucket } from '../../../../../shared/dashboard-snapshot' import { buildDashboardBucketCounts, createDashboardBucketCountsCache diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts deleted file mode 100644 index a1d0bc2aad8..00000000000 --- a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness' -import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' -import type { DashboardBucket } from '../../../../shared/dashboard-snapshot' -import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' -import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index' -import type { DashboardSnapshotState } from './build-dashboard-snapshot' -import { - buildDashboardBucketCounts, - createDashboardBucketCountsCache -} from './build-dashboard-bucket-counts' -import { selectDashboardOrchestration } from './dashboard-orchestration-selection' -import { dashboardRowBucketProjection, readAgentRowTurnRecords } from './dashboard-row-bucket' -import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces' -import { selectWorktreeAgentRowsCached } from './worktree-agent-rows-cache' - -const BASE = 1_700_000_000_000 -const STALE = AGENT_STATUS_STALE_AFTER_MS -const LEAF_1 = '11111111-1111-4111-8111-111111111111' -const LEAF_2 = '22222222-2222-4222-8222-222222222222' -const LEAF_3 = '33333333-3333-4333-8333-333333333333' -const PANE_1 = makePaneKey('tab1', LEAF_1) -const PANE_2 = makePaneKey('tab2', LEAF_2) -const FOLDER_WORKSPACE_ID = folderWorkspaceKey('folder-1') -const PANE_3 = makePaneKey('tab3', LEAF_3) - -/** Unmemoized reference using shared sidebar primitives, independent of the memoized builder. */ -function oracleBucketCounts( - state: DashboardSnapshotState, - now: number -): Record { - const counts: Record = { - attention: 0, - working: 0, - done: 0, - idle: 0 - } - const activeWorktrees = collectActiveDashboardWorkspaces(state, false) - const { singletonOrchestration, orchestrationByWorktree } = selectDashboardOrchestration( - state, - activeWorktrees - ) - for (const { worktree } of activeWorktrees) { - const rows = selectWorktreeAgentRowsCached({ - state, - worktreeId: worktree.id, - orchestration: - singletonOrchestration ?? - orchestrationByWorktree?.get(worktree.id) ?? - EMPTY_WORKTREE_AGENT_ORCHESTRATION, - now, - generation: undefined - }) - for (const row of rows) { - if (row.rowSource === 'subagent') { - continue - } - counts[dashboardRowBucketProjection(row, readAgentRowTurnRecords(state)).bucket] += 1 - } - } - return counts -} - -function worktree(id: string): Worktree { - return { - id, - repoId: 'r1', - path: `/r1/${id}`, - head: 'abc123', - branch: 'main', - isBare: false, - isMainWorktree: false, - displayName: id, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: BASE - } -} - -function tab(id: string, worktreeId: string): TerminalTab { - return { - id, - ptyId: `pty-${id}`, - worktreeId, - title: 'shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: BASE - } -} - -function entry( - paneKey: string, - tabId: string, - worktreeId: string, - overrides: Partial = {} -): AgentStatusEntry { - return { - paneKey, - state: 'working', - prompt: 'do the thing', - updatedAt: BASE, - stateStartedAt: BASE - 5_000, - stateHistory: [], - agentType: 'claude', - tabId, - worktreeId, - ...overrides - } -} - -function leafLayout(tabId: string, leafId: string) { - return { - root: { type: 'leaf', leafId } as const, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: `pty-${tabId}` } - } -} - -function folderWorkspace(): FolderWorkspace { - return { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Docs workspace', - folderPath: '/workspace/docs', - connectionId: null, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: BASE, - createdAt: BASE, - updatedAt: BASE - } -} - -function projectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Documentation', - parentPath: '/workspace', - connectionId: null, - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: BASE, - updatedAt: BASE - } -} - -function baseState(): DashboardSnapshotState { - return { - repos: [ - { - id: 'r1', - path: '/r1', - displayName: 'Repo One', - badgeColor: '#000', - addedAt: 1 - } - ], - worktreesByRepo: { r1: [worktree('w1'), worktree('w2')] }, - folderWorkspaces: [folderWorkspace()], - projectGroups: [projectGroup()], - tabsByWorktree: { - w1: [tab('tab1', 'w1')], - w2: [tab('tab2', 'w2')], - [FOLDER_WORKSPACE_ID]: [tab('tab3', FOLDER_WORKSPACE_ID)] - }, - agentStatusByPaneKey: { - [PANE_1]: entry(PANE_1, 'tab1', 'w1'), - [PANE_2]: entry(PANE_2, 'tab2', 'w2', { state: 'done' }), - [PANE_3]: entry(PANE_3, 'tab3', FOLDER_WORKSPACE_ID, { - state: 'waiting' - }) - }, - retainedAgentsByPaneKey: {}, - migrationUnsupportedByPtyId: {}, - runtimeAgentOrchestrationByPaneKey: {}, - terminalLayoutsByTabId: { - tab1: leafLayout('tab1', LEAF_1), - tab2: leafLayout('tab2', LEAF_2), - tab3: leafLayout('tab3', LEAF_3) - }, - ptyIdsByTabId: { - tab1: ['pty-tab1'], - tab2: ['pty-tab2'], - tab3: ['pty-tab3'] - }, - runtimePaneTitlesByTabId: { tab1: { 0: 'shell' } }, - acknowledgedAgentsByPaneKey: {}, - settings: null - } as unknown as DashboardSnapshotState -} - -/** Walk one cache across freshness boundaries; generation changes only when decay may shift buckets, and the exact last-fresh instant must still hit. */ -const CLOCK_WALK: { label: string; now: number; generation: number }[] = [ - { label: 'cold', now: BASE, generation: 1 }, - { label: 'last fresh instant (cache hit)', now: BASE + STALE, generation: 1 }, - { label: 'first stale instant', now: BASE + STALE + 1, generation: 2 }, - { label: 'long stale (cache hit)', now: BASE + STALE * 4, generation: 2 }, - { label: 'long stale (recomputed)', now: BASE + STALE * 4, generation: 3 } -] - -type Mutation = { - label: string - apply: (state: DashboardSnapshotState) => DashboardSnapshotState -} - -const MUTATIONS: Mutation[] = [ - { label: 'unchanged', apply: (state) => state }, - { - label: 'acknowledgement written', - apply: (state) => ({ - ...state, - acknowledgedAgentsByPaneKey: { [PANE_2]: BASE + STALE * 8 } - }) - }, - { - label: 'unrelated pane-title frame', - apply: (state) => ({ - ...state, - runtimePaneTitlesByTabId: { - ...state.runtimePaneTitlesByTabId, - tab2: { 0: 'sh' } - } - }) - }, - { - label: 'status write on one worktree', - apply: (state) => ({ - ...state, - agentStatusByPaneKey: { - ...state.agentStatusByPaneKey, - [PANE_1]: entry(PANE_1, 'tab1', 'w1', { - prompt: 'streamed', - state: 'blocked' - }) - } - }) - }, - { - label: 'worktree leaves the active set', - apply: (state) => ({ ...state, worktreesByRepo: { r1: [worktree('w1')] } }) - }, - { - label: 'folder workspace archived', - apply: (state) => ({ - ...state, - folderWorkspaces: [{ ...folderWorkspace(), isArchived: true }] - }) - }, - { - label: 'project group renamed', - apply: (state) => ({ - ...state, - projectGroups: [{ ...projectGroup(), name: 'Renamed' }] - }) - }, - { - label: 'pty goes away', - apply: (state) => ({ - ...state, - ptyIdsByTabId: { tab2: ['pty-tab2'], tab3: ['pty-tab3'] } - }) - } -] - -describe('buildDashboardBucketCounts equivalence with the unmemoized walk', () => { - it('matches the oracle when mutations are applied cumulatively through one cache', () => { - const cache = createDashboardBucketCountsCache() - let state = baseState() - for (const mutation of MUTATIONS) { - state = mutation.apply(state) - for (const step of CLOCK_WALK) { - expect( - buildDashboardBucketCounts(state, step.now, cache, step.generation), - `${mutation.label} @ ${step.label}` - ).toEqual(oracleBucketCounts(state, step.now)) - } - } - }) - - it('serves the last-fresh instant from a cache hit and still decays one ms later', () => { - const cache = createDashboardBucketCountsCache() - const state = baseState() - - expect(buildDashboardBucketCounts(state, BASE, cache, 1).working).toBe(1) - expect(cache.lastComputedWorktreeIds.length).toBe(3) - - // At now-observedAt === STALE the cache still hits and the entry remains fresh. - const atBoundary = buildDashboardBucketCounts(state, BASE + STALE, cache, 1) - expect(cache.lastComputedWorktreeIds).toEqual([]) - expect(atBoundary.working).toBe(1) - expect(atBoundary).toEqual(oracleBucketCounts(state, BASE + STALE)) - - const pastBoundary = buildDashboardBucketCounts(state, BASE + STALE + 1, cache, 2) - expect(pastBoundary.working).toBe(0) - expect(pastBoundary).toEqual(oracleBucketCounts(state, BASE + STALE + 1)) - }) -}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts deleted file mode 100644 index 3f312091e61..00000000000 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - buildDashboardSnapshot, - type DashboardSnapshotInput as DashboardSnapshotState -} from './build-dashboard-snapshot' - -import type { - AgentStatusEntry, - AgentStatusOrchestrationContext -} from '../../../../shared/agent-status/contracts/agent-status-types' - -import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' -import '../sidebar/workspace-card/activity/worktree-agent-row-selectors' -import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index' -import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' -import type * as AgentRowLineageModule from './agent-row-lineage' - -const mapMetadataCalls = vi.hoisted(() => ({ - hostKind: vi.fn(), - parentPaneKey: vi.fn() -})) - -vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - dashboardCardMapWorkspaceMetadata: ( - ...args: Parameters - ) => { - mapMetadataCalls.hostKind() - return actual.dashboardCardMapWorkspaceMetadata(...args) - } - } -}) - -vi.mock('./agent-row-lineage', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - dashboardCardParentPaneKey: (...args: Parameters) => { - mapMetadataCalls.parentPaneKey() - return actual.dashboardCardParentPaneKey(...args) - } - } -}) - -const NOW = 1_000_000_000 -const TAB_ID = 'tab1' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -const SPLIT_SIBLING_LEAF_ID = '55555555-5555-4555-8555-555555555555' -const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) - -function entry(overrides: Partial): AgentStatusEntry { - return { - paneKey: PANE_KEY, - state: 'working', - prompt: 'do the thing', - updatedAt: NOW, - stateStartedAt: NOW - 5000, - stateHistory: [], - agentType: 'claude', - tabId: TAB_ID, - worktreeId: 'w1', - ...overrides - } -} - -function tab(id = TAB_ID, worktreeId = 'w1'): TerminalTab { - return { - id, - ptyId: 'pty1', - worktreeId, - title: 'agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: NOW - } -} - -function worktree(id = 'w1', displayName = 'wt-one'): Worktree { - return { - id, - repoId: 'r1', - path: `/r1/${id}`, - head: 'abc123', - branch: 'main', - isBare: false, - isMainWorktree: false, - displayName, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: NOW - } -} - -function baseState(overrides: Partial): DashboardSnapshotState { - return { - chatNames: new Map(), - repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: NOW }], - worktreesByRepo: { r1: [worktree()] }, - tabsByWorktree: { w1: [tab()] }, - agentStatusByPaneKey: {}, - retainedAgentsByPaneKey: {}, - migrationUnsupportedByPtyId: {}, - runtimeAgentOrchestrationByPaneKey: {}, - terminalLayoutsByTabId: { - [TAB_ID]: { - root: { type: 'leaf', leafId: LEAF_ID }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: 'pty1' } - } - }, - ptyIdsByTabId: { [TAB_ID]: ['pty1'] }, - runtimePaneTitlesByTabId: {}, - acknowledgedAgentsByPaneKey: {}, - settings: null, - ...overrides - } -} - -describe('buildDashboardSnapshot', () => { - it('names each pane of a split tab from its own title', () => { - const siblingPaneKey = makePaneKey(TAB_ID, SPLIT_SIBLING_LEAF_ID) - const snapshot = buildDashboardSnapshot( - baseState({ - agentStatusByPaneKey: { - [PANE_KEY]: entry({}), - [siblingPaneKey]: entry({ paneKey: siblingPaneKey }) - }, - // The tab title is whichever pane has focus, so it must not name both. - tabsByWorktree: { w1: [{ ...tab(), title: '\u2733 Linear work log' }] }, - terminalLayoutsByTabId: { - [TAB_ID]: { - root: { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: LEAF_ID }, - second: { type: 'leaf', leafId: SPLIT_SIBLING_LEAF_ID } - }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: 'pty1', [SPLIT_SIBLING_LEAF_ID]: 'pty2' } - } - }, - ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty2'] }, - // Pane ids are replay-creation-ordered: 1 -> first leaf, 2 -> second. - runtimePaneTitlesByTabId: { - [TAB_ID]: { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' } - } - }), - NOW - ) - - const nameByPaneKey = new Map( - snapshot.cards.map((card) => [card.paneKey, card.conversationName]) - ) - expect(nameByPaneKey.get(PANE_KEY)).toBe('Linear work log') - expect(nameByPaneKey.get(siblingPaneKey)).toBe('Redis cache strategy') - }) - - it('nulls ptyId when the layout entry points at a dead pty', () => { - const snapshot = buildDashboardSnapshot( - baseState({ - agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, - // Layouts retain old PTY IDs after restart; only the live set proves preview availability. - ptyIdsByTabId: { [TAB_ID]: [] } - }), - NOW - ) - expect(snapshot.cards[0].ptyId).toBeNull() - }) - - it('labels a waiting agent with its question, not the prompt envelope JSON', () => { - const interactivePrompt = JSON.stringify({ - questions: [{ question: 'Which database?', options: [{ label: 'Postgres' }] }] - }) - const snapshot = buildDashboardSnapshot( - baseState({ - agentStatusByPaneKey: { - [PANE_KEY]: entry({ state: 'waiting', toolName: 'AskUserQuestion', interactivePrompt }) - } - }), - NOW - ) - expect(snapshot.cards[0].askSummary).toBe('Which database?') - }) - - it('skips card-only context for count snapshots', () => { - mapMetadataCalls.hostKind.mockClear() - mapMetadataCalls.parentPaneKey.mockClear() - let linkedReviewReads = 0 - let hostIdentityReads = 0 - const countWorktree = worktree() - Object.defineProperty(countWorktree, 'linkedPR', { - enumerable: true, - get: () => { - linkedReviewReads += 1 - return null - } - }) - const countRepo = { - id: 'r1', - path: '/r1', - displayName: 'Repo One', - badgeColor: '#000', - addedAt: 0, - get connectionId() { - hostIdentityReads += 1 - return null - } - } - const snapshot = buildDashboardSnapshot( - baseState({ - repos: [countRepo], - worktreesByRepo: { r1: [countWorktree] }, - agentStatusByPaneKey: { [PANE_KEY]: entry({}) } - }), - NOW, - { includeCardDetails: false, includeFilterOptions: false } - ) - - expect(snapshot.cards[0].workspaceStatusId).toBeUndefined() - expect(snapshot.cards[0].subagents).toBeUndefined() - expect(snapshot.cards[0].parentPaneKey).toBeUndefined() - expect(snapshot.cards[0].parentWorktreeId).toBeUndefined() - expect(snapshot.cards[0].hostKind).toBeUndefined() - expect(snapshot.cards[0].workspaceKind).toBeUndefined() - // A live PTY alone must not trigger host-input resolution on sidebar count rebuilds. - expect(snapshot.cards[0].ptyId).toBe('pty1') - expect(snapshot.cards[0].terminalInput).toBeUndefined() - expect(snapshot.workspaces).toBeUndefined() - expect(linkedReviewReads).toBe(0) - expect(hostIdentityReads).toBe(0) - expect(mapMetadataCalls.hostKind).not.toHaveBeenCalled() - expect(mapMetadataCalls.parentPaneKey).not.toHaveBeenCalled() - }) - - it('indexes runtime host labels once per detailed snapshot', () => { - let environmentIdReads = 0 - const environmentCount = 24 - const runtimeEnvironments = Array.from({ length: environmentCount }, (_, index) => { - const environment = { id: `environment-${index}`, name: `Builder ${index}` } - Object.defineProperty(environment, 'id', { - enumerable: true, - get: () => { - environmentIdReads += 1 - return `environment-${index}` - } - }) - return environment - }) as unknown as DashboardSnapshotState['runtimeEnvironments'] - const executionHostId = `runtime:environment-${environmentCount - 1}` as const - - const snapshot = buildDashboardSnapshot( - baseState({ - repos: [ - { - id: 'r1', - path: '/r1', - displayName: 'Repo One', - badgeColor: '#000', - addedAt: 0, - executionHostId - } - ], - worktreesByRepo: { - r1: [ - { ...worktree(), hostId: executionHostId }, - { ...worktree('w2', 'wt-two'), hostId: executionHostId } - ] - }, - runtimeEnvironments, - agentStatusByPaneKey: { [PANE_KEY]: entry({}) } - }), - NOW - ) - - expect(snapshot.cards[0].hostLabel).toBe(`Builder ${environmentCount - 1}`) - expect(environmentIdReads).toBe(environmentCount) - }) - - // Singleton/zero dashboard transitions must not release the shared orchestration index still read by mounted sidebar cards. - it('keeps batch records live and correct when production moves from multi to singleton to zero', () => { - const secondLeafId = '77777777-7777-4777-8777-777777777777' - const firstPaneKey = makePaneKey('tab-w1', LEAF_ID) - const secondPaneKey = makePaneKey('tab-w2', secondLeafId) - const runtimeAgentOrchestrationByPaneKey = { - [firstPaneKey]: { - taskId: 'task-1', - dispatchId: 'dispatch-1' - }, - [secondPaneKey]: { - taskId: 'task-2', - dispatchId: 'dispatch-2' - } - } - const multiState = baseState({ - worktreesByRepo: { r1: [worktree('w1'), worktree('w2')] }, - tabsByWorktree: { - w1: [tab('tab-w1', 'w1')], - w2: [tab('tab-w2', 'w2')] - }, - runtimeAgentOrchestrationByPaneKey - }) - const requested = ['w1', 'w2'] - const firstBatch = selectRuntimeAgentOrchestrationBatch(multiState, requested) - const firstW1 = firstBatch.get('w1') - - buildDashboardSnapshot( - baseState({ - worktreesByRepo: { r1: [worktree('w1')] }, - tabsByWorktree: multiState.tabsByWorktree, - runtimeAgentOrchestrationByPaneKey - }), - NOW - ) - const afterSingleton = selectRuntimeAgentOrchestrationBatch(multiState, requested) - expect(afterSingleton).toBe(firstBatch) - expect(afterSingleton.get('w1')).toBe(firstW1) - - buildDashboardSnapshot(baseState({ repos: [], worktreesByRepo: {} }), NOW) - const afterZero = selectRuntimeAgentOrchestrationBatch(multiState, requested) - expect(afterZero).toBe(firstBatch) - for (const worktreeId of requested) { - expect(afterZero.get(worktreeId)).toBe( - selectRuntimeAgentOrchestrationForWorktree(multiState, worktreeId) - ) - } - }) - - it('scans orchestration runtime once for a dashboard snapshot', () => { - const worktreeCount = 24 - const contextCount = 128 - let runtimeEnumerations = 0 - let contextVisits = 0 - const runtimeRecords: Record = {} - - for (let index = 0; index < contextCount; index += 1) { - const paneKey = makePaneKey( - `tab-${index % worktreeCount}`, - `33333333-3333-4333-8333-${index.toString(16).padStart(12, '0')}` - ) - runtimeRecords[paneKey] = { - taskId: `task-${index}`, - dispatchId: `dispatch-${index}`, - get parentPaneKey() { - contextVisits += 1 - return undefined - } - } - } - - const runtimeAgentOrchestrationByPaneKey = new Proxy(runtimeRecords, { - ownKeys(target) { - runtimeEnumerations += 1 - return Reflect.ownKeys(target) - } - }) - const worktrees = Array.from({ length: worktreeCount }, (_, index) => - worktree(`w${index}`, `wt-${index}`) - ) - const tabsByWorktree = Object.fromEntries( - worktrees.map((worktree, index) => [worktree.id, [tab(`tab-${index}`, worktree.id)]]) - ) - - const snapshot = buildDashboardSnapshot( - baseState({ - worktreesByRepo: { r1: worktrees }, - tabsByWorktree, - runtimeAgentOrchestrationByPaneKey - }), - NOW - ) - - expect(snapshot.cards).toEqual([]) - expect(runtimeEnumerations).toBe(1) - expect(contextVisits).toBe(contextCount) - }) -}) diff --git a/src/renderer/src/components/dashboard/navigation/agent-destination-navigation.integration.test.tsx b/src/renderer/src/components/dashboard/navigation/agent-destination-navigation.integration.test.tsx new file mode 100644 index 00000000000..2469b7e5bd7 --- /dev/null +++ b/src/renderer/src/components/dashboard/navigation/agent-destination-navigation.integration.test.tsx @@ -0,0 +1,163 @@ +import type * as RuntimeRpcCallModule from '@/runtime/rpc/calls/runtime-rpc-call' +// @vitest-environment happy-dom + +// @vitest-environment happy-dom +import { cleanup } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store/index' + +import { + makeCreatedAgentWorktree, + seedAlreadyActiveWorktree +} from '@/lib/workspace/activation/worktree-activation-created-agent.test-fixture' + +import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' + +import type * as WebRuntimeSessionEnvironment from '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment' + +import { activateRetainedAgentRow } from '../../sidebar/workspace-card/activity/activate-retained-agent-row' +import { activateWorktreeAgentPane } from '../../sidebar/workspace-card/activity/use-worktree-agent-activation' +import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection' + +const effects = vi.hoisted(() => ({ + rpc: vi.fn(async () => ({ ok: true })), + composerFocus: vi.fn(), + environmentCall: vi.fn(async (_environmentId: string, _args: unknown) => ({ + id: 'navigation-test', + ok: false as const, + error: { code: 'unavailable', message: 'Unit-test transport boundary' } + })) +})) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc: effects.rpc +})) +vi.mock('@/components/native-chat/composer/focus/native-chat-composer-focus-intent', () => ({ + requestNativeChatComposerFocus: effects.composerFocus +})) +vi.mock('../terminal-preview/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null })) +vi.mock( + '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment', + async (importOriginal) => ({ + ...(await importOriginal()), + captureRuntimeEnvironmentCall: (environmentId: string) => (args: unknown) => + effects.environmentCall(environmentId, args) + }) +) + +const initialState = useAppStore.getState() +const NOW = 1_000_000_000 +const LEAF = '11111111-1111-4111-8111-111111111111' +const WORKSPACE_HOST = 'runtime:host-A' as const +const TAB_HOST = 'runtime:host-B' as const + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.clearAllMocks() + vi.restoreAllMocks() +}) + +describe('Dashboard and retained sidebar destination contract', () => { + it('notifies the exact live retained terminal owner instead of the workspace default', async () => { + const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST } + seedAlreadyActiveWorktree(worktree, { + worktreesByRepo: { [worktree.repoId]: [worktree, { ...worktree, hostId: TAB_HOST }] }, + activeWorkspaceExecutionHostId: WORKSPACE_HOST, + ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' } + } + } + }) + + activateRetainedAgentRow(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF)) + expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(TAB_HOST) + await vi.waitFor(() => + expect(effects.environmentCall).toHaveBeenCalledWith( + 'host-B', + expect.objectContaining({ + method: 'session.tabs.activate', + params: expect.objectContaining({ leafId: LEAF }) + }) + ) + ) + expect(useAppStore.getState().getActiveTab(worktree.id)?.id).toBe('tab-1') + }) +}) + +it('rejects a live terminal row on a different host before workspace activation', () => { + const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST } + seedAlreadyActiveWorktree(worktree, { + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null, + ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' } + } + } + }) + + activateWorktreeAgentPane(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF), WORKSPACE_HOST) + + expect(useAppStore.getState()).toMatchObject({ + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null + }) +}) + +it.each([ + { label: 'live', activate: activateWorktreeAgentPane }, + { label: 'retained', activate: activateRetainedAgentRow } +])('opens a $label paired chat from its physical SSH workspace', ({ activate }) => { + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: 'ssh:server' as const, + runtimeOwnerEnvironmentId: 'host-B' + } + seedAlreadyActiveWorktree(worktree, { + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + activeTabType: 'editor', + unifiedTabsByWorktree: { + [worktree.id]: [ + { + id: 'native-B', + worktreeId: worktree.id, + executionHostId: TAB_HOST, + contentType: 'agent-session', + agentSessionAgent: 'claude', + entityId: 'session-B', + groupId: 'group-1', + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: NOW + } + ] + } + }) + activate( + worktree.id, + 'native-B', + structuredAgentSessionPaneKey('native-B', 'session-B'), + 'ssh:server' + ) + + expect(useAppStore.getState()).toMatchObject({ + activeWorktreeId: worktree.id, + activeWorkspaceExecutionHostId: 'ssh:server', + activeTabType: 'agent-session' + }) +}) diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/navigation/reveal-dashboard-agent.test.ts similarity index 72% rename from src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts rename to src/renderer/src/components/dashboard/navigation/reveal-dashboard-agent.test.ts index fad79235b6f..0f173a821d5 100644 --- a/src/renderer/src/components/dashboard/reveal-dashboard-agent.test.ts +++ b/src/renderer/src/components/dashboard/navigation/reveal-dashboard-agent.test.ts @@ -2,7 +2,7 @@ import { cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab } from '../../../../shared/tab-types' +import type { Tab } from '../../../../../shared/tab-types' const mocks = vi.hoisted(() => ({ activateAndRevealWorkspace: vi.fn(), @@ -21,7 +21,7 @@ const getState = () => ({ activateTab: mocks.activateTab, setActiveTabType: mocks.setActiveTabType }) -vi.mock('@/store', () => ({ useAppStore: { getState: () => getState() } })) +vi.mock('@/store/index', () => ({ useAppStore: { getState: () => getState() } })) vi.mock('@/store/index', () => ({ useAppStore: { getState: () => getState() } })) vi.mock('@/lib/workspace/activation/worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace @@ -45,7 +45,7 @@ vi.mock('@/runtime/rpc/targets/runtime-worktree-selector', () => ({ toRuntimeWorktreeSelector: (id: string) => `id:${id}` })) -vi.mock('../dashboard-popout/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null })) +vi.mock('../terminal-preview/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null })) import { revealDashboardAgent } from './reveal-dashboard-agent' @@ -82,21 +82,8 @@ describe('revealDashboardAgent', () => { mocks.activateAndRevealWorkspace.mockReturnValue(true) }) - it('does not fall back to a terminal when the current native owner has no runtime', () => { + it('refuses a native owner without a runtime', () => { mocks.tabs = [{ ...nativeTab('codex'), executionHostId: 'ssh:host-C' }] expect(revealDashboardAgent(args)).toBe(false) - expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() - expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() - expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() - }) - - it.each([false, true])('touches no tab when workspace reveal fails (native=%s)', (native) => { - mocks.tabs = native ? [nativeTab('codex')] : [] - mocks.activateAndRevealWorkspace.mockReturnValue(false) - expect(revealDashboardAgent(args)).toBe(false) - expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled() - expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() - expect(mocks.activateTab).not.toHaveBeenCalled() - expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts b/src/renderer/src/components/dashboard/navigation/reveal-dashboard-agent.ts similarity index 91% rename from src/renderer/src/components/dashboard/reveal-dashboard-agent.ts rename to src/renderer/src/components/dashboard/navigation/reveal-dashboard-agent.ts index fbd66384f8a..b3aeb9e963f 100644 --- a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts +++ b/src/renderer/src/components/dashboard/navigation/reveal-dashboard-agent.ts @@ -5,8 +5,8 @@ import { structuredAgentSessionOwnerForTab, structuredAgentSessionTargetForTab } from '@/runtime/native-chat/session-ownership/structured-agent-session-owner' -import { useAppStore } from '@/store' -import type { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot' +import { useAppStore } from '@/store/index' +import type { DashboardRevealAgentArgs } from '../../../../../shared/dashboard-snapshot' export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean { const state = useAppStore.getState() diff --git a/src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx b/src/renderer/src/components/dashboard/popout-bridge/DashboardPopoutBridge.tsx similarity index 100% rename from src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx rename to src/renderer/src/components/dashboard/popout-bridge/DashboardPopoutBridge.tsx diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/popout-bridge/useDashboardPopoutBridge.ts similarity index 95% rename from src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts rename to src/renderer/src/components/dashboard/popout-bridge/useDashboardPopoutBridge.ts index 1bd53b4ff5a..856485c196b 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/popout-bridge/useDashboardPopoutBridge.ts @@ -1,14 +1,17 @@ import { useEffect } from 'react' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import type { AppState } from '@/store/types' import { subscribeStructuredChatTabNames, getStructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-name-observation' -import { revealDashboardAgent } from './reveal-dashboard-agent' -import type { RepoIcon } from '../../../../shared/repo-icon' -import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' -import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' +import { revealDashboardAgent } from '../navigation/reveal-dashboard-agent' +import type { RepoIcon } from '../../../../../shared/repo-icon' +import { + buildDashboardSnapshot, + type DashboardSnapshotState +} from '../snapshot/build-dashboard-snapshot' +import { createWorktreeAgentRowsCache } from '../rows/worktree-agent-rows-cache' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' // Throttle cross-worktree snapshot rebuilds to 4Hz during agent-status bursts. diff --git a/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx b/src/renderer/src/components/dashboard/retention/RetainedAgentsSyncGate.tsx similarity index 100% rename from src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx rename to src/renderer/src/components/dashboard/retention/RetainedAgentsSyncGate.tsx diff --git a/src/renderer/src/components/dashboard/retention/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/retention/useRetainedAgents.test.ts new file mode 100644 index 00000000000..045f39e4758 --- /dev/null +++ b/src/renderer/src/components/dashboard/retention/useRetainedAgents.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { useAppStore } from '@/store/index' +import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-status/agent-pane-authority' +import type { + AgentStatusEntry, + AgentStatusState +} from '../../../../../shared/agent-status/contracts/agent-status-types' +import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' + +import type { Repo } from '../../../../../shared/repo-types' +import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { useRetainedAgentsSync } from './useRetainedAgents' + +const initialAppState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState(initialAppState, true) + resetAgentPaneAuthorityAliasesForTests() +}) + +afterEach(() => { + useAppStore.setState(initialAppState, true) + resetAgentPaneAuthorityAliasesForTests() +}) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 + } +} + +function makeWorktree(overrides?: Partial): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt-1', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +function makeTab(overrides: Partial & { id: string }): TerminalTab { + return { + worktreeId: 'wt-1', + title: 'Terminal', + ptyId: null, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function makeAgentRow(args: { + paneKey: string + state: AgentStatusState + interrupted?: boolean + mainAgent?: AgentStatusEntry['mainAgent'] +}) { + const entry: AgentStatusEntry = { + state: args.state, + prompt: 'Fix it', + updatedAt: 100, + stateStartedAt: 100, + paneKey: args.paneKey, + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude', + interrupted: args.interrupted, + mainAgent: args.mainAgent + } + + return { + paneKey: args.paneKey, + entry, + tab: makeTab({ id: 'tab-1' }), + agentType: 'claude' as const, + state: args.state, + startedAt: 1 + } +} + +describe('useRetainedAgentsSync', () => { + it('uses the freshest tab title when a real agent transition retains a completed row', async () => { + const repo = makeRepo() + const worktree = makeWorktree() + const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const row = makeAgentRow({ paneKey, state: 'done' }) + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { + [worktree.id]: [{ ...row.tab, title: '⠋ Codex is thinking' }] + }, + agentStatusByPaneKey: { [paneKey]: row.entry }, + agentStatusEpoch: initialAppState.agentStatusEpoch + 1 + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + + act(() => { + useAppStore.setState((state) => ({ + tabsByWorktree: { + [worktree.id]: [ + { + ...state.tabsByWorktree[worktree.id][0], + title: '⠙ Codex is thinking' + } + ] + } + })) + }) + act(() => { + useAppStore.setState((state) => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: state.agentStatusEpoch + 1 + })) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(useAppStore.getState().retainedAgentsByPaneKey[paneKey]?.tab.title).toBe( + '⠙ Codex is thinking' + ) + hook.unmount() + }) + + it('does not re-retain when status removal and tab closure commit before the next retention effect', async () => { + const repo = makeRepo() + const worktree = makeWorktree() + const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const row = makeAgentRow({ paneKey, state: 'done' }) + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { [worktree.id]: [row.tab] }, + agentStatusByPaneKey: { [row.paneKey]: row.entry }, + agentStatusEpoch: initialAppState.agentStatusEpoch + 1 + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + + // Why: model both teardown writes landing before the next retention effect runs. + act(() => { + useAppStore.setState((state) => ({ + tabsByWorktree: { [worktree.id]: [] }, + agentStatusByPaneKey: {}, + recentlyClosedAgentStatusTabIds: { [row.tab.id]: true }, + agentStatusEpoch: state.agentStatusEpoch + 1 + })) + }) + + const state = useAppStore.getState() + expect(state.retainedAgentsByPaneKey[row.paneKey]).toBeUndefined() + hook.unmount() + }) + + it('does not leave a ghost row when a done pane detaches into another tab', async () => { + const repo = makeRepo() + const worktree = makeWorktree() + const leafId = '11111111-1111-4111-8111-111111111111' + const sourceTab = makeTab({ id: 'tab-source', ptyId: 'pty-a' }) + const targetTab = makeTab({ id: 'tab-target' }) + const sourcePaneKey = makePaneKey(sourceTab.id, leafId) + const targetPaneKey = makePaneKey(targetTab.id, leafId) + const row = makeAgentRow({ paneKey: sourcePaneKey, state: 'done' }) + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { [worktree.id]: [sourceTab, targetTab] }, + ptyIdsByTabId: { [sourceTab.id]: ['pty-a'], [targetTab.id]: [] }, + agentStatusByPaneKey: { [sourcePaneKey]: { ...row.entry, tabId: sourceTab.id } }, + agentStatusEpoch: initialAppState.agentStatusEpoch + 1 + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + + act(() => { + useAppStore.getState().syncPaneDetachPtyOwnership({ + detachedLeafId: leafId, + detachedPtyId: 'pty-a', + sourceLayout: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: {} + }, + sourceTabId: sourceTab.id, + targetTabId: targetTab.id + }) + }) + await act(async () => { + await Promise.resolve() + }) + + const state = useAppStore.getState() + expect(state.retainedAgentsByPaneKey[sourcePaneKey]).toBeUndefined() + expect(state.retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined() + hook.unmount() + }) +}) diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/retention/useRetainedAgents.ts similarity index 91% rename from src/renderer/src/components/dashboard/useRetainedAgents.ts rename to src/renderer/src/components/dashboard/retention/useRetainedAgents.ts index fa75f806fa6..e3164cd6548 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/retention/useRetainedAgents.ts @@ -1,23 +1,23 @@ -import { agentEntrySessionStartedAt } from '../../../../shared/agent-session/lifecycle/agent-session-start-time' +import { agentEntrySessionStartedAt } from '../../../../../shared/agent-session/lifecycle/agent-session-start-time' import { useEffect, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import { isAgentStatusFresh, AGENT_STATUS_STALE_AFTER_MS -} from '../../../../shared/agent-status/contracts/agent-status-freshness' -import type { DashboardAgentRow } from './useDashboardData' +} from '../../../../../shared/agent-status/contracts/agent-status-freshness' +import type { DashboardAgentRow } from '../rows/dashboard-agent-row' import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract' -import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' -import type { Repo } from '../../../../shared/repo-types' -import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { FolderWorkspace } from '../../../../../shared/folder-workspaces/folder-workspace-types' +import type { Repo } from '../../../../../shared/repo-types' +import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-status/agent-pane-authority' -import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' -import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import { agentTurnEndedOnPurpose } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' +import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types' +import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import { agentTurnEndedOnPurpose } from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' import { createWorktreeTabBucketProjection, diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx b/src/renderer/src/components/dashboard/rows/DashboardAgentChildDisclosure.tsx similarity index 100% rename from src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx rename to src/renderer/src/components/dashboard/rows/DashboardAgentChildDisclosure.tsx diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx b/src/renderer/src/components/dashboard/rows/DashboardAgentRowMessage.tsx similarity index 100% rename from src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx rename to src/renderer/src/components/dashboard/rows/DashboardAgentRowMessage.tsx diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx b/src/renderer/src/components/dashboard/rows/DashboardAgentRowToolStep.tsx similarity index 100% rename from src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx rename to src/renderer/src/components/dashboard/rows/DashboardAgentRowToolStep.tsx diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx b/src/renderer/src/components/dashboard/rows/DashboardAgentRowTrailingControls.tsx similarity index 100% rename from src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx rename to src/renderer/src/components/dashboard/rows/DashboardAgentRowTrailingControls.tsx diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/rows/agent-finished-timestamp.ts similarity index 84% rename from src/renderer/src/components/dashboard/agent-finished-timestamp.ts rename to src/renderer/src/components/dashboard/rows/agent-finished-timestamp.ts index 40e07d55d99..f54244598f4 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/rows/agent-finished-timestamp.ts @@ -1,9 +1,9 @@ -import { agentEntryCompletionAt } from '../../../../shared/agent-status/lifecycle/agent-completion-time' +import { agentEntryCompletionAt } from '../../../../../shared/agent-status/lifecycle/agent-completion-time' import { agentTurnEndedOnPurpose, agentVerdictDisplayMark -} from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' -import type { DashboardAgentRow } from './useDashboardData' +} from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' +import type { DashboardAgentRow } from './dashboard-agent-row' /** Share completion age across sidebar and dashboard, including stopped/failed main turns and host-settled child rows. */ export function lastEnteredDoneAt( diff --git a/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts b/src/renderer/src/components/dashboard/rows/agent-row-lineage-model.test.ts similarity index 83% rename from src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts rename to src/renderer/src/components/dashboard/rows/agent-row-lineage-model.test.ts index 75d2e5688dc..d9e616943fc 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts +++ b/src/renderer/src/components/dashboard/rows/agent-row-lineage-model.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' -import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' -import type { DashboardAgentRow } from './useDashboardData' +import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types' +import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types' +import type { DashboardAgentRow } from './dashboard-agent-row' import { buildAgentRowLineageTree } from './agent-row-lineage-model' function makeTab(id: string): TerminalTab { @@ -73,16 +73,6 @@ describe('buildAgentRowLineageTree', () => { expect(tree.childPaneKeys.size).toBe(0) }) - it('does not use the call stack for a long parent chain', () => { - const rows = Array.from({ length: 10_000 }, (_, index) => - makeRow(`pane-${index}`, index > 0 ? { parentPaneKey: `pane-${index - 1}` } : {}) - ) - const tree = buildAgentRowLineageTree(rows) - expect(tree.rootRows).toEqual([rows[0]]) - expect(tree.childPaneKeys.size).toBe(rows.length - 1) - expect(tree.childrenByParentPaneKey.get('pane-9998')?.[0]).toBe(rows[9999]) - }) - it('terminates a reachable cycle introduced by duplicate pane rows', () => { const root = makeRow('root') const first = makeRow('first', { parentPaneKey: 'root' }) diff --git a/src/renderer/src/components/dashboard/agent-row-lineage-model.ts b/src/renderer/src/components/dashboard/rows/agent-row-lineage-model.ts similarity index 97% rename from src/renderer/src/components/dashboard/agent-row-lineage-model.ts rename to src/renderer/src/components/dashboard/rows/agent-row-lineage-model.ts index d450ea47f11..8a6de14e527 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage-model.ts +++ b/src/renderer/src/components/dashboard/rows/agent-row-lineage-model.ts @@ -1,4 +1,4 @@ -import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' +import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types' /** Minimal lineage input for real dashboard rows and synthetic thread-list rows. */ export type AgentLineageSourceRow = { diff --git a/src/renderer/src/components/dashboard/agent-row-lineage.ts b/src/renderer/src/components/dashboard/rows/agent-row-lineage.ts similarity index 97% rename from src/renderer/src/components/dashboard/agent-row-lineage.ts rename to src/renderer/src/components/dashboard/rows/agent-row-lineage.ts index 3d3ac4d9213..eed4d3ad865 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage.ts +++ b/src/renderer/src/components/dashboard/rows/agent-row-lineage.ts @@ -1,4 +1,4 @@ -import type { DashboardAgentRow } from './useDashboardData' +import type { DashboardAgentRow } from './dashboard-agent-row' import { buildAgentRowLineageTree } from './agent-row-lineage-model' export type AgentRowLineagePresentation = { diff --git a/src/renderer/src/components/dashboard/agent-row-pane-live-title.ts b/src/renderer/src/components/dashboard/rows/agent-row-pane-live-title.ts similarity index 72% rename from src/renderer/src/components/dashboard/agent-row-pane-live-title.ts rename to src/renderer/src/components/dashboard/rows/agent-row-pane-live-title.ts index 2beaa66b1e1..b9f60a5966d 100644 --- a/src/renderer/src/components/dashboard/agent-row-pane-live-title.ts +++ b/src/renderer/src/components/dashboard/rows/agent-row-pane-live-title.ts @@ -1,5 +1,5 @@ -import { resolveRuntimePaneTitleForLeaf } from '../../../../shared/runtime-pane-title-leaf-id' -import type { TerminalLayoutSnapshot } from '../../../../shared/workspace-layout/terminal-tab-types' +import { resolveRuntimePaneTitleForLeaf } from '../../../../../shared/runtime-pane-title-leaf-id' +import type { TerminalLayoutSnapshot } from '../../../../../shared/workspace-layout/terminal-tab-types' /** Undefined preserves single-pane tab-title fallback; null suppresses unattributable split titles so siblings cannot inherit the focused pane’s name. */ export function resolveAgentRowPaneLiveTitle( diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/rows/dashboard-agent-row.ts similarity index 68% rename from src/renderer/src/components/dashboard/useDashboardData.ts rename to src/renderer/src/components/dashboard/rows/dashboard-agent-row.ts index 2b07e1d5e90..8905352e711 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/rows/dashboard-agent-row.ts @@ -1,10 +1,10 @@ import type { AgentStatusEntry, AgentType -} from '../../../../shared/agent-status/contracts/agent-status-types' -import type { AgentRowState } from '../../../../shared/agent-status/presentation/agent-row-decay-state' -import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' -import type { AgentChildRowModel } from '../../../../shared/agent-status/presentation/agent-child-row-model' +} from '../../../../../shared/agent-status/contracts/agent-status-types' +import type { AgentRowState } from '../../../../../shared/agent-status/presentation/agent-row-decay-state' +import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types' +import type { AgentChildRowModel } from '../../../../../shared/agent-status/presentation/agent-child-row-model' export type DashboardAgentRow = { /** Synthetic subagent keys identify rows only; they have no PTY and must never be parsed as pane keys. */ diff --git a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/rows/dashboard-row-bucket.ts similarity index 87% rename from src/renderer/src/components/dashboard/dashboard-row-bucket.ts rename to src/renderer/src/components/dashboard/rows/dashboard-row-bucket.ts index e9e67668441..d226681d4f4 100644 --- a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts +++ b/src/renderer/src/components/dashboard/rows/dashboard-row-bucket.ts @@ -1,11 +1,11 @@ -import type { DashboardAgentRow } from './useDashboardData' +import type { DashboardAgentRow } from './dashboard-agent-row' import { dashboardCardBaseDisplayState, type DashboardBucket, type DashboardCardDotState -} from '../../../../shared/dashboard-snapshot' -import { dashboardBucketForDotState } from './dashboard-card-bucket' -import type { AgentRowState } from '../../../../shared/agent-status/presentation/agent-row-decay-state' +} from '../../../../../shared/dashboard-snapshot' +import { dashboardBucketForDotState } from '../snapshot/dashboard-card-bucket' +import type { AgentRowState } from '../../../../../shared/agent-status/presentation/agent-row-decay-state' import { isAgentSubjectUnread, type AgentAttentionTurnRecords diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/rows/use-agent-row-conversation-name.ts similarity index 89% rename from src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts rename to src/renderer/src/components/dashboard/rows/use-agent-row-conversation-name.ts index 1fa9c15b620..76ea9d91563 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/rows/use-agent-row-conversation-name.ts @@ -1,12 +1,12 @@ import { useAppStateValue } from '@/store/retained-views/app-state-view' -import { getAgentRowConversationName } from '../../../../shared/agent-status/presentation/agent-row-conversation-name' -import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name' +import { getAgentRowConversationName } from '../../../../../shared/agent-status/presentation/agent-row-conversation-name' +import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import { structuredChatDisplayName } from '../../../../../shared/structured-chat-row-name' import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' import { structuredChatTabById } from '@/lib/agent-session/tab-identity/structured-chat-tab-index' import { useStructuredChatTabConversationName } from '@/runtime/native-chat/naming/structured-conversation-name' import type { AppState } from '@/store/types' -import type { DashboardAgentRow } from './useDashboardData' +import type { DashboardAgentRow } from './dashboard-agent-row' type WorktreeTabs = NonNullable diff --git a/src/renderer/src/components/dashboard/use-claude-agent-row-view.ts b/src/renderer/src/components/dashboard/rows/use-claude-agent-row-view.ts similarity index 95% rename from src/renderer/src/components/dashboard/use-claude-agent-row-view.ts rename to src/renderer/src/components/dashboard/rows/use-claude-agent-row-view.ts index 239525d0a38..7eaa195aae9 100644 --- a/src/renderer/src/components/dashboard/use-claude-agent-row-view.ts +++ b/src/renderer/src/components/dashboard/rows/use-claude-agent-row-view.ts @@ -1,7 +1,7 @@ import { useCallback, useSyncExternalStore } from 'react' -import type { DashboardAgentRow } from './useDashboardData' +import type { DashboardAgentRow } from './dashboard-agent-row' import { useFocusedAgentPaneKey } from '@/components/sidebar/workspace-card/activity/focused-agent-row-highlight' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner' import { runtimeTargetForOwnerEnvironment } from '@/runtime/rpc/targets/runtime-client-target' import { diff --git a/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts b/src/renderer/src/components/dashboard/rows/worktree-agent-rows-cache.ts similarity index 92% rename from src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts rename to src/renderer/src/components/dashboard/rows/worktree-agent-rows-cache.ts index 26e33938c13..75ba7a2fcf2 100644 --- a/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts +++ b/src/renderer/src/components/dashboard/rows/worktree-agent-rows-cache.ts @@ -2,28 +2,28 @@ import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat import type { AgentStatusEntry, AgentStatusOrchestrationContext -} from '../../../../shared/agent-status/contracts/agent-status-types' -import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload' +} from '../../../../../shared/agent-status/contracts/agent-status-types' +import type { MigrationUnsupportedPtyEntry } from '../../../../../shared/agent-status/contracts/agent-status-ipc-payload' import type { TerminalLayoutSnapshot, TerminalTab -} from '../../../../shared/workspace-layout/terminal-tab-types' -import { migrationUnsupportedToAgentStatusEntry } from '../../../../shared/migration-unsupported-agent-entry' +} from '../../../../../shared/workspace-layout/terminal-tab-types' +import { migrationUnsupportedToAgentStatusEntry } from '../../../../../shared/migration-unsupported-agent-entry' import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract' import type { AppState } from '@/store/types' import { applyAgentRowLineage, type DashboardAgentRowWithLineage } from './agent-row-lineage' -import { buildWorktreeAgentRows } from '../sidebar/workspace-card/activity/worktree-agent-rows' +import { buildWorktreeAgentRows } from '../../sidebar/workspace-card/activity/worktree-agent-rows' import { selectLiveAgentStatusEntriesForWorktree, selectMigrationUnsupportedEntriesForWorktree, selectRetainedAgentEntriesForWorktree, selectTerminalLayoutsForWorktree -} from '../sidebar/workspace-card/activity/worktree-agent-row-selectors' +} from '../../sidebar/workspace-card/activity/worktree-agent-row-selectors' import { selectLivePtyIdsForWorktree, selectPaneForegroundAgentsForWorktree, selectRuntimePaneTitlesForWorktree -} from '../sidebar/workspace-card/activity/worktree-card-status-inputs' +} from '../../sidebar/workspace-card/activity/worktree-card-status-inputs' import type { PaneForegroundAgentEntry } from '@/store/slices/agent-status/pane-foreground-agent' type WorktreeAgentRowsCacheEntry = { diff --git a/src/renderer/src/components/dashboard/AgentDashboardExperimentalSetting.tsx b/src/renderer/src/components/dashboard/settings/AgentDashboardExperimentalSetting.tsx similarity index 90% rename from src/renderer/src/components/dashboard/AgentDashboardExperimentalSetting.tsx rename to src/renderer/src/components/dashboard/settings/AgentDashboardExperimentalSetting.tsx index 4f5f74314ae..d866fbe3d45 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardExperimentalSetting.tsx +++ b/src/renderer/src/components/dashboard/settings/AgentDashboardExperimentalSetting.tsx @@ -1,10 +1,10 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' import { useTranslate } from '@/i18n/use-translate' -import { Label } from '../ui/label' -import { SearchableSetting } from '../settings/section-layout/SearchableSetting' -import { SettingsSegmentedControl } from '../settings/form-controls/SettingsFormControls' +import { Label } from '../../ui/label' +import { SearchableSetting } from '../../settings/section-layout/SearchableSetting' +import { SettingsSegmentedControl } from '../../settings/form-controls/SettingsFormControls' import { Switch } from '@/components/ui/switch' -import { getExperimentalSearchEntry } from '../settings/advanced/experimental-search' +import { getExperimentalSearchEntry } from '../../settings/advanced/experimental-search' import { canOpenAgentDashboardPopout } from './agent-dashboard-open-mode' type AgentDashboardExperimentalSettingProps = { diff --git a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx b/src/renderer/src/components/dashboard/settings/AgentDashboardSettingsMenu.tsx similarity index 96% rename from src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx rename to src/renderer/src/components/dashboard/settings/AgentDashboardSettingsMenu.tsx index 8057893c52d..ba523f2449e 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx +++ b/src/renderer/src/components/dashboard/settings/AgentDashboardSettingsMenu.tsx @@ -1,5 +1,5 @@ import { Settings } from 'lucide-react' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -8,9 +8,9 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { SettingsSegmentedControl } from '../settings/form-controls/SettingsFormControls' +import { SettingsSegmentedControl } from '../../settings/form-controls/SettingsFormControls' import { Switch } from '@/components/ui/switch' -import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types' +import type { AgentDashboardMode } from '../../../../../shared/ui-chrome-types' import { useTranslate } from '@/i18n/use-translate' import { canOpenAgentDashboardPopout } from './agent-dashboard-open-mode' diff --git a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts b/src/renderer/src/components/dashboard/settings/agent-dashboard-open-mode.test.ts similarity index 64% rename from src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts rename to src/renderer/src/components/dashboard/settings/agent-dashboard-open-mode.test.ts index 2c16cf7fe3e..2e9b1f89a5a 100644 --- a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts +++ b/src/renderer/src/components/dashboard/settings/agent-dashboard-open-mode.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { toggleAgentDashboardFromShortcut } from '../../hooks/ipc-events/agent-dashboard-command' +import type { toggleAgentDashboardFromShortcut } from '../../../hooks/ipc-events/agent-dashboard-command' import { resolveAgentDashboardMode } from './agent-dashboard-open-mode' function dashboardState(): Parameters[0] { @@ -18,13 +18,9 @@ function dashboardState(): Parameters[0 describe('Agent Dashboard open mode', () => { afterEach(() => vi.unstubAllGlobals()) - it('opens the in-window board in a web client even when the setting says pop-out', () => { + it('resolves a web client to in-window despite a pop-out setting', () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) const state = dashboardState() - const openPopout = vi.fn() expect(resolveAgentDashboardMode(state.settings)).toBe('in-window') - toggleAgentDashboardFromShortcut(state, openPopout) - expect(openPopout).not.toHaveBeenCalled() - expect(state.setAgentDashboardDrawerOpen).toHaveBeenCalledWith(true) }) }) diff --git a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts b/src/renderer/src/components/dashboard/settings/agent-dashboard-open-mode.ts similarity index 77% rename from src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts rename to src/renderer/src/components/dashboard/settings/agent-dashboard-open-mode.ts index 1e570af3cd7..40957f04ae8 100644 --- a/src/renderer/src/components/dashboard/agent-dashboard-open-mode.ts +++ b/src/renderer/src/components/dashboard/settings/agent-dashboard-open-mode.ts @@ -1,5 +1,5 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import type { AgentDashboardMode } from '../../../../../shared/ui-chrome-types' import { isPairedWebClientWindow } from '@/app-shell/chrome/desktop-window-chrome' /** A browser tab cannot open a second Orca window, so a web client only has the in-window board. */ diff --git a/src/renderer/src/components/dashboard/snapshot/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/snapshot/build-dashboard-snapshot.test.ts new file mode 100644 index 00000000000..95d43a52d7a --- /dev/null +++ b/src/renderer/src/components/dashboard/snapshot/build-dashboard-snapshot.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildDashboardSnapshot, + type DashboardSnapshotInput as DashboardSnapshotState +} from './build-dashboard-snapshot' + +import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types' + +import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types' +import type { Worktree } from '../../../../../shared/worktree/types' + +import '../../sidebar/workspace-card/activity/worktree-agent-row-selectors' + +import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' +import type * as AgentRowLineageModule from '../rows/agent-row-lineage' + +const mapMetadataCalls = vi.hoisted(() => ({ + hostKind: vi.fn(), + parentPaneKey: vi.fn() +})) + +vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardMapWorkspaceMetadata: ( + ...args: Parameters + ) => { + mapMetadataCalls.hostKind() + return actual.dashboardCardMapWorkspaceMetadata(...args) + } + } +}) + +vi.mock('../rows/agent-row-lineage', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardParentPaneKey: (...args: Parameters) => { + mapMetadataCalls.parentPaneKey() + return actual.dashboardCardParentPaneKey(...args) + } + } +}) + +const NOW = 1_000_000_000 +const TAB_ID = 'tab1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +const SPLIT_SIBLING_LEAF_ID = '55555555-5555-4555-8555-555555555555' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +function entry(overrides: Partial): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'do the thing', + updatedAt: NOW, + stateStartedAt: NOW - 5000, + stateHistory: [], + agentType: 'claude', + tabId: TAB_ID, + worktreeId: 'w1', + ...overrides + } +} + +function tab(id = TAB_ID, worktreeId = 'w1'): TerminalTab { + return { + id, + ptyId: 'pty1', + worktreeId, + title: 'agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } +} + +function worktree(id = 'w1', displayName = 'wt-one'): Worktree { + return { + id, + repoId: 'r1', + path: `/r1/${id}`, + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: false, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW + } +} + +function baseState(overrides: Partial): DashboardSnapshotState { + return { + chatNames: new Map(), + repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: NOW }], + worktreesByRepo: { r1: [worktree()] }, + tabsByWorktree: { w1: [tab()] }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty1' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1'] }, + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {}, + settings: null, + ...overrides + } +} + +describe('buildDashboardSnapshot', () => { + it('names each pane of a split tab from its own title', () => { + const siblingPaneKey = makePaneKey(TAB_ID, SPLIT_SIBLING_LEAF_ID) + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({}), + [siblingPaneKey]: entry({ paneKey: siblingPaneKey }) + }, + // The tab title is whichever pane has focus, so it must not name both. + tabsByWorktree: { w1: [{ ...tab(), title: '\u2733 Linear work log' }] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SPLIT_SIBLING_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty1', [SPLIT_SIBLING_LEAF_ID]: 'pty2' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty2'] }, + // Pane ids are replay-creation-ordered: 1 -> first leaf, 2 -> second. + runtimePaneTitlesByTabId: { + [TAB_ID]: { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' } + } + }), + NOW + ) + + const nameByPaneKey = new Map( + snapshot.cards.map((card) => [card.paneKey, card.conversationName]) + ) + expect(nameByPaneKey.get(PANE_KEY)).toBe('Linear work log') + expect(nameByPaneKey.get(siblingPaneKey)).toBe('Redis cache strategy') + }) + + it('nulls ptyId when the layout entry points at a dead pty', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, + // Layouts retain old PTY IDs after restart; only the live set proves preview availability. + ptyIdsByTabId: { [TAB_ID]: [] } + }), + NOW + ) + expect(snapshot.cards[0].ptyId).toBeNull() + }) + + it('labels a waiting agent with its question, not the prompt envelope JSON', () => { + const interactivePrompt = JSON.stringify({ + questions: [{ question: 'Which database?', options: [{ label: 'Postgres' }] }] + }) + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ state: 'waiting', toolName: 'AskUserQuestion', interactivePrompt }) + } + }), + NOW + ) + expect(snapshot.cards[0].askSummary).toBe('Which database?') + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/snapshot/build-dashboard-snapshot.ts similarity index 89% rename from src/renderer/src/components/dashboard/build-dashboard-snapshot.ts rename to src/renderer/src/components/dashboard/snapshot/build-dashboard-snapshot.ts index 3bf830553b8..30ffdca19cb 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/snapshot/build-dashboard-snapshot.ts @@ -1,6 +1,6 @@ -import { agentVerdictDisplayMark } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' -import { interactivePromptLabel } from '../../../../shared/native-chat/input/native-chat-interactive-prompt' -import { sayNativeChatApprovalTranslated } from '../native-chat/prompts/native-chat-interactive-prompt' +import { agentVerdictDisplayMark } from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' +import { interactivePromptLabel } from '../../../../../shared/native-chat/input/native-chat-interactive-prompt' +import { sayNativeChatApprovalTranslated } from '../../native-chat/prompts/native-chat-interactive-prompt' import type { StructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-tab-name' import type { AppState } from '@/store/types' import { @@ -8,26 +8,26 @@ import { type DashboardCard, type DashboardSnapshot, type DashboardWorkspace -} from '../../../../shared/dashboard-snapshot' -import type { RepoIcon } from '../../../../shared/repo-icon' -import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +} from '../../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../../shared/repo-icon' +import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' import { resolveDashboardCardTerminalInput, type DashboardCardTerminalInputState } from './dashboard-card-terminal-input' import { readDashboardClientHost } from './dashboard-client-host' -import { dashboardCardParentPaneKey } from './agent-row-lineage' -import { lastEnteredDoneAt } from './agent-finished-timestamp' -import { selectTerminalLayoutsForWorktree } from '../sidebar/workspace-card/activity/worktree-agent-row-selectors' -import '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' -import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index' +import { dashboardCardParentPaneKey } from '../rows/agent-row-lineage' +import { lastEnteredDoneAt } from '../rows/agent-finished-timestamp' +import { selectTerminalLayoutsForWorktree } from '../../sidebar/workspace-card/activity/worktree-agent-row-selectors' +import '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' +import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-index' import { finishWorktreeAgentRowsCachePass, selectWorktreeAgentRowsCached, startWorktreeAgentRowsCachePass, type WorktreeAgentRowsCache -} from './worktree-agent-rows-cache' -import { selectRuntimePaneTitlesForWorktree } from '../sidebar/workspace-card/activity/worktree-card-status-inputs' +} from '../rows/worktree-agent-rows-cache' +import { selectRuntimePaneTitlesForWorktree } from '../../sidebar/workspace-card/activity/worktree-card-status-inputs' import { resolveDashboardCardContext, type DashboardCardContextState @@ -46,7 +46,7 @@ import { import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' import { groupSubagentsByParentPaneKey } from './dashboard-subagent-cards' import { selectDashboardOrchestration } from './dashboard-orchestration-selection' -import { dashboardRowBucketProjection, readAgentRowTurnRecords } from './dashboard-row-bucket' +import { dashboardRowBucketProjection, readAgentRowTurnRecords } from '../rows/dashboard-row-bucket' import type { Translate } from '@/i18n/use-translate' /** Keep builder inputs as a Pick so tests need only construct the store slices read here. */ diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-card-bucket.ts similarity index 88% rename from src/renderer/src/components/dashboard/dashboard-card-bucket.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-card-bucket.ts index 4cc7da66c3c..7b7bb368eff 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-card-bucket.ts @@ -1,7 +1,7 @@ import type { DashboardBucket, DashboardCardBaseDisplayState -} from '../../../../shared/dashboard-snapshot' +} from '../../../../../shared/dashboard-snapshot' export function dashboardBucketForDotState(state: DashboardCardBaseDisplayState): DashboardBucket { switch (state) { diff --git a/src/renderer/src/components/dashboard/dashboard-card-context.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-card-context.ts similarity index 77% rename from src/renderer/src/components/dashboard/dashboard-card-context.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-card-context.ts index 7895505d95c..e4c98a07499 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-context.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-card-context.ts @@ -1,20 +1,18 @@ import { branchName } from '@/lib/source-control/branches/branch-name' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review/hosted-review-cache-identity' import type { AppState } from '@/store/types' -import type { DashboardCardReview } from '../../../../shared/dashboard-snapshot' -import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/source-control/hosted-review/hosted-review-github' -import { isPositiveHostedReviewNumber } from '../../../../shared/source-control/hosted-review/hosted-review-contracts' -import type { Repo } from '../../../../shared/repo-types' -import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' -import { - DEFAULT_WORKSPACE_STATUSES, - getWorkspaceStatus -} from '../../../../shared/workspace-statuses' +import type { DashboardCardReview } from '../../../../../shared/dashboard-snapshot' +import { hostedReviewInfoFromGitHubPRInfo } from '../../../../../shared/source-control/hosted-review/hosted-review-github' +import { isPositiveHostedReviewNumber } from '../../../../../shared/source-control/hosted-review/hosted-review-contracts' +import type { Repo } from '../../../../../shared/repo-types' +import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +import { getWorkspaceStatus } from '../../../../../shared/workspace-statuses' +import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../shared/workspace-status-defaults' import { canUseParentPrChecksGitHubPRCacheEntry, getParentPrChecksGitHubPRCacheEntry -} from '../right-sidebar/hosted-review/folder-checks/parent-pr-checks-github-pr-cache' -import { canUseParentPrChecksHostedReviewCacheEntry } from '../right-sidebar/hosted-review/folder-checks/parent-pr-checks-hosted-review-cache' +} from '../../right-sidebar/hosted-review/folder-checks/parent-pr-checks-github-pr-cache' +import { canUseParentPrChecksHostedReviewCacheEntry } from '../../right-sidebar/hosted-review/folder-checks/parent-pr-checks-hosted-review-cache' import type { Translate } from '@/i18n/use-translate' export type DashboardCardContextState = Partial< diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-card-labels.ts similarity index 80% rename from src/renderer/src/components/dashboard/dashboard-card-labels.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-card-labels.ts index ce2310922be..c149d38f8df 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-card-labels.ts @@ -1,12 +1,12 @@ -import { getAgentRowConversationName } from '../../../../shared/agent-status/presentation/agent-row-conversation-name' -import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapshot' -import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title' +import { getAgentRowConversationName } from '../../../../../shared/agent-status/presentation/agent-row-conversation-name' +import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../../shared/dashboard-snapshot' +import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import { resolveAgentRowPaneLiveTitle } from '../rows/agent-row-pane-live-title' import { structuredChatTabById } from '@/lib/agent-session/tab-identity/structured-chat-tab-index' import { projectedStructuredChatTabName } from '@/runtime/native-chat/naming/structured-chat-tab-name' import type { StructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-tab-name' -import type { TerminalLayoutSnapshot } from '../../../../shared/workspace-layout/terminal-tab-types' -import type { DashboardAgentRow } from './useDashboardData' +import type { TerminalLayoutSnapshot } from '../../../../../shared/workspace-layout/terminal-tab-types' +import type { DashboardAgentRow } from '../rows/dashboard-agent-row' import type { AppState } from '@/store/types' export function rowTask(row: DashboardAgentRow): string { diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-card-terminal-input.ts similarity index 94% rename from src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-card-terminal-input.ts index 7e4e7f54ae7..53723a67749 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-card-terminal-input.ts @@ -1,8 +1,11 @@ import type { AppState } from '@/store/types' -import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' -import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { DashboardCardTerminalInput } from '../../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent' +import { + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../../../../shared/execution-host' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' import '@/lib/workspace/ownership/connection-context' import { getConnectionIdFromState } from '@/lib/workspace/ownership/connection-owner-resolution' import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' diff --git a/src/renderer/src/components/dashboard/dashboard-client-host.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-client-host.ts similarity index 100% rename from src/renderer/src/components/dashboard/dashboard-client-host.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-client-host.ts diff --git a/src/renderer/src/components/dashboard/dashboard-orchestration-selection.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-orchestration-selection.ts similarity index 83% rename from src/renderer/src/components/dashboard/dashboard-orchestration-selection.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-orchestration-selection.ts index bb47495f9a3..6a7c4a2c0fe 100644 --- a/src/renderer/src/components/dashboard/dashboard-orchestration-selection.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-orchestration-selection.ts @@ -3,9 +3,9 @@ import type { DashboardSnapshotState } from './build-dashboard-snapshot' import { releaseRuntimeAgentOrchestrationBatchCache, selectRuntimeAgentOrchestrationBatch -} from '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' -import '../sidebar/workspace-card/activity/worktree-agent-row-selectors' -import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index' +} from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch' +import '../../sidebar/workspace-card/activity/worktree-agent-row-selectors' +import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-index' export function selectDashboardOrchestration( state: DashboardSnapshotState, diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-filter-options.ts similarity index 85% rename from src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-filter-options.ts index 828bb029bdf..72a43b3d28b 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-filter-options.ts @@ -1,6 +1,6 @@ import type { AppState } from '@/store/types' -import type { DashboardFilterOptions } from '../../../../shared/dashboard-snapshot' -import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' +import type { DashboardFilterOptions } from '../../../../../shared/dashboard-snapshot' +import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../shared/workspace-status-defaults' import { boundedLabel } from './dashboard-card-labels' import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces' import { translateWorkspaceBoardStatusLabel } from '@/components/sidebar/workspace-board-status-label' diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-workspaces.ts similarity index 92% rename from src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-workspaces.ts index f74338407ed..07de946760e 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-workspaces.ts @@ -5,19 +5,19 @@ import { type DashboardCard, type DashboardCardHostKind, type DashboardCardWorkspaceKind -} from '../../../../shared/dashboard-snapshot' -import type { RepoIcon } from '../../../../shared/repo-icon' +} from '../../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../../shared/repo-icon' import { getWorktreeExecutionHostId, parseExecutionHostId, toRuntimeExecutionHostId, toSshExecutionHostId, type ExecutionHostId -} from '../../../../shared/execution-host' -import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree' -import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import { isFolderRepo } from '../../../../shared/repo-kind' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +} from '../../../../../shared/execution-host' +import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspaces/folder-workspace-worktree' +import { getHostDisplayLabelOverrides } from '../../../../../shared/host-setting-overrides' +import { isFolderRepo } from '../../../../../shared/repo-kind' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' export type ActiveDashboardWorkspace = { diff --git a/src/renderer/src/components/dashboard/dashboard-subagent-cards.ts b/src/renderer/src/components/dashboard/snapshot/dashboard-subagent-cards.ts similarity index 81% rename from src/renderer/src/components/dashboard/dashboard-subagent-cards.ts rename to src/renderer/src/components/dashboard/snapshot/dashboard-subagent-cards.ts index 39dabc28091..d2f4677bff6 100644 --- a/src/renderer/src/components/dashboard/dashboard-subagent-cards.ts +++ b/src/renderer/src/components/dashboard/snapshot/dashboard-subagent-cards.ts @@ -1,7 +1,7 @@ -import type { DashboardCardSubagent } from '../../../../shared/dashboard-snapshot' +import type { DashboardCardSubagent } from '../../../../../shared/dashboard-snapshot' import { nonEmpty } from './dashboard-card-labels' -import type { DashboardAgentRow } from './useDashboardData' -import { dashboardCardDotState } from './dashboard-row-bucket' +import type { DashboardAgentRow } from '../rows/dashboard-agent-row' +import { dashboardCardDotState } from '../rows/dashboard-row-bucket' /** Nest terminal-less subagents on the card of their spawning parent pane. */ export function groupSubagentsByParentPaneKey( diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/snapshot/useLiveDashboardSnapshot.ts similarity index 96% rename from src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts rename to src/renderer/src/components/dashboard/snapshot/useLiveDashboardSnapshot.ts index 481ae3e6d2d..bae0f7d7304 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/snapshot/useLiveDashboardSnapshot.ts @@ -1,9 +1,9 @@ import { useMemo, useRef } from 'react' -import { useAppStore } from '@/store' +import { useAppStore } from '@/store/index' import { useStructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-name-observation' -import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { DashboardSnapshot } from '../../../../../shared/dashboard-snapshot' import { buildDashboardSnapshot } from './build-dashboard-snapshot' -import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' +import { createWorktreeAgentRowsCache } from '../rows/worktree-agent-rows-cache' import { selectDetectedWorktreesByRepo, selectFolderWorkspaces, diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard/terminal-preview/AgentTerminalDialog.tsx similarity index 95% rename from src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx rename to src/renderer/src/components/dashboard/terminal-preview/AgentTerminalDialog.tsx index 47ae4dff869..77c9a229d00 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard/terminal-preview/AgentTerminalDialog.tsx @@ -1,24 +1,21 @@ import { useEffect, useId, useRef } from 'react' import { SquareArrowOutUpRight, XIcon } from 'lucide-react' import { AgentTypeIcon } from '@/components/agent/icons/agent-type-icon' -import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label' +import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label' import { agentStateLabel } from '@/components/AgentStateDot' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { dashboardCardDisplayState, type DashboardCard, - type DashboardRevealAgentArgs -} from '../../../../shared/dashboard-snapshot' + type DashboardRevealAgentArgs as AgentRevealArgs +} from '../../../../../shared/dashboard-snapshot' import { AgentTerminalPreview } from './AgentTerminalPreview' -import { AgentCardLastMessages, hasAgentCardLastMessages } from './AgentCardLastMessages' +import { AgentCardLastMessages, hasAgentCardLastMessages } from '../board/AgentCardLastMessages' import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' -/** Routing payload for focusing an agent's pane in the main window. */ -export type AgentRevealArgs = DashboardRevealAgentArgs - type AgentTerminalDialogProps = { /** The agent shown in the dialog; null renders the dialog closed. */ card: DashboardCard | null diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard/terminal-preview/AgentTerminalPreview.tsx similarity index 91% rename from src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx rename to src/renderer/src/components/dashboard/terminal-preview/AgentTerminalPreview.tsx index 952a64d9b1f..43601de9520 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard/terminal-preview/AgentTerminalPreview.tsx @@ -2,35 +2,35 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { composeActiveTerminalTheme } from '@/components/terminal-pane/appearance/terminal-appearance' import { useSystemPrefersDark } from '@/hooks/theme/use-system-prefers-dark' -import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker' -import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-replay' +import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker' +import { replayPreviewConnectionSnapshot } from './transport/preview-terminal-snapshot-replay' import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { buildPreviewAppearanceOptions, buildPreviewTerminalOptions, previewAdvertisesKittyKeyboard -} from './preview-terminal-options' -import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures' +} from '../../standalone-terminal/rendering/preview-terminal-options' +import { syncPreviewTerminalLigatures } from '../../standalone-terminal/rendering/preview-terminal-ligatures' import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message' import { resolveEffectiveTerminalAppearance } from '@/lib/terminal/appearance/terminal-theme' import { cn } from '@/lib/class-names' import { useAppStore } from '@/store/index' import { usePluginFontReady } from '@/store/plugins/appearance/plugin-fonts' -import { createPreviewGridClaim } from './preview-grid-claim' -import { createPreviewBoxFit } from './preview-terminal-box-fit' -import { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal' -import type { StandaloneTerminal } from '../standalone-terminal/open-standalone-terminal' -import { terminalPreviewApiFor } from './terminal-preview-api' +import { createPreviewGridClaim } from './geometry/preview-grid-claim' +import { createPreviewBoxFit } from './geometry/preview-terminal-box-fit' +import { openStandaloneTerminal } from '../../standalone-terminal/open-standalone-terminal' +import type { StandaloneTerminal } from '../../standalone-terminal/open-standalone-terminal' +import { terminalPreviewApiFor } from './transport/terminal-preview-api' import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { closeLinkActionRequest } from '@/components/link-actions/link-action-request' import type { LinkActionRequest } from '@/components/link-actions/link-action-request' -import { installDashboardPreviewLinkInteraction } from './dashboard-preview-link-interaction' +import { installDashboardPreviewLinkInteraction } from './links/dashboard-preview-link-interaction' import { useTranslate } from '@/i18n/use-translate' -import type { PreviewTerminalLinkHandler } from './preview-terminal-links' +import type { PreviewTerminalLinkHandler } from '../../standalone-terminal/rendering/preview-terminal-links' import type { ITheme, Terminal } from '@xterm/xterm' -import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import type { TerminalPreviewDataPayload } from '../../../../shared/terminal/replay/terminal-preview' -import type { DashboardPreviewLinkSource } from './dashboard-preview-http-actions' +import type { DashboardCardTerminalInput } from '../../../../../shared/dashboard-snapshot' +import type { TerminalPreviewDataPayload } from '../../../../../shared/terminal/replay/terminal-preview' +import type { DashboardPreviewLinkSource } from './links/dashboard-preview-http-actions' const PREVIEW_SCROLLBACK_ROWS = 24 // Preview snapshots bound history; pane scrollback would only cost memory. diff --git a/src/renderer/src/components/dashboard-popout/agent-terminal-preview-target.ts b/src/renderer/src/components/dashboard/terminal-preview/agent-terminal-preview-target.ts similarity index 100% rename from src/renderer/src/components/dashboard-popout/agent-terminal-preview-target.ts rename to src/renderer/src/components/dashboard/terminal-preview/agent-terminal-preview-target.ts diff --git a/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts b/src/renderer/src/components/dashboard/terminal-preview/geometry/preview-grid-claim.ts similarity index 92% rename from src/renderer/src/components/dashboard-popout/preview-grid-claim.ts rename to src/renderer/src/components/dashboard/terminal-preview/geometry/preview-grid-claim.ts index 46bd7eaf985..c5fcd9b1613 100644 --- a/src/renderer/src/components/dashboard-popout/preview-grid-claim.ts +++ b/src/renderer/src/components/dashboard/terminal-preview/geometry/preview-grid-claim.ts @@ -1,6 +1,6 @@ import type { Terminal } from '@xterm/xterm' -import { clampTerminalViewport } from '../../../../shared/terminal/viewport/terminal-viewport' -import { terminalPreviewApiFor } from './terminal-preview-api' +import { clampTerminalViewport } from '../../../../../../shared/terminal/viewport/terminal-viewport' +import { terminalPreviewApiFor } from '../transport/terminal-preview-api' const FIT_REQUEST_DEBOUNCE_MS = 200 /** Claim the dialog-sized PTY grid once per target size; unchanged requests must not fight a host or phone reclaiming geometry. */ diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts b/src/renderer/src/components/dashboard/terminal-preview/geometry/preview-terminal-box-fit.ts similarity index 100% rename from src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts rename to src/renderer/src/components/dashboard/terminal-preview/geometry/preview-terminal-box-fit.ts diff --git a/src/renderer/src/components/dashboard-popout/dashboard-preview-http-actions.ts b/src/renderer/src/components/dashboard/terminal-preview/links/dashboard-preview-http-actions.ts similarity index 94% rename from src/renderer/src/components/dashboard-popout/dashboard-preview-http-actions.ts rename to src/renderer/src/components/dashboard/terminal-preview/links/dashboard-preview-http-actions.ts index 91bd1aef3a0..ae94a132a18 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-preview-http-actions.ts +++ b/src/renderer/src/components/dashboard/terminal-preview/links/dashboard-preview-http-actions.ts @@ -1,11 +1,11 @@ import { toast } from 'sonner' import { intentPresentation } from '@/lib/browser/workspace/workspace-browser-tab-presentation' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { parseRoutableExecutionHostId } from '../../../../shared/execution-host' +import { parseAppSshPtyId } from '../../../../../../shared/ssh-pty-id' +import { parseRoutableExecutionHostId } from '../../../../../../shared/execution-host' import { parseRemoteRuntimePtyId, isRemoteRuntimePtyId -} from '../../../../shared/remote-runtime/remote-runtime-pty-id' +} from '../../../../../../shared/remote-runtime/remote-runtime-pty-id' import { useAppStore } from '@/store/index' import { resolveOwner } from '@/lib/workspace/ownership/resolve-owner' import { resolveWorktreeOperationRoute } from '@/lib/workspace/ownership/worktree-operation-route' @@ -24,7 +24,7 @@ import type { HttpLinkDestination, HttpLinkActionDestinations } from '@/lib/browser/navigation/http-link-destinations' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard } from '../../../../../../shared/dashboard-snapshot' import type { HttpLinkSourceOwner } from '@/lib/browser/navigation/http-link-routing' export type DashboardPreviewLinkSource = Pick< diff --git a/src/renderer/src/components/dashboard-popout/dashboard-preview-link-interaction.ts b/src/renderer/src/components/dashboard/terminal-preview/links/dashboard-preview-link-interaction.ts similarity index 96% rename from src/renderer/src/components/dashboard-popout/dashboard-preview-link-interaction.ts rename to src/renderer/src/components/dashboard/terminal-preview/links/dashboard-preview-link-interaction.ts index a75ae43fad9..a4344c581f0 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-preview-link-interaction.ts +++ b/src/renderer/src/components/dashboard/terminal-preview/links/dashboard-preview-link-interaction.ts @@ -1,4 +1,4 @@ -import type { PreviewTerminalLinkHandler } from './preview-terminal-links' +import type { PreviewTerminalLinkHandler } from '../../../standalone-terminal/rendering/preview-terminal-links' import type { Terminal } from '@xterm/xterm' import type { LinkActionRequest } from '@/components/link-actions/link-action-request' import { installTerminalLinkifierClickPriming } from '@/components/terminal-pane/links/gestures/terminal-linkifier-click-priming' @@ -12,10 +12,10 @@ import { } from '@/components/terminal-pane/links/gestures/terminal-link-activation' import { getTerminalBufferPositionForMouseEvent } from '@/components/terminal-pane/input/pointer/terminal-mouse-buffer-position' import { findHttpLinkAtTerminalMouseEvent } from '@/components/terminal-pane/links/http/terminal-url-link-hit-testing' -import { findTerminalHttpLinkAtBufferPosition } from '../../../../shared/terminal/links/terminal-link-buffer-hit-testing' +import { findTerminalHttpLinkAtBufferPosition } from '../../../../../../shared/terminal/links/terminal-link-buffer-hit-testing' import { httpLinkDirectClickDestination } from '@/lib/browser/navigation/http-link-destinations' import { isWindowsUserAgent } from '@/app-shell/platform/user-agent-platform' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { GlobalSettings } from '../../../../../../shared/global-settings-types' import { dashboardPreviewHttpActions, type DashboardPreviewLinkSource diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts b/src/renderer/src/components/dashboard/terminal-preview/terminal-preview-unavailable-message.ts similarity index 79% rename from src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts rename to src/renderer/src/components/dashboard/terminal-preview/terminal-preview-unavailable-message.ts index 255dd4362eb..d024306fd3c 100644 --- a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts +++ b/src/renderer/src/components/dashboard/terminal-preview/terminal-preview-unavailable-message.ts @@ -1,8 +1,8 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' -import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import type { DashboardCardHostKind } from '../../../../../shared/dashboard-snapshot' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' +import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime/remote-runtime-pty-id' /** Remote snapshot absence cannot prove pane exit because the client does not own the PTY; see docs/reference/ssh-execution-boundary.md. */ export function terminalPreviewUnavailableMessage( diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts b/src/renderer/src/components/dashboard/terminal-preview/transport/preview-terminal-snapshot-replay.ts similarity index 80% rename from src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts rename to src/renderer/src/components/dashboard/terminal-preview/transport/preview-terminal-snapshot-replay.ts index ae659aa593f..afc128389fa 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts +++ b/src/renderer/src/components/dashboard/terminal-preview/transport/preview-terminal-snapshot-replay.ts @@ -1,10 +1,10 @@ -import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker' -import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-flags' -import { buildKittyKeyboardRestore } from '../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' +import type { TerminalKittyKeyboardModeTracker } from '../../../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker' +import { parseTerminalKittyKeyboardFlags } from '../../../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-flags' +import { buildKittyKeyboardRestore } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' import type { TerminalPreviewReplayChunk, TerminalPreviewSnapshot -} from '../../../../shared/terminal/replay/terminal-preview' +} from '../../../../../../shared/terminal/replay/terminal-preview' /** Restore proven kitty flags after kitty-free snapshot ANSI; remain synchronous so events cannot observe the temporary reset. */ export function replayPreviewConnectionSnapshot(args: { diff --git a/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.ts b/src/renderer/src/components/dashboard/terminal-preview/transport/remote-runtime-terminal-preview-api.ts similarity index 98% rename from src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.ts rename to src/renderer/src/components/dashboard/terminal-preview/transport/remote-runtime-terminal-preview-api.ts index 0ee2a2934ab..2f4819e81b7 100644 --- a/src/renderer/src/components/dashboard-popout/remote-runtime-terminal-preview-api.ts +++ b/src/renderer/src/components/dashboard/terminal-preview/transport/remote-runtime-terminal-preview-api.ts @@ -1,16 +1,16 @@ -import { createNonSecureContextUuid as createBrowserUuid } from '../../../../shared/non-secure-context-uuid' +import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../../shared/non-secure-context-uuid' import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-output/terminal-delivery-credit' import { getRemoteRuntimeTerminalMultiplexer } from '@/runtime/hosted-terminals/streams/registry/remote-runtime-terminal-multiplexer-registry' import type { RemoteRuntimeMultiplexedTerminal, RemoteRuntimeMultiplexedTerminalCallbacks } from '@/runtime/hosted-terminals/streams/remote-terminal-stream-contracts' -import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import { parseRemoteRuntimePtyId } from '../../../../../../shared/remote-runtime/remote-runtime-pty-id' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload, TerminalPreviewReplayChunk -} from '../../../../shared/terminal/replay/terminal-preview' +} from '../../../../../../shared/terminal/replay/terminal-preview' type TerminalPreviewApi = typeof window.api.terminalPreview diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-api.ts b/src/renderer/src/components/dashboard/terminal-preview/transport/terminal-preview-api.ts similarity index 79% rename from src/renderer/src/components/dashboard-popout/terminal-preview-api.ts rename to src/renderer/src/components/dashboard/terminal-preview/transport/terminal-preview-api.ts index a5784935a97..798eccb2782 100644 --- a/src/renderer/src/components/dashboard-popout/terminal-preview-api.ts +++ b/src/renderer/src/components/dashboard/terminal-preview/transport/terminal-preview-api.ts @@ -1,4 +1,4 @@ -import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import { isRemoteRuntimePtyId } from '../../../../../../shared/remote-runtime/remote-runtime-pty-id' import { remoteRuntimeTerminalPreviewApi } from './remote-runtime-terminal-preview-api' /** A paired host's pane previews from that host; everything else from this app's main process. */ diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts deleted file mode 100644 index 0f6ff754d57..00000000000 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -// @vitest-environment happy-dom -import { act, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { buildWorktreeAgentRows } from '../sidebar/workspace-card/activity/worktree-agent-rows' -import { useAppStore } from '@/store' -import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-status/agent-pane-authority' -import type { - AgentStatusEntry, - AgentStatusState -} from '../../../../shared/agent-status/contracts/agent-status-types' -import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api' -import { - ENV, - LEAF_ID, - WT, - makeSnapshot, - resetWebSessionTabsSyncTestState -} from '@/runtime/web-session-tabs-sync/testing/web-session-tabs-sync.test-fixture' -import type { Repo } from '../../../../shared/repo-types' -import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { - buildRetainedAgentsSyncSnapshot, - collectRetainedAgentsOnDisappear, - useRetainedAgentsSync -} from './useRetainedAgents' - -const initialAppState = useAppStore.getInitialState() - -beforeEach(() => { - useAppStore.setState(initialAppState, true) - resetAgentPaneAuthorityAliasesForTests() -}) - -afterEach(() => { - useAppStore.setState(initialAppState, true) - resetAgentPaneAuthorityAliasesForTests() -}) - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/repo', - displayName: 'Repo', - badgeColor: '#000', - addedAt: 1 - } -} - -function makeWorktree(overrides?: Partial): Worktree { - return { - id: 'wt-1', - repoId: 'repo-1', - path: '/repo/wt-1', - head: 'abc123', - branch: 'feature', - isBare: false, - isMainWorktree: false, - displayName: 'feature', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - ...overrides - } -} - -function makeTab(overrides: Partial & { id: string }): TerminalTab { - return { - worktreeId: 'wt-1', - title: 'Terminal', - ptyId: null, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ...overrides - } -} - -function makeAgentRow(args: { - paneKey: string - state: AgentStatusState - interrupted?: boolean - mainAgent?: AgentStatusEntry['mainAgent'] -}) { - const entry: AgentStatusEntry = { - state: args.state, - prompt: 'Fix it', - updatedAt: 100, - stateStartedAt: 100, - paneKey: args.paneKey, - terminalTitle: 'Claude', - stateHistory: [], - agentType: 'claude', - interrupted: args.interrupted, - mainAgent: args.mainAgent - } - - return { - paneKey: args.paneKey, - entry, - tab: makeTab({ id: 'tab-1' }), - agentType: 'claude' as const, - state: args.state, - startedAt: 1 - } -} - -describe('collectRetainedAgentsOnDisappear', () => { - it('carries paired retained order through same-session return and resets known replacement', () => { - resetWebSessionTabsSyncTestState() - useAppStore.setState({ - repos: [makeRepo()], - worktreesByRepo: { 'repo-1': [makeWorktree({ id: WT })] } - }) - const entry: AgentStatusEntry = { - ...makeAgentRow({ paneKey: makePaneKey('host-tab-1', LEAF_ID), state: 'done' }).entry, - worktreeId: WT, - providerSession: { key: 'session_id', id: 'A' } - } - const quiet: AgentStatusEntry = { - ...entry, - paneKey: makePaneKey('host-tab-2', LEAF_ID), - state: 'working', - updatedAt: 2000, - stateStartedAt: 2000, - providerSession: { key: 'session_id', id: 'quiet' } - } - const update = (agentStatus: AgentStatusEntry | undefined, version: number) => { - const tabs = [agentStatus, quiet].map((status, index) => ({ - type: 'terminal' as const, - id: `host-tab-${index + 1}::${LEAF_ID}`, - title: 'Claude', - parentTabId: `host-tab-${index + 1}`, - leafId: LEAF_ID, - isActive: index === 0, - status: 'ready' as const, - terminal: `terminal-${index + 1}`, - agentStatus: status - })) - useAppStore.setState( - applyWebSessionTabsSnapshot( - useAppStore.getState(), - makeSnapshot(tabs, { snapshotVersion: version }), - ENV, - 1_000_000 + version - ) - ) - } - const snapshot = () => - buildRetainedAgentsSyncSnapshot({ - repos: [makeRepo()], - worktreesByRepo: { 'repo-1': [makeWorktree({ id: WT })] }, - folderWorkspaces: [], - tabsByWorktree: useAppStore.getState().tabsByWorktree, - agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, - now: 1_000_000 - }) - const rows = () => - buildWorktreeAgentRows({ - tabs: useAppStore.getState().tabsByWorktree[WT] ?? [], - entries: Object.values(useAppStore.getState().agentStatusByPaneKey), - retained: Object.values(useAppStore.getState().retainedAgentsByPaneKey), - now: 1_000_000 - }) - update({ ...entry, updatedAt: 1000, stateStartedAt: 1000, state: 'working' }, 1) - update({ ...entry, updatedAt: 5000, stateStartedAt: 5000 }, 2) - const previous = snapshot() - update(undefined, 3) - const current = snapshot() - const result = collectRetainedAgentsOnDisappear({ - previousAgents: previous.currentAgents, - currentAgents: current.currentAgents, - retainedAgentsByPaneKey: useAppStore.getState().retainedAgentsByPaneKey, - retentionSuppressedPaneKeys: {}, - recentlyClosedAgentStatusTabIds: {}, - recentlyRetiredAgentStatusPaneKeys: {}, - tabIndex: current.tabIndex - }) - expect(result.toRetain).toHaveLength(1) - useAppStore.getState().retainAgents(result.toRetain) - expect(rows().map((row) => row.startedAt)).toEqual([1000, 2000]) - update({ ...entry, updatedAt: 6000, stateStartedAt: 6000, state: 'working' }, 4) - expect(rows().map((row) => row.entry.providerSession?.id)).toEqual(['A', 'quiet']) - expect(rows().map((row) => row.startedAt)).toEqual([1000, 2000]) - update(undefined, 5) - update( - { - ...entry, - updatedAt: 7000, - stateStartedAt: 6000, - state: 'working', - providerSession: { key: 'session_id', id: 'B' } - }, - 6 - ) - expect(rows().map((row) => row.entry.providerSession?.id)).toEqual(['quiet', 'B']) - expect(rows().map((row) => row.startedAt)).toEqual([2000, 7000]) - }) -}) - -describe('useRetainedAgentsSync', () => { - it('uses the freshest tab title when a real agent transition retains a completed row', async () => { - const repo = makeRepo() - const worktree = makeWorktree() - const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') - const row = makeAgentRow({ paneKey, state: 'done' }) - useAppStore.setState({ - repos: [repo], - worktreesByRepo: { [repo.id]: [worktree] }, - tabsByWorktree: { - [worktree.id]: [{ ...row.tab, title: '⠋ Codex is thinking' }] - }, - agentStatusByPaneKey: { [paneKey]: row.entry }, - agentStatusEpoch: initialAppState.agentStatusEpoch + 1 - }) - const hook = renderHook(() => useRetainedAgentsSync()) - await act(async () => { - await Promise.resolve() - }) - - act(() => { - useAppStore.setState((state) => ({ - tabsByWorktree: { - [worktree.id]: [ - { - ...state.tabsByWorktree[worktree.id][0], - title: '⠙ Codex is thinking' - } - ] - } - })) - }) - act(() => { - useAppStore.setState((state) => ({ - agentStatusByPaneKey: {}, - agentStatusEpoch: state.agentStatusEpoch + 1 - })) - }) - await act(async () => { - await Promise.resolve() - }) - - expect(useAppStore.getState().retainedAgentsByPaneKey[paneKey]?.tab.title).toBe( - '⠙ Codex is thinking' - ) - hook.unmount() - }) - - it('does not re-retain when status removal and tab closure commit before the next retention effect', async () => { - const repo = makeRepo() - const worktree = makeWorktree() - const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') - const row = makeAgentRow({ paneKey, state: 'done' }) - useAppStore.setState({ - repos: [repo], - worktreesByRepo: { [repo.id]: [worktree] }, - tabsByWorktree: { [worktree.id]: [row.tab] }, - agentStatusByPaneKey: { [row.paneKey]: row.entry }, - agentStatusEpoch: initialAppState.agentStatusEpoch + 1 - }) - const hook = renderHook(() => useRetainedAgentsSync()) - await act(async () => { - await Promise.resolve() - }) - expect(useAppStore.getState().retentionSuppressedPaneKeys[row.paneKey]).toBeUndefined() - - // Why: model both teardown writes landing before the next retention effect runs. - act(() => { - useAppStore.setState((state) => ({ - tabsByWorktree: { [worktree.id]: [] }, - agentStatusByPaneKey: {}, - recentlyClosedAgentStatusTabIds: { [row.tab.id]: true }, - agentStatusEpoch: state.agentStatusEpoch + 1 - })) - }) - - const state = useAppStore.getState() - expect(state.recentlyClosedAgentStatusTabIds[row.tab.id]).toBe(true) - expect(state.retainedAgentsByPaneKey[row.paneKey]).toBeUndefined() - hook.unmount() - }) - - it('does not leave a ghost row when a done pane detaches into another tab', async () => { - const repo = makeRepo() - const worktree = makeWorktree() - const leafId = '11111111-1111-4111-8111-111111111111' - const sourceTab = makeTab({ id: 'tab-source', ptyId: 'pty-a' }) - const targetTab = makeTab({ id: 'tab-target' }) - const sourcePaneKey = makePaneKey(sourceTab.id, leafId) - const targetPaneKey = makePaneKey(targetTab.id, leafId) - const row = makeAgentRow({ paneKey: sourcePaneKey, state: 'done' }) - useAppStore.setState({ - repos: [repo], - worktreesByRepo: { [repo.id]: [worktree] }, - tabsByWorktree: { [worktree.id]: [sourceTab, targetTab] }, - ptyIdsByTabId: { [sourceTab.id]: ['pty-a'], [targetTab.id]: [] }, - agentStatusByPaneKey: { [sourcePaneKey]: { ...row.entry, tabId: sourceTab.id } }, - agentStatusEpoch: initialAppState.agentStatusEpoch + 1 - }) - const hook = renderHook(() => useRetainedAgentsSync()) - await act(async () => { - await Promise.resolve() - }) - - act(() => { - useAppStore.getState().syncPaneDetachPtyOwnership({ - detachedLeafId: leafId, - detachedPtyId: 'pty-a', - sourceLayout: { - root: null, - activeLeafId: null, - expandedLeafId: null, - ptyIdsByLeafId: {} - }, - sourceTabId: sourceTab.id, - targetTabId: targetTab.id - }) - }) - await act(async () => { - await Promise.resolve() - }) - - const state = useAppStore.getState() - expect(state.agentStatusByPaneKey[targetPaneKey]?.state).toBe('done') - expect(state.retainedAgentsByPaneKey[sourcePaneKey]).toBeUndefined() - expect(state.retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined() - expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBeUndefined() - hook.unmount() - }) - - it('keeps a wrapped-history completion in position through disappearance retention', () => { - const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') - const store = useAppStore.getState() - store.setAgentStatus( - paneKey, - { state: 'working', prompt: 'task', agentType: 'pi' }, - undefined, - { - updatedAt: 1000 - } - ) - for (let i = 0; i < 23; i++) { - store.setAgentStatus( - paneKey, - { state: i % 2 ? 'working' : 'done', prompt: 'task', agentType: 'pi' }, - undefined, - { updatedAt: 3000 + i * 1000 } - ) - } - const entry = useAppStore.getState().agentStatusByPaneKey[paneKey] - expect(entry.stateHistory[0].startedAt).toBeGreaterThan(2000) - const previous = buildRetainedAgentsSyncSnapshot({ - repos: [makeRepo()], - worktreesByRepo: { 'repo-1': [makeWorktree()] }, - folderWorkspaces: [], - tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1' })] }, - agentStatusByPaneKey: { [paneKey]: entry }, - now: 30000 - }) - const result = collectRetainedAgentsOnDisappear({ - previousAgents: previous.currentAgents, - currentAgents: new Map(), - retainedAgentsByPaneKey: {}, - retentionSuppressedPaneKeys: {}, - recentlyClosedAgentStatusTabIds: {}, - recentlyRetiredAgentStatusPaneKeys: {} - }) - expect(result.toRetain[0]?.startedAt).toBe(1000) - const quiet = makeAgentRow({ - paneKey: makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222'), - state: 'working' - }).entry - const rows = buildWorktreeAgentRows({ - tabs: [makeTab({ id: 'tab-2' })], - entries: [{ ...quiet, stateStartedAt: 2000 }], - retained: result.toRetain, - now: 30000 - }) - expect(rows.map((row) => row.paneKey)).toEqual([paneKey, quiet.paneKey]) - }) -}) diff --git a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx index 40bc1576fec..0bd8e83bb6f 100644 --- a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.tsx @@ -43,10 +43,10 @@ import { agentKindForAgentType } from '@/lib/agent-status/aggregation/worktree-a import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label' import { track } from '@/diagnostics/telemetry/telemetry' import { useNow } from '@/hooks/use-now' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' -import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' +import { lastEnteredDoneAt } from '@/components/dashboard/rows/agent-finished-timestamp' import { useWorktreeAgentRows } from '@/components/sidebar/workspace-card/activity/useWorktreeAgentRows' -import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name' +import { useAgentRowConversationName } from '@/components/dashboard/rows/use-agent-row-conversation-name' import { getAgentRowPrimaryText } from '../../../../../shared/agent-status/presentation/agent-row-primary-text' import type { LaunchSource } from '../../../../../shared/telemetry-property-schemas' import { agentRowDisplayDotState } from '@/lib/agent-status/presentation/agent-row-dot-state' diff --git a/src/renderer/src/components/editor/review-note-delivery/review-notes-send-target-order.ts b/src/renderer/src/components/editor/review-note-delivery/review-notes-send-target-order.ts index 274d678fc80..4845618d1d3 100644 --- a/src/renderer/src/components/editor/review-note-delivery/review-notes-send-target-order.ts +++ b/src/renderer/src/components/editor/review-note-delivery/review-notes-send-target-order.ts @@ -1,4 +1,4 @@ -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import type { NotesSendAgentTarget } from '@/lib/agent-session/messaging/notes-send-agent-targets' export type OrderedSendTarget = { diff --git a/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-source.ts b/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-source.ts index 010560ee33f..8920d62bfdb 100644 --- a/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-source.ts +++ b/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-source.ts @@ -5,7 +5,7 @@ import { createWorktreeAgentRowsCache, startWorktreeAgentRowsCachePass, selectWorktreeAgentRowsCached -} from '@/components/dashboard/worktree-agent-rows-cache' +} from '@/components/dashboard/rows/worktree-agent-rows-cache' import { createWorktreeAgentFreshnessSelector } from '@/components/sidebar/workspace-card/activity/worktree-agent-freshness-selector' import '@/components/sidebar/workspace-card/activity/worktree-agent-row-selectors' import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '@/components/sidebar/workspace-card/activity/worktree-agent-orchestration-index' diff --git a/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalView.tsx b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalView.tsx index 76d6fe4c6bc..8d82cba11d1 100644 --- a/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalView.tsx +++ b/src/renderer/src/components/plain-ssh-terminal/PlainSshTerminalView.tsx @@ -8,11 +8,11 @@ import { composeActiveTerminalTheme } from '@/components/terminal-pane/appearanc import { resolveEffectiveTerminalAppearance } from '@/lib/terminal/appearance/terminal-theme' import { useSystemPrefersDark } from '@/hooks/theme/use-system-prefers-dark' import { resolveTerminalMinimumContrastRatio } from '../../../../shared/terminal-themes/terminal-contrast-correction' -import { syncPreviewTerminalLigatures } from '@/components/dashboard-popout/preview-terminal-ligatures' +import { syncPreviewTerminalLigatures } from '@/components/standalone-terminal/rendering/preview-terminal-ligatures' import { buildPreviewAppearanceOptions, buildPreviewTerminalOptions -} from '@/components/dashboard-popout/preview-terminal-options' +} from '@/components/standalone-terminal/rendering/preview-terminal-options' import { openStandaloneTerminal } from '@/components/standalone-terminal/open-standalone-terminal' import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels' import { usePluginFontReady } from '@/store/plugins/appearance/plugin-fonts' diff --git a/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx b/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx index e04aafc4feb..e0c64da6566 100644 --- a/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx @@ -9,7 +9,7 @@ import { getExperimentalSearchEntry } from './experimental-search' import { HiddenExperimentalGroup } from './HiddenExperimentalGroup' import { NumberField, SettingsSegmentedControl } from '../form-controls/SettingsFormControls' import { useTranslate } from '@/i18n/use-translate' -import { AgentDashboardExperimentalSetting } from '../../dashboard/AgentDashboardExperimentalSetting' +import { AgentDashboardExperimentalSetting } from '../../dashboard/settings/AgentDashboardExperimentalSetting' import { EphemeralVmsExperimentalSetting } from '../virtual-machines/recipes/EphemeralVmsExperimentalSetting' import { WslHostsExperimentalSetting } from '../wsl/hosts/WslHostsExperimentalSetting' import { wslHostsAvailable } from '../../sidebar/use-wsl-hosts-enabled' diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx index 020775a907b..3977f79f28c 100644 --- a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx @@ -3,8 +3,8 @@ import { useAppStore } from '@/store/index' import { cn } from '@/lib/class-names' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' -import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' -import { resolveAgentDashboardMode } from '@/components/dashboard/agent-dashboard-open-mode' +import { useAgentBucketCounts } from '@/components/dashboard/bucket-counts/useAgentBucketCounts' +import { resolveAgentDashboardMode } from '@/components/dashboard/settings/agent-dashboard-open-mode' import { translate } from '@/i18n/i18n' import { useTranslate, type Translate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/WorkerLineage.fixture.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/WorkerLineage.fixture.tsx index 263a3ce5c47..69584c7581a 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/WorkerLineage.fixture.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/WorkerLineage.fixture.tsx @@ -1,6 +1,6 @@ import { defineFixture } from '../../../../component-fixtures/component-fixture' import WorktreeCardAgents from './WorktreeCardAgents' -import { applyAgentRowLineage } from '../../../dashboard/agent-row-lineage' +import { applyAgentRowLineage } from '../../../dashboard/rows/agent-row-lineage' import { buildWorktreeAgentRows } from './worktree-agent-rows' import { structuredAgentSessionPaneKey } from '../../../../../../shared/agent-session/view/structured-agent-session-projection' import type { Tab } from '../../../../../../shared/tab-types' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx index 63567d83a79..f2728c5177e 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/WorktreeCardAgents.tsx @@ -3,7 +3,7 @@ import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' import { useNow } from '@/hooks/use-now' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/class-names' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { CompactAgentExpansion, CompactAgentSummaryButton } from './worktree-card-compact-agents' import { CompactAgentRow } from './worktree-card-compact-agent-row' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/mirrored-terminal-lineage.test-fixture.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/mirrored-terminal-lineage.test-fixture.tsx deleted file mode 100644 index dde0c977cac..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/activity/mirrored-terminal-lineage.test-fixture.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import { act } from 'react' -import { expect, vi } from 'vitest' -import type { RuntimeAgentLineageTestModule } from '../../../../../../shared/runtime-agent-lineage-consumer.test-fixture' -import { isStructuredTab } from '../../../../../../shared/agent-session/view/structured-agent-session-tab' -import { useAppStore } from '@/store' -import type { AppState } from '@/store/types' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { makePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' -import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' -import { makeTab } from '@/store/terminals/testing/terminal-tab.test-fixture' - -// Load the native graph during collection; fixture state stays per test. -const { - default: { publicLineageFixture, canonicalChatRow, LINEAGE_PARENT, LINEAGE_CHILD } -} = await vi.importActual<{ default: RuntimeAgentLineageTestModule }>( - '../../../../../../main/runtime/workspaces/process-listing/runtime-agent-lineage.test-fixture' -) - -const LEAF = '11111111-1111-4111-8111-111111111111' - -export async function verifyMirroredTerminalLineage( - kind: 'default' | 'reserved' | 'collision', - { - patch, - entry, - mount, - getRows - }: { - patch: (update: Partial) => void - entry: (key: string, worktree: string) => AgentStatusEntry - mount: () => void - getRows: () => DashboardAgentRow[] - } -): Promise { - const { makeUnifiedTab } = await import('@/store/slices/tabs/tab-group.test-fixture'), - { makeWorktree } = await import('@/store/slices/worktrees/worktree.test-fixture'), - { TEST_REPO } = await import('@/store/repos/repo.test-fixture') - const { selectDashboardOrchestration } = - await import('@/components/dashboard/dashboard-orchestration-selection') - const { selectWorktreeAgentRowsCached } = - await import('@/components/dashboard/worktree-agent-rows-cache') - const { buildActivityEvents } = await import('@/components/activity/activity-event-builder') - const { buildMirroredAgentTabs } = - await import('@/runtime/web-session-tabs-sync/terminal-surfaces') - const { buildMirroredAgentStatusPatch } = - await import('@/runtime/web-session-tabs-sync/agent-status-patch') - const { buildTerminalUnifiedTab } = await import('@/runtime/web-session-tabs-sync/tab-builders') - const { toRemoteRuntimePtyId } = - await import('../../../../../../shared/remote-runtime/remote-runtime-pty-id') - const { toWebTerminalSurfaceTabId } = - await import('../../../../../../shared/workspace-layout/terminal-surface-id') - const { projectStatus } = - await import('@/components/native-chat/session/status/structured-agent-session-status-projection') - const { structuredAgentSessionPaneKey: pane, structuredAgentSessionTabId: tabId } = - await import('../../../../../../shared/agent-session/view/structured-agent-session-projection') - const { selectVisibleAgentOrchestration } = - await import('@/runtime/agent-orchestration/visible-agent-orchestration') - const worktree = makeWorktree({ id: 'folder-a', repoId: TEST_REPO.id }) - patch({ repos: [TEST_REPO], worktreesByRepo: { [TEST_REPO.id]: [worktree] } }) - const f = publicLineageFixture('folder-a') - try { - const context = f.projection.buildByPaneKey()?.[canonicalChatRow(LINEAGE_CHILD)] - if (!context) { - throw new Error('Missing production context') - } - const native = buildMirroredAgentTabs( - { - worktree: 'folder-a', - publicationEpoch: 'test', - snapshotVersion: 1, - activeGroupId: 'g', - activeTabId: null, - activeTabType: 'agent-session', - tabs: [ - { - type: 'agent-session', - id: 'host-parent', - title: 'Parent', - sessionId: LINEAGE_PARENT, - replacesSessionId: 'previous', - agent: 'codex', - isActive: false - } - ] - }, - 'runtime:paired', - new Map(), - 'g', - 0, - kind === 'default' - ? [] - : [ - makeUnifiedTab({ - id: kind === 'reserved' ? 'reserved-parent' : tabId(LINEAGE_PARENT), - entityId: kind === 'reserved' ? 'previous' : 'occupied', - worktreeId: 'folder-a', - groupId: 'g', - contentType: 'agent-session' - }) - ], - Date.now() - )[0].unifiedTab - if (!isStructuredTab(native)) { - throw new Error('Expected the mirrored native session tab') - } - const actualParent = pane(native.id, LINEAGE_PARENT) - const tab = makeTab({ - id: toWebTerminalSurfaceTabId('host-terminal'), - worktreeId: 'folder-a', - ptyId: toRemoteRuntimePtyId('terminal', 'paired') - }) - const worker = makePaneKey(tab.id, LEAF) - const terminal = buildTerminalUnifiedTab(tab, 'g', 'paired') - patch({ - tabsByWorktree: { 'folder-a': [tab] }, - unifiedTabsByWorktree: {}, - ptyIdsByTabId: {}, - agentStatusByPaneKey: {}, - retainedAgentsByPaneKey: {}, - runtimeAgentOrchestrationByPaneKey: {} - }) - const produced = buildMirroredAgentStatusPatch( - useAppStore.getState(), - [], - [ - { - type: 'terminal', - id: 'surface', - title: 'Worker', - parentTabId: 'host-terminal', - leafId: LEAF, - terminal: 'terminal', - status: 'ready', - isActive: false, - agentStatus: { - ...entry(makePaneKey('host-terminal', LEAF), 'folder-a'), - orchestration: context - } - } - ], - [], - 'paired', - 'folder-a', - new Set(), - Date.now() - ) - if (!produced) { - throw new Error('Missing mirrored terminal status') - } - patch(produced) - expect(useAppStore.getState().agentStatusByPaneKey[worker].connectionId).toBe('paired') - // Status before graph: the missing parent stays rooted, then tab hydration alone joins it. - expect(selectVisibleAgentOrchestration(useAppStore.getState())[worker]?.parentPaneKey).not.toBe( - actualParent - ) - patch({ unifiedTabsByWorktree: { 'folder-a': [native, terminal] } }) - act(() => - useAppStore.getState().transactAgentStatuses((transaction) => - projectStatus( - transaction, - native, - { - sessionId: LINEAGE_PARENT, - workspaceId: 'folder-a', - agent: 'codex', - status: 'working', - latestPrompt: 'Parent', - updatedAt: Date.now(), - hostExecutionOwned: true - }, - 'live', - null - ) - ) - ) - mount() - const assertNested = (parent = actualParent) => { - expect(getRows().map((row) => row.paneKey)).toEqual([parent, worker]) - expect(getRows().map((row) => row.lineage?.depth)).toEqual([0, 1]) - expect(getRows()[1].entry.orchestration?.parentPaneKey).toBe(parent) - expect( - useAppStore.getState().agentStatusByPaneKey[worker]?.orchestration?.parentPaneKey ?? - context.parentPaneKey - ).toBe(canonicalChatRow(LINEAGE_PARENT)) - const state = useAppStore.getState() - const orchestration = - selectDashboardOrchestration(state, [{ worktree }]).singletonOrchestration ?? {} - const dashboard = selectWorktreeAgentRowsCached({ - state, - worktreeId: 'folder-a', - orchestration, - now: Date.now(), - generation: 1 - }) - expect(dashboard.map((row) => [row.paneKey, row.lineage.depth])).toEqual([ - [parent, 0], - [worker, 1] - ]) - const activity = buildActivityEvents({ - ...state, - chatNames: new Map(), - worktreeMap: new Map([[worktree.id, worktree]]), - repoMap: new Map([[TEST_REPO.id, TEST_REPO]]), - runtimeAgentOrchestrationByPaneKey: selectVisibleAgentOrchestration(state), - now: Date.now() - }) - expect(activity.paneEntryByPaneKey[worker]?.orchestration?.parentPaneKey).toBe(parent) - expect(activity.paneEntryByPaneKey[parent]).toBeDefined() - } - assertNested() - // A host-supplied legacy connection spelling does not override actual producer proof. - patch({ - agentStatusByPaneKey: { - ...useAppStore.getState().agentStatusByPaneKey, - [worker]: { - ...useAppStore.getState().agentStatusByPaneKey[worker], - connectionId: 'ssh-legacy' - } - } - }) - assertNested() - const canonical = useAppStore.getState().agentStatusByPaneKey[worker] - const epoch = useAppStore.getState().agentStatusEpoch - // Graph before parent hydration; matching task/Dispatch producer remains valid with nonempty connectionId. - const parentEntry = useAppStore.getState().agentStatusByPaneKey[actualParent] - patch({ - unifiedTabsByWorktree: { 'folder-a': [terminal] }, - agentStatusByPaneKey: { [worker]: canonical } - }) - act(() => - useAppStore - .getState() - .setRuntimeAgentOrchestrationByPaneKey( - { [worker]: structuredClone(context) }, - 'runtime:paired' - ) - ) - expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) - patch({ - unifiedTabsByWorktree: { 'folder-a': [native, terminal] }, - agentStatusByPaneKey: { [worker]: canonical, [actualParent]: parentEntry } - }) - assertNested() - act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey({})) - // PTY-only hydration is an independent cache input; disagreeing tab/PTY facts must not join. - patch({ - unifiedTabsByWorktree: { 'folder-a': [native] }, - tabsByWorktree: { 'folder-a': [{ ...tab, ptyId: null }] } - }) - expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) - patch({ ptyIdsByTabId: { [tab.id]: [tab.ptyId ?? ''] } }) - assertNested() - patch({ - unifiedTabsByWorktree: { - 'folder-a': [native, { ...terminal, executionHostId: 'runtime:other' }] - } - }) - expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) - patch({ - unifiedTabsByWorktree: { - 'folder-a': [{ ...native, executionHostId: 'runtime:other' }, terminal] - }, - ptyIdsByTabId: {} - }) - expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) - patch({ unifiedTabsByWorktree: { 'folder-a': [native, terminal] } }) - assertNested() - // Retained snapshot proves the runtime after the current terminal tab/PTY/unified row disappears. - act(() => - useAppStore.getState().retainAgents([ - { - entry: { ...canonical, state: 'done' }, - worktreeId: 'folder-a', - tab, - agentType: 'claude', - startedAt: Date.now() - } - ]) - ) - patch({ - agentStatusByPaneKey: { - [actualParent]: useAppStore.getState().agentStatusByPaneKey[actualParent] - }, - tabsByWorktree: {}, - unifiedTabsByWorktree: { 'folder-a': [native] }, - ptyIdsByTabId: {} - }) - assertNested() - patch({ unifiedTabsByWorktree: { 'folder-a': [{ ...native, id: 'remapped-parent' }] } }) - // The parent status arrives independently; canonical retained context did not change or need an RPC. - patch({ - agentStatusByPaneKey: { - [pane('remapped-parent', LINEAGE_PARENT)]: { - ...useAppStore.getState().agentStatusByPaneKey[actualParent], - paneKey: pane('remapped-parent', LINEAGE_PARENT), - tabId: 'remapped-parent' - } - } - }) - assertNested(pane('remapped-parent', LINEAGE_PARENT)) - expect( - useAppStore.getState().retainedAgentsByPaneKey[worker].entry.orchestration?.parentPaneKey - ).toBe(canonicalChatRow(LINEAGE_PARENT)) - expect(useAppStore.getState().agentStatusEpoch).toBeGreaterThanOrEqual(epoch) - // Removing the only retained PTY proof cannot infer a runtime from its connection spelling. - patch({ - retainedAgentsByPaneKey: { - [worker]: { - ...useAppStore.getState().retainedAgentsByPaneKey[worker], - tab: { ...tab, ptyId: null } - } - } - }) - expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) - expect(new Set(getRows().map((row) => row.paneKey)).size).toBe(getRows().length) - // Matching graph context identifies its producer even when terminal routing names another host. - const renamedParent = pane('remapped-parent', LINEAGE_PARENT) - patch({ - retainedAgentsByPaneKey: {}, - tabsByWorktree: { 'folder-a': [tab] }, - unifiedTabsByWorktree: { - 'folder-a': [{ ...native, id: 'remapped-parent', executionHostId: 'local' }, terminal] - }, - agentStatusByPaneKey: { - [renamedParent]: useAppStore.getState().agentStatusByPaneKey[renamedParent], - [worker]: canonical - } - }) - act(() => - useAppStore - .getState() - .setRuntimeAgentOrchestrationByPaneKey({ [worker]: structuredClone(context) }) - ) - assertNested(renamedParent) - act(() => - useAppStore - .getState() - .setRuntimeAgentOrchestrationByPaneKey( - { [worker]: structuredClone(context) }, - 'runtime:other' - ) - ) - expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) - act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey({})) - patch({ - tabsByWorktree: { 'folder-a': [{ ...tab, ptyId: 'remote:%zz@@terminal' }] }, - unifiedTabsByWorktree: { - 'folder-a': [{ ...native, id: 'remapped-parent', executionHostId: 'local' }] - }, - agentStatusByPaneKey: { - ...useAppStore.getState().agentStatusByPaneKey, - [worker]: { ...canonical, connectionId: undefined } - } - }) - expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) - } finally { - f.db.close() - } -} diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts index 3f0f72965d8..c3e3c5bed09 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-list-model.ts @@ -4,8 +4,8 @@ import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { deriveRunningAgentSendTargets } from '@/lib/agent-session/messaging/running-agent-targets' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' -import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' +import { buildAgentRowLineageTree } from '@/components/dashboard/rows/agent-row-lineage-model' import { useTranslate } from '@/i18n/use-translate' import { agentSendDisabledReasonLabel } from '@/lib/agent-session/messaging/agent-send-disabled-reason-label' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-rows-subscriptions.test.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-rows-subscriptions.test.tsx index bb451230f5d..9083934fd71 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-rows-subscriptions.test.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-agent-rows-subscriptions.test.tsx @@ -6,7 +6,7 @@ import { useAppStore } from '@/store' import { makeTab } from '@/store/terminals/testing/terminal-tab.test-fixture' import type { AppState } from '@/store/types' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { makePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../../shared/agent-status/contracts/agent-status-freshness' import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-order.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-order.ts index 053a2dd6511..3066218529f 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-order.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-order.ts @@ -1,4 +1,4 @@ -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' function comparableNumber(value: number | undefined, fallback = 0): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts index 156e9f0848a..1a33b9fd97d 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts @@ -3,8 +3,8 @@ import { isExecutionHostAliasForWorktree } from '@/lib/workspace/ownership/workt import { structuredAgentSessionOwnerInputs } from '@/runtime/native-chat/session-ownership/structured-agent-session-owner' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' +import { applyAgentRowLineage } from '@/components/dashboard/rows/agent-row-lineage' import { migrationUnsupportedToAgentStatusEntry } from '../../../../../../shared/migration-unsupported-agent-entry' import type { AppState } from '@/store/types' import { diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows.ts index bba87b3a15b..756342b70c8 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows.ts @@ -1,6 +1,6 @@ import type { Tab } from '../../../../../../shared/tab-types' import { parseAgentStatusPaneIdentity } from '../../../../../../shared/agent-status/identity/agent-status-worktree-attribution' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { isAgentStatusFresh, AGENT_STATUS_STALE_AFTER_MS diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-ack-inputs.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-ack-inputs.ts index 2bd4b99b67f..96387186ebe 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-ack-inputs.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-ack-inputs.ts @@ -1,9 +1,9 @@ -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { isAgentSubjectUnread } from '@/attention/agent-attention-acknowledgement' import { readAgentRowTurnRecords, type AgentRowUnreadState -} from '@/components/dashboard/dashboard-row-bucket' +} from '@/components/dashboard/rows/dashboard-row-bucket' export function selectAgentRowsUnread( state: AgentRowUnreadState, diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.test.ts index 1fbb314f034..4eaeeac70ea 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { buildSummaryAgentGroups } from './worktree-card-agent-summary' function row(paneKey: string, state: 'done' | 'working' | 'waiting'): DashboardAgentRow { diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.ts index 6f3fe9f40f8..12d542f826b 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agent-summary.ts @@ -1,5 +1,5 @@ import type { AgentDotState } from '@/components/AgentStateDot' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import { formatAgentTypeLabel } from '../../../../../../shared/agent-status/presentation/agent-type-label' import { agentRowDisplayDotState as getAgentDotState } from '@/lib/agent-status/presentation/agent-row-dot-state' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-line-order.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-line-order.ts index b11cfa12ef1..8838e5ed25c 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-line-order.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-line-order.ts @@ -1,4 +1,4 @@ -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import { agentRowStoppingLabel } from '@/lib/agent-status/presentation/agent-row-stopping-label' import type { Translate } from '@/i18n/use-translate' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.stable-message.test.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.stable-message.test.tsx index d323e01c3f7..01861d6323c 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.stable-message.test.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.stable-message.test.tsx @@ -2,13 +2,13 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import { TooltipProvider } from '@/components/ui/tooltip' import { CompactAgentRow } from './worktree-card-compact-agent-row' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true -vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ +vi.mock('@/components/dashboard/rows/use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null })) diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx index 6f544aff741..612199be03c 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.tsx @@ -1,20 +1,20 @@ import React, { useCallback, useEffect, useRef } from 'react' -import { DashboardAgentChildDisclosure } from '@/components/dashboard/DashboardAgentChildDisclosure' +import { DashboardAgentChildDisclosure } from '@/components/dashboard/rows/DashboardAgentChildDisclosure' import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentChildRowContent } from '@/components/AgentChildRowContent' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import { AgentTypeIcon } from '@/components/agent/icons/agent-type-icon' import { formatAgentTypeLabel } from '../../../../../../shared/agent-status/presentation/agent-type-label' import { cn } from '@/lib/class-names' import { viewClaudeAgentFromRow } from '@/lib/agent-session/navigation/claude-agent-row-view' -import { useClaudeAgentRowView } from '@/components/dashboard/use-claude-agent-row-view' +import { useClaudeAgentRowView } from '@/components/dashboard/rows/use-claude-agent-row-view' import './worktree-card-agent-summary' import { agentRowDisplayDotState as getAgentDotState } from '../../../../lib/agent-status/presentation/agent-row-dot-state' import { getAgentRowPrimaryText } from '../../../../../../shared/agent-status/presentation/agent-row-primary-text' import { formatAgentToolPreview } from '@/lib/agent-status/presentation/agent-row-tool-preview' import { agentNoUpdateLabel } from '../../../../../../shared/agent-status/presentation/agent-row-decay-state' -import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name' -import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' +import { useAgentRowConversationName } from '@/components/dashboard/rows/use-agent-row-conversation-name' +import { lastEnteredDoneAt } from '@/components/dashboard/rows/agent-finished-timestamp' import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/time-format/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-status/presentation/agent-verdict-status-line' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agents.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agents.tsx index 9eca5cbb793..8231c89173e 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agents.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agents.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef } from 'react' import { ChevronDown } from 'lucide-react' import { AgentStateDot } from '@/components/AgentStateDot' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import { AgentTypeIcon } from '@/components/agent/icons/agent-type-icon' import { cn } from '@/lib/class-names' import { diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.test.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.test.ts index ccab467020a..8a2cdcd0874 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.test.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { buildCollapsedChildAgentSignalStates } from './worktree-card-idle-child-agents' function row(paneKey: string, state: DashboardAgentRow['state']): DashboardAgentRow { diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx index 549b47770eb..f5ecf1eb1e1 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import { ChevronDown } from 'lucide-react' import { AgentStateDot } from '@/components/AgentStateDot' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row' import { cn } from '@/lib/class-names' import { useTranslate } from '@/i18n/use-translate' import { CompactAgentExpansion } from './worktree-card-compact-agents' diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-subagent-child-rows.ts index 25a4b6236df..903df04a7ac 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-subagent-child-rows.ts @@ -1,4 +1,4 @@ -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import type { AgentRowState } from '../../../../../../shared/agent-status/presentation/agent-row-decay-state' import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' import { diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts index 97db8a57b2a..6615e626e71 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts @@ -1,5 +1,5 @@ import type { TitleDerivedAgentRowsInput } from './title-derived-agent-row-input' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { formatAgentTypeLabel } from '../../../../../../shared/agent-status/presentation/agent-type-label' import { isClaudeManagementTitle, diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx index 3cef077640d..79a46c5cedc 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-title-status.tsx @@ -1,7 +1,7 @@ import React, { createContext, useCallback, useContext, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { useNow } from '@/hooks/use-now' import { useAppStore } from '@/store' import type { WorktreeStatus } from '../../../../../../shared/worktree-status-rollup' diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts b/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts index 86cbc3c69e6..02ea212e0fe 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-agent-signals.ts @@ -1,7 +1,7 @@ import type { AgentRowState } from '../../../../shared/agent-status/presentation/agent-row-decay-state' import type { AgentChildRowModel } from '../../../../shared/agent-status/presentation/agent-child-row-model' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' +import { buildAgentRowLineageTree } from '@/components/dashboard/rows/agent-row-lineage-model' import { agentRowDisplayDotState } from '@/lib/agent-status/presentation/agent-row-dot-state' import { resolveRowProgramStatus } from '@/lib/program-status/agent-row-presentation' import { classifySlotLine, slotLineAt, type SlotLineSignal } from './worktree-card-slot-signal' diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts index cc5000e9625..da386900f10 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts @@ -1,6 +1,6 @@ import { getResolvedExecutionHostIdForWorktree } from '@/lib/workspace/ownership/resolved-worktree-execution-host' import { createLineageSlotReader, type WorkspaceSlot } from './worktree-card-slot-range' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' import { toLineageSlotRecord, diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts index 2ab2f50af36..da591ed69cc 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts @@ -1,6 +1,6 @@ import type { AgentDotState } from '@/components/AgentStateDot' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' +import { lastEnteredDoneAt } from '@/components/dashboard/rows/agent-finished-timestamp' /** What one agent line means for the experimental card, in slot priority order. */ export type SlotLineKind = 'needs' | 'failed' | 'unread' | 'working' | 'seen' | 'idle' diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-app-menu-clipboard.ts b/src/renderer/src/components/standalone-terminal/input/preview-terminal-app-menu-clipboard.ts similarity index 100% rename from src/renderer/src/components/dashboard-popout/preview-terminal-app-menu-clipboard.ts rename to src/renderer/src/components/standalone-terminal/input/preview-terminal-app-menu-clipboard.ts diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts b/src/renderer/src/components/standalone-terminal/input/preview-terminal-ime-bridge.ts similarity index 100% rename from src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge.ts rename to src/renderer/src/components/standalone-terminal/input/preview-terminal-ime-bridge.ts diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts b/src/renderer/src/components/standalone-terminal/input/preview-terminal-key-handler.ts similarity index 98% rename from src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts rename to src/renderer/src/components/standalone-terminal/input/preview-terminal-key-handler.ts index f46fa9d48e3..951ee33aa8e 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts +++ b/src/renderer/src/components/standalone-terminal/input/preview-terminal-key-handler.ts @@ -1,7 +1,7 @@ import type { Terminal } from '@xterm/xterm' import { getShortcutPlatform } from '@/lib/keyboard/shortcut-platform' -import { keybindingMatchesAction } from '../../../../shared/keybindings' -import { useAppStore } from '@/store' +import { keybindingMatchesAction } from '../../../../../shared/keybindings/matching' +import { useAppStore } from '@/store/index' import { isCurrentPlatformIosWeb } from '@/app-shell/platform/ios-web-platform' import { getLayoutCharacterForCode, diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts b/src/renderer/src/components/standalone-terminal/input/preview-terminal-paste.ts similarity index 97% rename from src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts rename to src/renderer/src/components/standalone-terminal/input/preview-terminal-paste.ts index aa757c946b3..38c0eb8cb50 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-paste.ts +++ b/src/renderer/src/components/standalone-terminal/input/preview-terminal-paste.ts @@ -6,7 +6,7 @@ import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/input/pa import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/input/paste/terminal-paste-limits' import { pasteTerminalText } from '@/components/terminal-pane/input/paste/terminal-bracketed-paste' import { isTerminalPanePasteFocusCurrent } from '@/components/terminal-pane/input/paste/terminal-paste-target-state' -import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' +import type { DashboardCardTerminalInput } from '../../../../../shared/dashboard-snapshot' export type PreviewTerminalPasteSource = 'keyboard' | 'app-menu' | 'right-click' diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.ts b/src/renderer/src/components/standalone-terminal/input/preview-terminal-right-click-paste.ts similarity index 100% rename from src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.ts rename to src/renderer/src/components/standalone-terminal/input/preview-terminal-right-click-paste.ts diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/standalone-terminal/input/preview-terminal-shortcuts.ts similarity index 89% rename from src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts rename to src/renderer/src/components/standalone-terminal/input/preview-terminal-shortcuts.ts index 60384f71492..e4a08d0e0dc 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts +++ b/src/renderer/src/components/standalone-terminal/input/preview-terminal-shortcuts.ts @@ -3,12 +3,12 @@ import type { MacOptionAsAlt } from '@/components/terminal-pane/input/keyboard/t import type { TerminalShortcutAction } from '@/components/terminal-pane/input/keyboard/terminal-shortcut-policy' import { getLayoutCharacterForCode } from '@/lib/keyboard-layout/layout-base-character' import type { OptionKeyLocationState } from '@/lib/keyboard-layout/option-key-location-state' -import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import { - normalizeTerminalShortcutPolicy, - type KeybindingOverrides, - type TerminalShortcutPolicy -} from '../../../../shared/keybindings' +import type { DashboardCardTerminalInput } from '../../../../../shared/dashboard-snapshot' +import { normalizeTerminalShortcutPolicy } from '../../../../../shared/keybindings/effective' +import type { + KeybindingOverrides, + TerminalShortcutPolicy +} from '../../../../../shared/keybindings/types' export type PreviewShortcutContext = { clientPlatform: NodeJS.Platform diff --git a/src/renderer/src/components/standalone-terminal/open-standalone-terminal.ts b/src/renderer/src/components/standalone-terminal/open-standalone-terminal.ts index ff2aa6e25d4..96947d81bed 100644 --- a/src/renderer/src/components/standalone-terminal/open-standalone-terminal.ts +++ b/src/renderer/src/components/standalone-terminal/open-standalone-terminal.ts @@ -10,13 +10,13 @@ import type { MacOptionAsAlt } from '@/components/terminal-pane/input/keyboard/t import { subscribeToTerminalUserInput } from '@/components/terminal-pane/input/terminal-user-input-signal' import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/clipboard/terminal-native-copy-gutter' import { isWindowsUserAgent } from '@/app-shell/platform/user-agent-platform' -import type { PreviewTerminalLinkHandlers } from '../dashboard-popout/preview-terminal-links' -import { installPreviewTerminalCompatibility } from '../dashboard-popout/preview-terminal-compatibility' -import { createPreviewClipboardPaster } from '../dashboard-popout/preview-terminal-paste' -import { installPreviewImeBridge } from '../dashboard-popout/preview-terminal-ime-bridge' -import { installPreviewTerminalKeyHandler } from '../dashboard-popout/preview-terminal-key-handler' -import { installPreviewTerminalAppMenuClipboard } from '../dashboard-popout/preview-terminal-app-menu-clipboard' -import { installPreviewTerminalRightClickPaste } from '../dashboard-popout/preview-terminal-right-click-paste' +import type { PreviewTerminalLinkHandlers } from './rendering/preview-terminal-links' +import { installPreviewTerminalCompatibility } from './rendering/preview-terminal-compatibility' +import { createPreviewClipboardPaster } from './input/preview-terminal-paste' +import { installPreviewImeBridge } from './input/preview-terminal-ime-bridge' +import { installPreviewTerminalKeyHandler } from './input/preview-terminal-key-handler' +import { installPreviewTerminalAppMenuClipboard } from './input/preview-terminal-app-menu-clipboard' +import { installPreviewTerminalRightClickPaste } from './input/preview-terminal-right-click-paste' export type StandaloneTerminalArgs = { container: HTMLElement diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-compatibility.ts similarity index 94% rename from src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts rename to src/renderer/src/components/standalone-terminal/rendering/preview-terminal-compatibility.ts index 00640ae9cdc..15909195f5f 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts +++ b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-compatibility.ts @@ -1,7 +1,7 @@ import type { Terminal } from '@xterm/xterm' import { Unicode11Addon } from '@xterm/addon-unicode11' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal/appearance/terminal-unicode-provider' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import { activateOrcaTerminalUnicodeProvider } from '../../../../../shared/terminal/appearance/terminal-unicode-provider' import { installWindowsCtrlAltChordRepair } from '@/lib/pane-manager/terminal-protocol/terminal-windows-ctrl-alt-chord-classification' import { attachTerminalMouseWheelMultiplier } from '@/lib/pane-manager/terminal-input/pane-terminal-mouse-wheel' import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-rendering/terminal-arabic-shaping-joiner' diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-ligatures.ts similarity index 85% rename from src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts rename to src/renderer/src/components/standalone-terminal/rendering/preview-terminal-ligatures.ts index 1da2f26a134..eb20de73c90 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-ligatures.ts +++ b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-ligatures.ts @@ -1,6 +1,6 @@ import type { Terminal } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal/appearance/terminal-ligatures' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import { resolveTerminalLigaturesEnabled } from '../../../../../shared/terminal/appearance/terminal-ligatures' import { TerminalLigaturesAddon } from '@/lib/pane-manager/terminal-rendering/terminal-ligatures-addon' const ligatureAddonsByTerminal = new WeakMap() diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-links.ts b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-links.ts similarity index 95% rename from src/renderer/src/components/dashboard-popout/preview-terminal-links.ts rename to src/renderer/src/components/standalone-terminal/rendering/preview-terminal-links.ts index a129ef4de62..f582eca27cf 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-links.ts +++ b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-links.ts @@ -2,7 +2,7 @@ import type { IBufferRange, ILinkHandler, Terminal } from '@xterm/xterm' import { WebLinksAddon } from '@xterm/addon-web-links' import { installGuardedLinkProviderRegistration } from '@/lib/pane-manager/terminal-input/terminal-link-provider-guard' import { isTerminalLinkDirectActivation as isTerminalHttpLinkActivation } from '@/components/terminal-pane/links/gestures/terminal-link-activation' -import { TERMINAL_HTTP_URL_REGEX } from '../../../../shared/terminal/links/terminal-http-url-extraction' +import { TERMINAL_HTTP_URL_REGEX } from '../../../../../shared/terminal/links/terminal-http-url-extraction' export type PreviewTerminalLinkHandler = Omit & { activate(event: MouseEvent, uri: string, range?: IBufferRange): void diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-options.ts similarity index 87% rename from src/renderer/src/components/dashboard-popout/preview-terminal-options.ts rename to src/renderer/src/components/standalone-terminal/rendering/preview-terminal-options.ts index 6726e0dd912..0db68001c3a 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts +++ b/src/renderer/src/components/standalone-terminal/rendering/preview-terminal-options.ts @@ -1,8 +1,8 @@ import type { ITerminalInitOnlyOptions, ITerminalOptions, ITheme } from '@xterm/xterm' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import { resolveTerminalFontWeights } from '../../../../shared/terminal/appearance/terminal-fonts' -import { normalizeTerminalLineHeight } from '../../../../shared/terminal/appearance/terminal-line-height-settings' +import type { GlobalSettings } from '../../../../../shared/global-settings-types' +import type { DashboardCardTerminalInput } from '../../../../../shared/dashboard-snapshot' +import { resolveTerminalFontWeights } from '../../../../../shared/terminal/appearance/terminal-fonts' +import { normalizeTerminalLineHeight } from '../../../../../shared/terminal/appearance/terminal-line-height-settings' import { buildDefaultTerminalOptions, normalizeTerminalFastScrollSensitivity, @@ -11,9 +11,9 @@ import { resolveTerminalScrollOnUserInput } from '@/lib/pane-manager/pane-terminal-options' import { buildLocalConptyTerminalOptions } from '@/lib/pane-manager/terminal-protocol/windows-pty-compatibility' -import { buildFontFamily } from '../../../../shared/monospace-font-family' +import { buildFontFamily } from '../../../../../shared/monospace-font-family' import { pluginFontFamilyWhenLoaded } from '@/store/plugins/appearance/plugin-fonts' -import { resolveTerminalMinimumContrastRatio } from '../../../../shared/terminal-themes/terminal-contrast-correction' +import { resolveTerminalMinimumContrastRatio } from '../../../../../shared/terminal-themes/terminal-contrast-correction' /** Options a live settings change can write onto an open preview terminal. */ export function buildPreviewAppearanceOptions( diff --git a/src/renderer/src/components/terminal-workspace-keydown.ts b/src/renderer/src/components/terminal-workspace-keydown.ts index b7c46169f85..de8665dc4d6 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.ts @@ -23,7 +23,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { translate } from '@/i18n/i18n' import { getKeybindingContext } from './terminal-workspace-model' -import { isAgentTerminalPreviewTarget } from './dashboard-popout/agent-terminal-preview-target' +import { isAgentTerminalPreviewTarget } from './dashboard/terminal-preview/agent-terminal-preview-target' import { resolveTerminalAgentTabShortcut } from './terminal-agent-tab-shortcut' import { getDetectionTargetIdentityForWorktree } from '@/lib/agent-session/launch/inventory/agent-detection-target-inventory' import { diff --git a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts index 75882470f1b..6153cd051d1 100644 --- a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts +++ b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts @@ -1,6 +1,6 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AppState } from '../../store/types' -import { resolveAgentDashboardMode } from '../../components/dashboard/agent-dashboard-open-mode' +import { resolveAgentDashboardMode } from '../../components/dashboard/settings/agent-dashboard-open-mode' export function toggleAgentDashboardFromShortcut( state: Pick< diff --git a/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.test.ts b/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.test.ts index 8a6a8754261..415501523a9 100644 --- a/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.test.ts +++ b/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.test.ts @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { useNativeChatOpenSubagent } from '@/components/native-chat/subagents/native-chat-open-subagent' import { viewClaudeAgentFromRow } from './claude-agent-row-view' diff --git a/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.ts b/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.ts index 155a24a4270..dfb5f825d07 100644 --- a/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.ts +++ b/src/renderer/src/lib/agent-session/navigation/claude-agent-row-view.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { DashboardAgentRow } from '@/components/dashboard/rows/dashboard-agent-row' import { useAppStore } from '@/store/index' import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' import { hostRouteForAuthority } from '@/runtime/rpc/targets/runtime-client-target' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts deleted file mode 100644 index 5617d3f934d..00000000000 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** Host tab retraction must sweep renderer status and plant close markers like local close (STA-4593). Client-owned rows bypass normal mirror deletion and lose their only reachable pane keys at retraction; done rows otherwise become retained ghosts. Exercise real mirror, retention and sidebar logic with fake clocks. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-session-contracts' -import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' -import { toWebTerminalSurfaceTabId } from '../../../../../shared/workspace-layout/terminal-surface-id' -import { getDefaultSettings } from '../../../../../shared/constants' -import type { AppState } from '../../../store/types' -import { createTestStore, seedStore } from '../../../store/testing/app-store-test-fixture' -import { makeWorktree } from '../../../store/slices/worktrees/worktree.test-fixture' -import { - markRendererOwnedAgentStatusWrite, - registerRendererOwnedAgentStatusPane, - resetRendererOwnedAgentStatusPanesForTests -} from '../../../components/terminal-pane/agent-status/renderer-owned-agent-status-registry' -import { applyFreshWebSessionTabsSnapshot } from '../snapshot-api' -import { resetWebSessionTabsSnapshotFreshnessForTests } from '../tracking-lifecycle' -import { - buildRetainedAgentsSyncSnapshot, - collectRetainedAgentsOnDisappear -} from '../../../components/dashboard/useRetainedAgents' -import { buildWorktreeAgentRows } from '../../../components/sidebar/workspace-card/activity/worktree-agent-rows' -import { - selectLiveAgentStatusEntriesForWorktree, - selectRetainedAgentEntriesForWorktree -} from '../../../components/sidebar/workspace-card/activity/worktree-agent-row-selectors' -import { - selectLivePtyIdsForWorktree, - selectRuntimePaneTitlesForWorktree -} from '../../../components/sidebar/workspace-card/activity/worktree-card-status-inputs' - -// Mirror imports app store singleton; redirect it to this createTestStore harness. -vi.mock('../../../store/index', () => ({ - useAppStore: { - setState: vi.fn(), - getState: vi.fn(() => ({})), - subscribe: vi.fn(() => () => {}) - } -})) - -const WT = 'repo1::/path/wt1' -const ENV = 'web-env-1' -const HOST_EPOCH = 'host-epoch-1' -const T0 = 1_700_000_000_000 - -const KEEP_TAB = 'host-tab-keep' -const RETRACTED_TAB = 'host-tab-reviewer' -const KEEP_LEAF = '11111111-1111-4111-8111-111111111111' -const RETRACTED_LEAF = '22222222-2222-4222-8222-222222222222' - -type TestStore = ReturnType -type RetainedSyncAgents = ReturnType['currentAgents'] - -function mirrorTabId(hostTabId: string): string { - return toWebTerminalSurfaceTabId(hostTabId) -} - -function mirrorPaneKey(hostTabId: string, leafId: string): string { - return makePaneKey(mirrorTabId(hostTabId), leafId) -} - -const GHOST_PANE_KEY = mirrorPaneKey(RETRACTED_TAB, RETRACTED_LEAF) - -/** hostAgentStatus=true models a host-hook pane; otherwise status comes from client bytes. */ -function makeHostSnapshot(args: { - snapshotVersion: number - hostTabIds: readonly string[] - hostNow: number - hostAgentStatusTabIds?: readonly string[] - hostAgentStatusState?: 'working' | 'done' -}): RuntimeMobileSessionTabsResult { - const leafByTab: Record = { - [KEEP_TAB]: KEEP_LEAF, - [RETRACTED_TAB]: RETRACTED_LEAF - } - return { - worktree: WT, - publicationEpoch: HOST_EPOCH, - snapshotVersion: args.snapshotVersion, - activeGroupId: 'host-group-1', - activeTabId: `${args.hostTabIds[0]}::${leafByTab[args.hostTabIds[0]!]}`, - activeTabType: 'terminal', - tabs: args.hostTabIds.map((hostTabId, index) => ({ - type: 'terminal' as const, - id: `${hostTabId}::${leafByTab[hostTabId]}`, - title: hostTabId === RETRACTED_TAB ? 'Review origin/main' : 'Codex', - parentTabId: hostTabId, - leafId: leafByTab[hostTabId]!, - isActive: index === 0, - launchAgent: 'codex', - status: 'ready' as const, - terminal: `terminal-${index + 1}`, - ...(args.hostAgentStatusTabIds?.includes(hostTabId) - ? { - agentStatus: { - state: args.hostAgentStatusState ?? ('working' as const), - prompt: `work on ${hostTabId}`, - updatedAt: args.hostNow, - stateStartedAt: args.hostNow - 60_000, - agentType: 'codex', - paneKey: makePaneKey(hostTabId, leafByTab[hostTabId]!), - tabId: hostTabId, - worktreeId: WT, - stateHistory: [] - } - } - : {}) - })) - } -} - -/** Mirrors applyWebSessionTabsStorePatch: build the patch from live state, then set it. */ -function applyHostSnapshot( - store: TestStore, - snapshot: RuntimeMobileSessionTabsResult, - now: number, - opts?: { expectChange?: boolean } -): void { - vi.setSystemTime(now) - const state = store.getState() - const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, now) - if (opts?.expectChange !== false) { - expect(patch, 'host snapshot must pass the freshness gate').not.toBe(state) - } - store.setState(patch as Partial) -} - -/** Replay paired pty-connection ownership: claim on transport creation, verify on byte writes, release on unmount. */ -function replayClientByteStatus( - store: TestStore, - hostTabId: string, - leafId: string, - state: 'working' | 'done', - clientNow: number -): () => void { - vi.setSystemTime(clientNow) - const paneKey = mirrorPaneKey(hostTabId, leafId) - const release = registerRendererOwnedAgentStatusPane(paneKey, ENV) - markRendererOwnedAgentStatusWrite(paneKey) - store - .getState() - .setAgentStatus( - paneKey, - { state, prompt: `review on ${hostTabId}`, agentType: 'codex' }, - 'codex', - undefined, - { - tabId: mirrorTabId(hostTabId), - worktreeId: WT - } - ) - return release -} - -/** Byte-identical replay of useRetainedAgentsSync's effect body, minus React. */ -function replayRetainedAgentsSync( - store: TestStore, - previousAgents: RetainedSyncAgents, - now: number -): RetainedSyncAgents { - const state = store.getState() - const { currentAgents, existingWorktreeIds, tabIndex } = buildRetainedAgentsSyncSnapshot({ - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - folderWorkspaces: state.folderWorkspaces, - tabsByWorktree: state.tabsByWorktree, - agentStatusByPaneKey: state.agentStatusByPaneKey, - now - }) - const { toRetain, consumedSuppressedPaneKeys } = collectRetainedAgentsOnDisappear({ - previousAgents, - currentAgents, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys, - recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds, - recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys, - tabIndex - }) - store.getState().retainAgents(toRetain) - store.getState().pruneRetainedAgents(existingWorktreeIds) - if (consumedSuppressedPaneKeys.length > 0) { - store.getState().clearRetentionSuppressedPaneKeys(consumedSuppressedPaneKeys) - } - return currentAgents -} - -function seedPairedClientStore(): TestStore { - const store = createTestStore() - seedStore(store, { - settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, - worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, - activeWorktreeId: WT - } as Partial) - return store -} - -type SidebarObservation = { - tabIds: string[] - rowPaneKeys: string[] - retainedPaneKeys: string[] -} - -/** Byte-identical to useWorktreeAgentRows' inputs, minus React. */ -function observeSidebar(store: TestStore, now: number): SidebarObservation { - const state = store.getState() - const tabs = state.tabsByWorktree[WT] ?? [] - const rows = buildWorktreeAgentRows({ - tabs, - entries: selectLiveAgentStatusEntriesForWorktree(state, WT), - retained: selectRetainedAgentEntriesForWorktree(state, WT), - runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, WT), - ptyIdsByTabId: selectLivePtyIdsForWorktree(state, WT), - terminalLayoutsByTabId: Object.fromEntries( - tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]]) - ), - now - }) - return { - tabIds: tabs.map((tab) => tab.id), - rowPaneKeys: rows.filter((row) => row.rowSource !== 'subagent').map((row) => row.paneKey), - retainedPaneKeys: Object.keys(state.retainedAgentsByPaneKey) - } -} - -describe('a host-retracted paired tab leaves no ghost agent row behind', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(T0) - resetWebSessionTabsSnapshotFreshnessForTests() - resetRendererOwnedAgentStatusPanesForTests() - }) - - afterEach(() => { - vi.useRealTimers() - resetRendererOwnedAgentStatusPanesForTests() - }) - - it('Mechanism C: a done agent whose tab the host retracted must not become a permanent retained row', () => { - const store = seedPairedClientStore() - let previousAgents: RetainedSyncAgents = new Map() - applyHostSnapshot( - store, - makeHostSnapshot({ - snapshotVersion: 1, - hostTabIds: [KEEP_TAB, RETRACTED_TAB], - hostNow: T0 - 1_000 - }), - T0 - ) - const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'done', T0) - previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 500) - expect([...previousAgents.keys()]).toContain(GHOST_PANE_KEY) - - applyHostSnapshot( - store, - makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), - T0 + 2_000 - ) - // Require closeTab’s named marker, not merely an arbitrary retention suppressor. - expect( - store.getState().recentlyClosedAgentStatusTabIds[mirrorTabId(RETRACTED_TAB)], - 'the paired retraction did not plant the closed-tab suppressor' - ).toBeDefined() - release() - previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 2_500) - - const observed = observeSidebar(store, T0 + 3_000) - const evidence = JSON.stringify( - { observed, retained: store.getState().retainedAgentsByPaneKey[GHOST_PANE_KEY] }, - null, - 2 - ) - // Host retraction needs the same closed-tab retention suppression as local close. - expect( - store.getState().retainedAgentsByPaneKey[GHOST_PANE_KEY], - `a host-retracted done agent was promoted into permanent retained state\n${evidence}` - ).toBeUndefined() - expect( - observed.rowPaneKeys.filter((paneKey) => paneKey === GHOST_PANE_KEY), - `the sidebar renders a retained ghost row for a tab that no longer exists\n${evidence}` - ).toEqual([]) - }) - - it('a retract-and-return flap must not strand a suppressor that eats a later real retention', () => { - const store = seedPairedClientStore() - let previousAgents: RetainedSyncAgents = new Map() - applyHostSnapshot( - store, - makeHostSnapshot({ - snapshotVersion: 1, - hostTabIds: [KEEP_TAB, RETRACTED_TAB], - hostNow: T0 - 1_000 - }), - T0 - ) - const release = replayClientByteStatus(store, RETRACTED_TAB, RETRACTED_LEAF, 'working', T0) - previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 500) - - // React can batch retraction/return into one retention interval while the pane stays mounted. Its next fresh write must lift the unconsumed one-shot suppressor or a later real completion is lost. - applyHostSnapshot( - store, - makeHostSnapshot({ snapshotVersion: 2, hostTabIds: [KEEP_TAB], hostNow: T0 + 1_000 }), - T0 + 2_000 - ) - applyHostSnapshot( - store, - makeHostSnapshot({ - snapshotVersion: 3, - hostTabIds: [KEEP_TAB, RETRACTED_TAB], - hostNow: T0 + 2_500 - }), - T0 + 3_000 - ) - vi.setSystemTime(T0 + 3_500) - markRendererOwnedAgentStatusWrite(GHOST_PANE_KEY) - store - .getState() - .setAgentStatus( - GHOST_PANE_KEY, - { state: 'done', prompt: 'review finished', agentType: 'codex' }, - 'codex', - undefined, - { tabId: mirrorTabId(RETRACTED_TAB), worktreeId: WT } - ) - previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 4_000) - expect(store.getState().agentStatusByPaneKey[GHOST_PANE_KEY]?.state).toBe('done') - - // After unmount, ordinary disappearance of non-client-owned done status must remain retainable. - release() - applyHostSnapshot( - store, - makeHostSnapshot({ - snapshotVersion: 4, - hostTabIds: [KEEP_TAB, RETRACTED_TAB], - hostNow: T0 + 600_000 - }), - T0 + 601_000 - ) - expect( - store.getState().agentStatusByPaneKey[GHOST_PANE_KEY], - 'precondition: the released done row must be removed by the mirror delete loop' - ).toBeUndefined() - previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 601_500) - - expect( - store.getState().retainedAgentsByPaneKey[GHOST_PANE_KEY], - 'a stranded retraction suppressor ate a legitimate done-agent retention' - ).toBeDefined() - }) -})