feat(dashboard): open-worktree dialog control + finished-time parity

Pop-out agent dashboard polish:
- Terminal dialog: replace the footer Close button with the standard top-right
  X, rename the primary action from Focus worktree to Open worktree, and prefix
  it with an open-in-window icon.
- Card time column: finished agents now read time-since-finish (matching the
  left worktree sidebar) instead of time-since-start. Extracts the sidebar's
  finished-timestamp logic into a shared helper so the two surfaces cannot
  drift, and threads finishedAt through the dashboard snapshot contract.
This commit is contained in:
Brennan Benson
2026-07-20 21:37:39 -07:00
parent 6c9be228ad
commit 1976d61eec
13 changed files with 53 additions and 37 deletions
@@ -60,6 +60,7 @@ function card(overrides: Partial<DashboardCard>): DashboardCard {
repoName: 'Repo',
worktreeName: 'wt',
startedAt: 0,
finishedAt: null,
stateChangedAt: 0,
unseen: false,
...overrides
@@ -35,6 +35,7 @@ function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
repoName: 'Orca',
worktreeName: 'dashboard-review',
startedAt: 1_000,
finishedAt: null,
stateChangedAt: 1_000,
unseen: false,
...overrides
@@ -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(
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate font-mono">{card.repoName}</span>
{card.startedAt > 0 ? (
{displayTimestamp(card) > 0 ? (
<span className="ml-auto shrink-0 tabular-nums">
{formatStartedAgo(card.startedAt, now)}
{formatStartedAgo(displayTimestamp(card), now)}
</span>
) : null}
</div>
@@ -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))
)
@@ -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({
<Dialog open={card !== null} onOpenChange={onOpenChange}>
{card ? (
<DialogContent
showCloseButton={false}
aria-describedby={undefined}
// Why: sm:max-w-lg in DialogContent's base classes would defeat a bare
// max-w-*, so the full-width override must carry the same breakpoint.
@@ -81,15 +81,11 @@ export function AgentTerminalDialog({
)}
</div>
)}
<div className="flex items-center justify-end gap-2 px-2.5 py-1.5">
<DialogClose asChild>
<Button type="button" variant="outline" size="xs">
{translate('dashboardPopout.terminal.close', 'Close')}
</Button>
</DialogClose>
<div className="flex items-center justify-end px-2.5 py-1.5">
<DialogClose asChild>
<Button type="button" variant="outline" size="xs" onClick={reveal}>
{translate('dashboardPopout.terminal.focusWorktree', 'Focus worktree')}
<SquareArrowOutUpRight className="size-3" />
{translate('dashboardPopout.terminal.focusWorktree', 'Open worktree')}
</Button>
</DialogClose>
</div>
@@ -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<DashboardAgentRow, 'rowSource' | 'state' | 'entry'>
): 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
}
@@ -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.
@@ -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))
+1 -2
View File
@@ -13608,8 +13608,7 @@
},
"terminal": {
"closed": "No live terminal — this agent's pane has closed.",
"close": "Close",
"focusWorktree": "Focus worktree"
"focusWorktree": "Open worktree"
}
},
"dashboard": {
-1
View File
@@ -13585,7 +13585,6 @@
},
"terminal": {
"closed": "No live terminal — this agent's pane has closed.",
"close": "Close",
"focusWorktree": "Focus worktree"
}
},
-1
View File
@@ -13585,7 +13585,6 @@
},
"terminal": {
"closed": "No live terminal — this agent's pane has closed.",
"close": "Close",
"focusWorktree": "Focus worktree"
}
},
-1
View File
@@ -13585,7 +13585,6 @@
},
"terminal": {
"closed": "No live terminal — this agent's pane has closed.",
"close": "Close",
"focusWorktree": "Focus worktree"
}
},
-1
View File
@@ -13585,7 +13585,6 @@
},
"terminal": {
"closed": "No live terminal — this agent's pane has closed.",
"close": "Close",
"focusWorktree": "Focus worktree"
}
},
+4
View File
@@ -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