mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -13608,8 +13608,7 @@
|
||||
},
|
||||
"terminal": {
|
||||
"closed": "No live terminal — this agent's pane has closed.",
|
||||
"close": "Close",
|
||||
"focusWorktree": "Focus worktree"
|
||||
"focusWorktree": "Open worktree"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -13585,7 +13585,6 @@
|
||||
},
|
||||
"terminal": {
|
||||
"closed": "No live terminal — this agent's pane has closed.",
|
||||
"close": "Close",
|
||||
"focusWorktree": "Focus worktree"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13585,7 +13585,6 @@
|
||||
},
|
||||
"terminal": {
|
||||
"closed": "No live terminal — this agent's pane has closed.",
|
||||
"close": "Close",
|
||||
"focusWorktree": "Focus worktree"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13585,7 +13585,6 @@
|
||||
},
|
||||
"terminal": {
|
||||
"closed": "No live terminal — this agent's pane has closed.",
|
||||
"close": "Close",
|
||||
"focusWorktree": "Focus worktree"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13585,7 +13585,6 @@
|
||||
},
|
||||
"terminal": {
|
||||
"closed": "No live terminal — this agent's pane has closed.",
|
||||
"close": "Close",
|
||||
"focusWorktree": "Focus worktree"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user