diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index da866c17ac4..a9bd26804e0 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -60,6 +60,7 @@ function card(overrides: Partial): DashboardCard { repoName: 'Repo', worktreeName: 'wt', startedAt: 0, + finishedAt: null, stateChangedAt: 0, unseen: false, ...overrides diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index 9e2712e2e6c..d338a21d381 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -35,6 +35,7 @@ function card(overrides: Partial = {}): DashboardCard { repoName: 'Orca', worktreeName: 'dashboard-review', startedAt: 1_000, + finishedAt: null, stateChangedAt: 1_000, unseen: false, ...overrides diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index ef4d6ddec32..fc87e507a4b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -26,6 +26,12 @@ function formatStartedAgo(startedAt: number, now: number): string { }) } +/** The timestamp the card's time column counts from: since it finished when the + * agent has completed, else since it started — parity with the worktree sidebar. */ +function displayTimestamp(card: DashboardCard): number { + return card.finishedAt ?? card.startedAt +} + function sameCard(a: DashboardCard, b: DashboardCard): boolean { return ( a.paneKey === b.paneKey && @@ -43,6 +49,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.repoName === b.repoName && a.worktreeName === b.worktreeName && a.startedAt === b.startedAt && + a.finishedAt === b.finishedAt && a.stateChangedAt === b.stateChangedAt && a.unseen === b.unseen && a.askSummary === b.askSummary @@ -125,9 +132,9 @@ export const AgentKanbanCard = memo(
{card.repoName} - {card.startedAt > 0 ? ( + {displayTimestamp(card) > 0 ? ( - {formatStartedAgo(card.startedAt, now)} + {formatStartedAgo(displayTimestamp(card), now)} ) : null}
@@ -137,7 +144,7 @@ export const AgentKanbanCard = memo( (previous, next) => previous.onOpenTerminal === next.onOpenTerminal && sameCard(previous.card, next.card) && - (previous.card.startedAt <= 0 || - formatStartedAgo(previous.card.startedAt, previous.now) === - formatStartedAgo(next.card.startedAt, next.now)) + (displayTimestamp(previous.card) <= 0 || + formatStartedAgo(displayTimestamp(previous.card), previous.now) === + formatStartedAgo(displayTimestamp(next.card), next.now)) ) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index 76177fe955b..29d9ef9f310 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { SquareArrowOutUpRight } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { agentStateLabel } from '@/components/AgentStateDot' @@ -41,7 +42,6 @@ export function AgentTerminalDialog({ {card ? ( )} -
- - - +
diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts new file mode 100644 index 00000000000..2c81cec0be2 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -0,0 +1,27 @@ +import type { DashboardAgentRow } from './useDashboardData' + +/** + * The moment an agent last entered `done`, or null if it never finished (still + * working / idle without a prior completion). Shared by the left worktree + * sidebar and the pop-out dashboard so both time from the SAME event: a finished + * agent reads "N since it finished", an active one falls through to its start. + */ +export function lastEnteredDoneAt( + agent: Pick +): number | null { + // Why: idle subagent child rows are alive-but-idle (teammates persist + // between turns), not finished — fall through to the started-at timestamp. + if (agent.rowSource === 'subagent' && agent.state === 'idle') { + return null + } + const entry = agent.entry + if (entry.state === 'done') { + return entry.stateStartedAt + } + for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { + if (entry.stateHistory[i].state === 'done') { + return entry.stateHistory[i].startedAt + } + } + return null +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 6a44e73c828..cce4a5cb062 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -8,6 +8,7 @@ import type { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { applyAgentRowLineage } from './agent-row-lineage' +import { lastEnteredDoneAt } from './agent-finished-timestamp' import type { DashboardAgentRow } from './useDashboardData' import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows' import { @@ -155,6 +156,7 @@ export function buildDashboardSnapshot( lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt), lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage), startedAt: row.startedAt, + finishedAt: lastEnteredDoneAt(row), stateChangedAt: row.entry.stateStartedAt || row.startedAt, // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the // board and the sidebar bold/mute the same agents at the same time. diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 125c8528cc4..5282fe65422 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { getAgentDotState } from './worktree-card-agent-summary' import { translate } from '@/i18n/i18n' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' +import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' function formatShortTimeAgo(ts: number, now: number): string { @@ -26,24 +27,6 @@ function formatShortTimeAgo(ts: number, now: number): string { return `${Math.floor(hours / 24)}d` } -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - // Why: idle subagent child rows are alive-but-idle (teammates persist - // between turns), not finished — fall through to the started-at timestamp. - if (agent.rowSource === 'subagent' && agent.state === 'idle') { - return null - } - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function getCompactAgentPrimary(agent: DashboardAgentRowData): string { const prompt = getAgentRowPrimaryText(agent.entry) return prompt || agentStateLabel(getAgentDotState(agent)) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9d830a1aa56..bc4b94bfb12 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13608,8 +13608,7 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", - "focusWorktree": "Focus worktree" + "focusWorktree": "Open worktree" } }, "dashboard": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2bd07533c66..713265c0336 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13585,7 +13585,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c76b43af82d..a4c750353b2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13585,7 +13585,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 755bc978803..25c59214cfc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13585,7 +13585,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index caba5e748ae..9892c0194e8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13585,7 +13585,6 @@ }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", - "close": "Close", "focusWorktree": "Focus worktree" } }, diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 2f63f451df4..0f3296c4e1d 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -45,6 +45,10 @@ export type DashboardCard = { worktreeName: string /** "Started … ago" display. */ startedAt: number + /** When the agent last entered `done`, or null if it never finished. Drives + * the card's time column: finished cards read time-since-finish (parity with + * the left worktree sidebar), active cards fall back to startedAt. */ + finishedAt: number | null /** When the agent entered its current state — column ordering key (cards * that moved into a bucket most recently sort first). 0 when unknown. */ stateChangedAt: number