fix(dashboard): card timer shows total execution = timed events + workflow runtime

The task card timer chip previously fell back through several metrics
(timed duration → workflow runtime → wallclock), so cards showed only a
subset of execution time. For FN-2714 this rendered <1m on the card while
the stats tab reported >2m of workflow runtime.

The chip now reports the sum of [timing]-tagged log events and workflow
step runtime (matching the new "Total execution time" metric in the stats
panel), with live elapsed for in-progress workflow steps. When neither
metric is recorded, the chip is hidden rather than falling back to
wallclock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 14:23:54 -07:00
committed by gsxdsm
parent 777d6a1942
commit 7986b03207
9 changed files with 921 additions and 412 deletions

View File

@@ -166,8 +166,6 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
// ── Mini inline logo (header) ─────────────────────────────────────────────────
function MiniLogo() {
// flexShrink={0} keeps FUSION from being squeezed (and wrapping) when the
// header row's other children push past the terminal width.
return (
<Box flexDirection="row" gap={0} flexShrink={0}>
<Text color="cyanBright" bold wrap="truncate-end">FUSION</Text>
@@ -496,12 +494,12 @@ function LogsPanel({
) : entries.length !== state.logEntries.length && entries.length === 0 ? (
<Text dimColor>No entries match filter {logsSeverityFilter.toUpperCase()}.</Text>
) : (
<Box flexDirection="column">
<Box flexDirection="row" gap={1} marginBottom={0}>
<Text dimColor>[w] wrap {logsWrapEnabled ? "on" : "off"}</Text>
<Text dimColor>[f] {logsSeverityFilter}</Text>
{hiddenAbove > 0 && <Text dimColor>↑ {hiddenAbove} more</Text>}
{hiddenBelow > 0 && <Text dimColor>↓ {hiddenBelow} more</Text>}
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
<Box height={1} flexDirection="row" gap={1} marginBottom={0} flexShrink={0} overflow="hidden">
<Text wrap="truncate-end" dimColor>[w] wrap {logsWrapEnabled ? "on" : "off"}</Text>
<Text wrap="truncate-end" dimColor>[f] {logsSeverityFilter}</Text>
{hiddenAbove > 0 && <Text wrap="truncate-end" dimColor>↑ {hiddenAbove} more</Text>}
{hiddenBelow > 0 && <Text wrap="truncate-end" dimColor>↓ {hiddenBelow} more</Text>}
</Box>
{visibleEntries.map((entry, displayIdx) => {
const absoluteIndex = visibleStart + displayIdx;
@@ -512,43 +510,60 @@ function LogsPanel({
const lvlColor = entry.level === "error" ? "red" : entry.level === "warn" ? "yellow" : "green";
const marker = isSelected ? "▶ " : " ";
// Wrap each entry in a height-pinned Box (when wrap is off) so a
// single entry can never grow beyond 1 row. Without this, certain
// narrow widths can cause Ink/Yoga to measure the nested-Text
// entry as taller than 1 row even with truncate-end set, which
// pushes the panel intrinsic height past the slot and scrolls
// the outer header off the top of the alt-screen.
const entryHeight = logsWrapEnabled ? undefined : 1;
if (isNarrow) {
const idx = narrowTimestamp(absoluteIndex);
const pfx = narrowPrefix(entry.prefix, NARROW_PREFIX_WIDTH);
return (
<Text
<Box
key={`${entry.timestamp.getTime()}-${displayIdx}`}
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
height={entryHeight}
flexShrink={0}
overflow="hidden"
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{idx} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${pfx} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
<Text
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{idx} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${pfx} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
</Box>
);
}
const ts = formatTimestamp(entry.timestamp);
// Pad/truncate prefix to a fixed slot so message column aligns
// across rows (rows without a prefix get blank padding instead of
// collapsing).
const prefixSlot = entry.prefix
? `[${entry.prefix}]`.slice(0, PREFIX_WIDTH).padEnd(PREFIX_WIDTH)
: " ".repeat(PREFIX_WIDTH);
return (
<Text
<Box
key={`${entry.timestamp.getTime()}-${displayIdx}`}
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
height={entryHeight}
flexShrink={0}
overflow="hidden"
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{ts} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${prefixSlot} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
<Text
backgroundColor={bg}
wrap={logsWrapEnabled ? "wrap" : "truncate-end"}
>
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>{marker}</Text>
<Text color={fg} dimColor={!isSelected}>{ts} </Text>
<Text color={lvlColor}>{lvl}</Text>
<Text color={fg} dimColor={!isSelected}>{` ${prefixSlot} `}</Text>
<Text color={fg} bold={isSelected}>{entry.message}</Text>
</Text>
</Box>
);
})}
</Box>
@@ -599,11 +614,11 @@ function UtilitiesPanel({ state, isFocused }: { state: DashboardState; isFocused
];
return (
<Panel title="Utilities" isFocused={isFocused} flexGrow={1}>
<Box flexDirection="column">
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
{actions.map((action) => (
<Box key={action.key} flexDirection="row" gap={1}>
<Box key={action.key} flexDirection="row" gap={1} flexShrink={0}>
<Text color="yellow">[{action.key}]</Text>
<Text>{action.label}</Text>
<Text wrap="truncate-end">{action.label}</Text>
</Box>
))}
</Box>
@@ -620,7 +635,7 @@ function HelpOverlay() {
["[a]", "Agents view"],
["[g]", "Settings view"],
["[t]", "Git view"],
["[e]", "Explorer (file browser)"],
["[f]", "Files (when not on Logs); cycles log severity filter on Logs"],
["[Tab]", "Cycle focused panel / pane forward"],
["[Shift+Tab]", "Cycle focused panel / pane backward"],
["[1-5]", "Jump to panel (Main: System/Logs/Utilities/Stats/Settings)"],
@@ -637,7 +652,6 @@ function HelpOverlay() {
["[Enter/Space]", "Expand log entry (Logs)"],
["[c]", "Copy selected log entry to clipboard (Logs)"],
["[w]", "Toggle word wrap (Logs / Files)"],
["[f]", "Cycle severity filter (Main, any panel)"],
["[Space]", "Toggle boolean (Settings)"],
["[+/-]", "Adjust number (Settings)"],
["[p]", "Project picker (Board, Files)"],
@@ -691,11 +705,10 @@ function StatusModeGrid({
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
const cols = stdout?.columns ?? 80;
// Top row: System (narrow) + Logs (wide).
// Bottom row: Stats + Utilities + Settings, all the same fixed height so
// they line up. ~8 rows fits Stats (4 stat rows + chrome 3) and a few
// utility/settings entries — anything more is clipped.
const middleHeight = Math.max(1, rows - 2);
// Middle area = rows - header(1) - body marginTop(1) - statusbar(1) = rows-3.
// Top of middle: System (short, intrinsic) + Logs (fills).
// Bottom of middle: Stats + Utilities + Settings, equal-width.
const middleHeight = Math.max(1, rows - 3);
const bottomShare = Math.min(10, Math.max(6, Math.floor(middleHeight * 0.35)));
const topShare = Math.max(1, middleHeight - bottomShare);
// LogsPanel chrome: border 2 + title 1 + filter 1 = 4.
@@ -705,20 +718,23 @@ function StatusModeGrid({
return (
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="column" flexGrow={1} overflow="hidden">
{/* System: full width, short height (border 2 + 1-2 wrapped content rows). */}
<Box flexShrink={0} overflow="hidden">
{/* System: full width, short height. flexShrink=2 so it collapses
faster than Logs when vertical space is tight. */}
<Box flexShrink={2} overflow="hidden">
<SystemPanel state={state} isFocused={focused === "system"} />
</Box>
{/* Logs: fills remaining vertical space. */}
<Box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
{/* Logs: fills remaining vertical space. flexShrink=0 so System and
the bottom row collapse first — Logs keeps its space. */}
<Box flexGrow={1} flexShrink={0} flexDirection="column" overflow="hidden">
<LogsPanel
state={state}
isFocused={focused === "logs"}
availableRows={logsAvailableRows}
/>
</Box>
{/* Bottom row: Stats + Utilities + Settings, equal-width. */}
<Box flexDirection="row" flexShrink={0} overflow="hidden">
{/* Bottom row: Stats + Utilities + Settings, equal-width. flexShrink=2
so when terminal height is small they collapse before Logs does. */}
<Box flexDirection="row" flexShrink={2} overflow="hidden">
<Box flexDirection="column" flexGrow={1} flexBasis={0} overflow="hidden">
<StatsPanel state={state} isFocused={focused === "stats"} />
</Box>
@@ -749,10 +765,12 @@ function StatusModeSingle({
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
const cols = stdout?.columns ?? 80;
// LogsPanel's row budget needs an explicit cap so it doesn't try to render
// more entries than will fit. Header(1) + StatusBar(1) + Panel chrome(3)
// + filter row(1) = 6.
const logsAvailableRows = Math.max(1, rows - 6);
// LogsPanel's row budget — an explicit cap so it doesn't try to render
// more entries than will fit. Chrome accounting:
// header(1) + body marginTop(1) + statusbar(1) +
// panel border top(1) + panel title(1) + panel border bottom(1) +
// filter row(1) = 7.
const logsAvailableRows = Math.max(1, rows - 7);
tuiDebug("StatusModeSingle", { cols, rows, logsAvailableRows, focused });
const activePanel = () => {
@@ -818,16 +836,6 @@ function MainHeader({ state }: { state: DashboardState }) {
const interactiveView = state.interactiveView;
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const rows = stdout?.rows ?? 24;
tuiDebug("MainHeader", {
cols,
rows,
mode: state.mode,
view: state.interactiveView,
activeSection: state.activeSection,
});
// Single unified tab strip. "Main" is the status mode; the rest are
// interactive views. Active key matches the current mode/view.
type Tab =
| { key: string; label: string; kind: "main" }
| { key: string; label: string; kind: "interactive"; view: InteractiveView };
@@ -837,18 +845,8 @@ function MainHeader({ state }: { state: DashboardState }) {
{ key: "a", label: "Agents", kind: "interactive", view: "agents" },
{ key: "g", label: "Settings", kind: "interactive", view: "settings" },
{ key: "t", label: "Git", kind: "interactive", view: "git" },
{ key: "e", label: "Explorer", kind: "interactive", view: "files" },
{ key: "f", label: "Files", kind: "interactive", view: "files" },
];
// Don't gate header rendering on stdout.rows — tmux pane switches and
// other resize events can briefly report stale or zero dimensions, and a
// transient `return null` orphans the header on the next layout pass.
// Always render; Yoga/overflow:hidden handles the extreme cases.
// Width tiers, measured against actual rendered content with 6 tabs:
// * Full + help (cols >= 110): "[k] Label" tabs + help/quit hint.
// * Full (90-109): "[k] Label" tabs, no help hint (~85 chars).
// * Compact (50-89): "[k]" glyphs only — every shortcut still
// visible, just no labels (~45 chars).
// * Tiny (< 50): just FUSION + the active tab pill.
const showHelpHint = cols >= 110;
const fullLabels = cols >= 90;
const tiny = cols < 50;
@@ -856,7 +854,6 @@ function MainHeader({ state }: { state: DashboardState }) {
const isActive = (t: Tab) =>
t.kind === "main" ? !inInteractive : inInteractive && t.view === interactiveView;
if (tiny) {
// Just FUSION + the active tab pill. Inactive shortcuts dropped here.
const active = tabs.find(isActive);
return (
<Box height={1} flexDirection="row" gap={1} paddingX={1} flexShrink={0} overflow="hidden">
@@ -869,13 +866,6 @@ function MainHeader({ state }: { state: DashboardState }) {
</Box>
);
}
// height={1} hard-caps the header at a single row. Without this, at
// certain boundary widths the default Text wrap="wrap" on a tab whose
// content lands one column past the parent width would push a second
// row, making the header 2 rows tall — which in turn makes the whole
// frame exceed terminal rows, so Ink pushes the top of the layout
// off-screen. Combined with wrap="truncate-end" on every tab Text
// below, both axes are protected against single-column overflow.
return (
<Box height={1} flexDirection="row" gap={1} paddingX={1} paddingY={0} flexShrink={0} overflow="hidden">
<MiniLogo />
@@ -3851,13 +3841,17 @@ export function DashboardApp({ controller }: DashboardAppProps) {
controller.setInteractiveView("settings");
return;
}
// Logs severity filter — works any time the logs panel is visible
// (status mode). Cycles all → info → warn → error → all.
// 'f' is overloaded:
// * status mode + Logs panel focused → cycle severity filter
// * everywhere else → switch to Files view (interactive)
if (input === "f" || input === "F") {
if (state.mode === "status") {
if (state.mode === "status" && state.activeSection === "logs") {
controller.cycleSeverityFilter();
return;
}
controller.setMode("interactive");
controller.setInteractiveView("files");
return;
}
if (input === "t" || input === "T") {
@@ -3866,12 +3860,6 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return;
}
if (input === "e" || input === "E") {
controller.setMode("interactive");
controller.setInteractiveView("files");
return;
}
// 'm' / 's' (alias) — switch to Main (status mode). Lowercase only;
// capital S/M are reserved for vim-style "jump to end" semantics.
if (input === "m" || input === "s") {
@@ -4066,30 +4054,29 @@ export function DashboardApp({ controller }: DashboardAppProps) {
hasSystemInfo: Boolean(state.systemInfo),
});
// Pin explicit heights on both children so the layout is fully deterministic
// and Yoga has no freedom to redistribute rows. Header always occupies row 0;
// the body fills rows 1..(rows-1). With overflow:hidden everywhere, content
// that exceeds its slot is clipped — the header can't be pushed off.
const headerHeight = 1;
const bodyHeight = Math.max(0, rows - headerHeight);
// Use flex-column natural placement (matches what Board/InteractiveMode
// does — that layout has always worked). Header takes its intrinsic 1
// row; body fills the rest via flexGrow=1.
//
// CRITICAL: marginTop={1} on the body. At narrow widths with Logs or
// Utilities active, Yoga's flex-column was placing the panel border at
// y=0 (same row as the header), causing the body to overdraw the header.
// The 1-row top margin guarantees panel content starts on row 1
// regardless of any Yoga edge-case at certain widths. Net cost: 1 row
// of vertical space (so the panel area is rows-2 instead of rows-1),
// but the header is always visible.
return (
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
<Box
height={headerHeight}
width={cols}
flexShrink={0}
flexGrow={0}
flexDirection="row"
overflow="hidden"
>
{/* Header: explicit height={1} so the wrapper always reserves row 0,
even at narrow widths where MainHeader's intrinsic height could
(in some Yoga edge cases) collapse to 0. */}
<Box height={1} width={cols} flexShrink={0} flexGrow={0} flexDirection="row" overflow="hidden">
<MainHeader state={state} />
</Box>
<Box
height={bodyHeight}
width={cols}
flexShrink={0}
flexGrow={0}
flexGrow={1}
flexShrink={1}
marginTop={1}
flexDirection="column"
overflow="hidden"
>

View File

@@ -402,8 +402,17 @@ export class DashboardTUI {
// ── State helpers called from Ink App ────────────────────────────────────
setActiveSection(section: SectionId): void {
const changed = this.activeSection !== section;
this.activeSection = section;
this.showHelp = false;
if (changed) {
// Brute-force: wipe alt-screen + reset Ink's log-update tracking before
// rendering the new section. Some sections (Logs, Utilities) appear to
// leave residual state in tmux that scrolls the header off the top on
// the first render after switching. A full recover here is overkill on
// every switch but eliminates the artifact.
this.recoverFrameNow();
}
this.notify();
}
@@ -442,6 +451,7 @@ export class DashboardTUI {
const idx = SECTION_ORDER.indexOf(this.activeSection);
this.activeSection = SECTION_ORDER[(idx + direction + SECTION_ORDER.length) % SECTION_ORDER.length];
this.showHelp = false;
this.recoverFrameNow();
this.notify();
}
@@ -637,6 +647,16 @@ export class DashboardTUI {
// visible at the bottom. \x1b[2J\x1b[H wipes the buffer first.
// Order: wipe → reset Ink's tracking → record dims → notify so React
// reads fresh dims and rerenders cleanly.
// Public version: callable from state-changing methods (e.g. switching
// panels). Reads current dims from process.stdout and runs the same
// wipe + ink-clear sequence as the resize-driven path.
recoverFrameNow(): void {
const cols = process.stdout?.columns ?? 0;
const rows = process.stdout?.rows ?? 0;
if (cols <= 0 || rows <= 0) return;
this.recoverFrame(cols, rows);
}
private recoverFrame(cols: number, rows: number): void {
tuiDebug("recoverFrame", {
cols,

View File

@@ -5,12 +5,48 @@
justify-content: center;
}
.planning-modal *,
.planning-modal {
width: 90vw;
max-width: 640px;
min-height: 400px;
max-height: min(90vh, calc(100dvh - 2 * var(--overlay-padding-top, 10vh)));
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
}
.planning-modal *::-webkit-scrollbar,
.planning-modal::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.planning-modal *::-webkit-scrollbar-track,
.planning-modal::-webkit-scrollbar-track {
background: transparent;
}
.planning-modal *::-webkit-scrollbar-thumb,
.planning-modal::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-sm);
}
.planning-modal *::-webkit-scrollbar-thumb:hover,
.planning-modal::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.planning-modal *::-webkit-scrollbar-corner,
.planning-modal::-webkit-scrollbar-corner {
background: transparent;
}
.planning-modal {
width: min(95vw, 960px);
max-width: 95vw;
min-width: 360px;
height: 85vh;
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
overflow: hidden;
resize: both;
}
.planning-modal .modal-header {
@@ -30,6 +66,225 @@
position: relative;
}
.planning-modal-body--split {
flex-direction: row;
}
.planning-detail {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
/* Sidebar */
.planning-sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--card);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.planning-sidebar-header {
padding: 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.planning-sidebar-new {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.planning-sidebar-new:hover {
background: var(--card-hover);
border-color: var(--todo);
}
.planning-sidebar-new.active {
background: color-mix(in srgb, var(--todo) 15%, transparent);
border-color: var(--todo);
color: var(--todo);
}
.planning-sidebar-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.planning-sidebar-empty {
padding: 16px 12px;
font-size: 12px;
text-align: center;
line-height: 1.5;
}
.planning-sidebar-item {
position: relative;
display: flex;
align-items: stretch;
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.planning-sidebar-item:hover {
background: var(--card-hover);
}
.planning-sidebar-item.selected {
background: color-mix(in srgb, var(--todo) 18%, transparent);
}
.planning-sidebar-item.pending-delete {
background: color-mix(in srgb, var(--danger, #f85149) 15%, transparent);
}
.planning-sidebar-item-button {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 8px 10px 10px;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.planning-sidebar-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.planning-sidebar-item-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.planning-sidebar-item-meta {
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 4px;
align-items: center;
}
.planning-sidebar-status-icon {
flex-shrink: 0;
margin-top: 2px;
}
.planning-sidebar-status-generating { color: var(--todo); }
.planning-sidebar-status-awaiting { color: var(--triage); }
.planning-sidebar-status-complete { color: var(--success, #3fb950); }
.planning-sidebar-status-error { color: var(--danger, #f85149); }
.planning-sidebar-item-delete {
flex-shrink: 0;
width: 32px;
display: none;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
}
.planning-sidebar-item:hover .planning-sidebar-item-delete,
.planning-sidebar-item:focus-within .planning-sidebar-item-delete {
display: flex;
}
.planning-sidebar-item-delete:hover {
color: var(--danger, #f85149);
background: color-mix(in srgb, var(--danger, #f85149) 15%, transparent);
}
.planning-sidebar-confirm {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
}
.planning-mobile-back {
display: none;
background: none;
border: none;
color: var(--text);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
}
.planning-mobile-back:hover {
background: var(--card-hover);
}
/* Mobile: stack — only one pane visible at a time */
@media (max-width: 720px) {
.planning-modal-body--split {
flex-direction: column;
}
.planning-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.planning-modal-body--show-detail .planning-sidebar {
display: none;
}
.planning-modal-body--show-list .planning-detail {
display: none;
}
.planning-modal-body--show-detail .planning-mobile-back {
display: inline-flex;
}
/* Always keep delete button visible on mobile (no hover) */
.planning-sidebar-item-delete {
display: flex;
}
}
.planning-error {
flex-shrink: 0;
margin: 24px 24px 0;
@@ -249,8 +504,8 @@
.planning-progress {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding-bottom: 16px;
gap: var(--space-xs);
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -286,15 +541,15 @@
}
.planning-question-scroll {
gap: 20px;
padding-top: var(--space-md);
gap: 16px;
padding-top: 0;
}
.planning-question-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
gap: 16px;
padding: 16px 20px 20px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);

View File

@@ -9,6 +9,8 @@ import {
createTaskFromPlanning,
connectPlanningStream,
fetchAiSession,
fetchAiSessions,
deleteAiSession,
parseConversationHistory,
startPlanningBreakdown,
createTasksFromPlanning,
@@ -19,13 +21,15 @@ import {
type SubtaskItem,
type ModelInfo,
type ConversationHistoryEntry,
type AiSessionSummary,
} from "../api";
import { subscribeSse } from "../sse-bus";
import {
savePlanningDescription,
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
@@ -128,6 +132,31 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const trackedLockSessionRef = useRef<string | null>(null);
// Sidebar list state
const [planningSessions, setPlanningSessions] = useState<AiSessionSummary[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(resumeSessionId ?? null);
// Mobile: when the modal is narrow, only one pane is visible at a time.
// `mobileShowDetail` toggles between list (false) and detail (true).
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const resetDetailState = useCallback(() => {
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
}, []);
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
const getModelBadgeLabel = useCallback(
@@ -333,6 +362,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
setSelectedSessionId(sessionId);
connectToPlanningStream(sessionId);
setResponseHistory([]);
@@ -389,17 +419,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
}, [isOpen, initialPlanProp, view.type, handleStartPlanning, projectId]);
// Resume a persisted background session
useEffect(() => {
if (!isOpen || !resumeSessionId || view.type !== "initial") return;
let cancelled = false;
(async () => {
try {
const session = await fetchAiSession(resumeSessionId);
if (cancelled || !session) return;
// Load a specific persisted session into the right pane.
const loadSession = useCallback(
async (sessionId: string) => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
currentSessionIdRef.current = resumeSessionId;
setLockSessionId(resumeSessionId);
setError(null);
setStreamingOutput("");
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setIsRetrying(false);
setView({ type: "loading" });
try {
const session = await fetchAiSession(sessionId);
if (!session) {
setError("Session not found");
setView({ type: "initial" });
return;
}
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
@@ -413,34 +456,163 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (session.status === "awaiting_input" && session.currentQuestion) {
clearPlanningDescription(projectId);
const question = JSON.parse(session.currentQuestion);
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
// Connect to stream for real-time updates (e.g., thinking output, next question)
// The server will emit a catch-up question event if the client missed it
connectToPlanningStream(resumeSessionId);
connectToPlanningStream(sessionId);
} else if (session.status === "complete" && session.result) {
clearPlanningDescription(projectId);
const summary = JSON.parse(session.result);
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
connectToPlanningStream(resumeSessionId);
connectToPlanningStream(sessionId);
} else if (session.status === "error") {
setError(null);
setView({
type: "error",
session: { sessionId: resumeSessionId, currentQuestion: null, summary: null },
session: { sessionId, currentQuestion: null, summary: null },
errorMessage: session.error || "Session failed",
});
}
} catch {
setError("Failed to resume session");
setError("Failed to load session");
setView({ type: "initial" });
}
})();
return () => { cancelled = true; };
}, [connectToPlanningStream, isOpen, resumeSessionId, view.type, projectId]);
},
[connectToPlanningStream, projectId],
);
// Resume the externally-requested session when the modal first opens.
// (Selecting from the sidebar uses handleSelectSession instead.)
useEffect(() => {
if (!isOpen || !resumeSessionId) return;
if (currentSessionIdRef.current === resumeSessionId) return;
setSelectedSessionId(resumeSessionId);
setMobileShowDetail(true);
void loadSession(resumeSessionId);
}, [isOpen, resumeSessionId, loadSession]);
// Load + maintain the planning sessions list (sidebar).
const refreshSessionsList = useCallback(async () => {
setSessionsLoading(true);
try {
const all = await fetchAiSessions(projectId);
const planning = all
.filter((s) => s.type === "planning")
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
setPlanningSessions(planning);
} catch {
// Best-effort: list errors should not block the modal
} finally {
setSessionsLoading(false);
}
}, [projectId]);
useEffect(() => {
if (!isOpen) return;
void refreshSessionsList();
}, [isOpen, refreshSessionsList]);
// SSE subscription keeps the list live (mirrors useBackgroundSessions, but
// unfiltered by status so completed/errored sessions stay visible).
useEffect(() => {
if (!isOpen) return;
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const handleUpdated = (e: MessageEvent) => {
try {
const updated = JSON.parse(e.data) as AiSessionSummary;
if (updated.type !== "planning") return;
setPlanningSessions((prev) => {
const idx = prev.findIndex((s) => s.id === updated.id);
const next = idx >= 0 ? [...prev.slice(0, idx), updated, ...prev.slice(idx + 1)] : [updated, ...prev];
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
});
} catch {
// ignore malformed payload
}
};
const handleDeleted = (e: MessageEvent) => {
try {
const id = JSON.parse(e.data) as string;
setPlanningSessions((prev) => prev.filter((s) => s.id !== id));
} catch {
// ignore malformed payload
}
};
return subscribeSse(`/api/events${params}`, {
events: {
"ai_session:updated": handleUpdated,
"ai_session:deleted": handleDeleted,
},
});
}, [isOpen, projectId]);
// Sidebar handlers
const handleSelectSession = useCallback(
(sessionId: string) => {
if (selectedSessionId === sessionId) {
setMobileShowDetail(true);
return;
}
setSelectedSessionId(sessionId);
setMobileShowDetail(true);
void loadSession(sessionId);
},
[loadSession, selectedSessionId],
);
const handleNewSession = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
resetDetailState();
setSelectedSessionId(null);
setMobileShowDetail(true);
}, [resetDetailState]);
const handleBackToList = useCallback(() => {
setMobileShowDetail(false);
}, []);
const handleDeleteSession = useCallback(
async (sessionId: string) => {
const isActiveServerSession = (status: AiSessionSummary["status"]) =>
status === "generating" || status === "awaiting_input";
const target = planningSessions.find((s) => s.id === sessionId);
// Cancel an in-flight server session before deleting so the engine stops
// generating; for terminal sessions skip the cancel call.
if (target && isActiveServerSession(target.status)) {
try {
await cancelPlanning(sessionId, projectId, sessionTabId);
} catch {
// best-effort
}
}
try {
await deleteAiSession(sessionId);
} catch {
// best-effort: SSE will reconcile if the delete actually succeeded
}
setPlanningSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (selectedSessionId === sessionId) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
resetDetailState();
setSelectedSessionId(null);
setMobileShowDetail(false);
}
setPendingDeleteId(null);
},
[planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId],
);
// Reset hasAutoStarted when modal closes
useEffect(() => {
@@ -527,49 +699,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onClose();
}, [onClose]);
const handleCancel = useCallback(() => {
// Determine the active session ID to abandon
let activeSessionId: string | null = null;
if (view.type === "question" || view.type === "summary" || view.type === "error") {
activeSessionId = view.session.sessionId;
} else if (view.type === "breakdown") {
activeSessionId = view.sessionId;
} else if (view.type === "loading") {
// During loading, the session ID is stored in the ref
activeSessionId = currentSessionIdRef.current;
}
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
if (initialPlan) {
// Close the modal without abandoning the active server session. Sessions
// remain in the list and can be resumed later. Only an explicit Delete
// (from the sidebar) cancels and removes a session.
const handleClose = useCallback(() => {
// Save the in-progress draft so the next open restores it.
if (initialPlan && view.type === "initial") {
savePlanningDescription(initialPlan, projectId);
}
// Always close the stream connection
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
// Explicitly abandon the session on the server to prevent zombie sessions
if (activeSessionId) {
void cancelPlanning(activeSessionId, projectId, sessionTabId).catch(() => {
// Best-effort: cancellation failures should not block UI reset
});
}
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [initialPlan, onClose, projectId, sessionTabId, view]);
}, [initialPlan, onClose, projectId, view.type]);
// Handle escape key to close
useEffect(() => {
@@ -577,13 +721,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleCancel]);
}, [isOpen, handleClose]);
const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => {
@@ -724,12 +868,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
try {
const task = await createTaskFromPlanning(view.session.sessionId, editedSummary ?? undefined, projectId);
onTaskCreated(task);
handleCancel();
handleClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
}
}, [editedSummary, view, projectId, onTaskCreated, handleCancel]);
}, [editedSummary, view, projectId, onTaskCreated, handleClose]);
const handleStartBreakdown = useCallback(async () => {
if (view.type !== "summary") return;
@@ -809,10 +953,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleCancel()} role="dialog" aria-modal="true">
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleClose()} role="dialog" aria-modal="true">
<div className="modal modal-lg planning-modal">
<div className="modal-header">
<div className="detail-title-row">
{mobileShowDetail && (
<button
className="modal-back planning-mobile-back"
onClick={handleBackToList}
aria-label="Back to sessions"
title="Back to sessions"
>
<ChevronLeft size={18} />
</button>
)}
<Lightbulb size={20} className="icon-triage" />
<h3>Planning Mode</h3>
</div>
@@ -827,13 +981,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<button className="modal-close" onClick={handleClose} aria-label="Close">
<X size={20} />
</button>
</div>
</div>
<div className="planning-modal-body">
<div
className={`planning-modal-body planning-modal-body--split ${
mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list"
}`}
>
<PlanningSessionList
sessions={planningSessions}
loading={sessionsLoading}
selectedSessionId={selectedSessionId}
pendingDeleteId={pendingDeleteId}
onSelectSession={handleSelectSession}
onNewSession={handleNewSession}
onRequestDelete={setPendingDeleteId}
onConfirmDelete={(id) => void handleDeleteSession(id)}
onCancelDelete={() => setPendingDeleteId(null)}
/>
<div className="planning-detail">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
{activeInAnotherTab && (
@@ -996,7 +1167,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span className="icon-ml-6">{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Dismiss</button>
<button className="btn" onClick={handleClose} disabled={isRetrying}>Dismiss</button>
</div>
</div>
</div>
@@ -1061,6 +1232,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}}
/>
)}
</div>
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
@@ -1804,3 +1976,159 @@ function BreakdownView({
</div>
);
}
// ── PlanningSessionList (sidebar) ──────────────────────────────────────────
interface PlanningSessionListProps {
sessions: AiSessionSummary[];
loading: boolean;
selectedSessionId: string | null;
pendingDeleteId: string | null;
onSelectSession: (id: string) => void;
onNewSession: () => void;
onRequestDelete: (id: string) => void;
onConfirmDelete: (id: string) => void;
onCancelDelete: () => void;
}
function PlanningSessionList({
sessions,
loading,
selectedSessionId,
pendingDeleteId,
onSelectSession,
onNewSession,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: PlanningSessionListProps) {
return (
<aside className="planning-sidebar" aria-label="Planning sessions">
<div className="planning-sidebar-header">
<button
className={`planning-sidebar-new ${selectedSessionId === null ? "active" : ""}`}
onClick={onNewSession}
type="button"
>
<MessageSquarePlus size={16} />
<span>New session</span>
</button>
</div>
<div className="planning-sidebar-list">
{sessions.length === 0 && !loading && (
<div className="planning-sidebar-empty text-muted">
No saved sessions yet. Start one on the right to see it here.
</div>
)}
{sessions.map((session) => {
const isSelected = session.id === selectedSessionId;
const isPendingDelete = pendingDeleteId === session.id;
return (
<div
key={session.id}
className={`planning-sidebar-item ${isSelected ? "selected" : ""} ${isPendingDelete ? "pending-delete" : ""}`}
>
<button
type="button"
className="planning-sidebar-item-button"
onClick={() => onSelectSession(session.id)}
>
<PlanningSessionStatusIcon status={session.status} />
<span className="planning-sidebar-item-body">
<span className="planning-sidebar-item-title">
{session.title || "Untitled session"}
</span>
<span className="planning-sidebar-item-meta">
<PlanningSessionStatusLabel status={session.status} />
<span aria-hidden> · </span>
<span>{formatRelativeTime(session.updatedAt)}</span>
</span>
</span>
</button>
{isPendingDelete ? (
<div className="planning-sidebar-confirm">
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => onConfirmDelete(session.id)}
>
Delete
</button>
<button
type="button"
className="btn btn-sm"
onClick={onCancelDelete}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="planning-sidebar-item-delete"
onClick={(e) => {
e.stopPropagation();
onRequestDelete(session.id);
}}
aria-label="Delete session"
title="Delete session"
>
<Trash2 size={14} />
</button>
)}
</div>
);
})}
</div>
</aside>
);
}
function PlanningSessionStatusIcon({ status }: { status: AiSessionSummary["status"] }) {
switch (status) {
case "generating":
return <Loader2 size={14} className="spin planning-sidebar-status-icon planning-sidebar-status-generating" />;
case "awaiting_input":
return <HelpCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-awaiting" />;
case "complete":
return <CheckCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-complete" />;
case "error":
return <AlertCircle size={14} className="planning-sidebar-status-icon planning-sidebar-status-error" />;
default:
return <Clock size={14} className="planning-sidebar-status-icon" />;
}
}
function PlanningSessionStatusLabel({ status }: { status: AiSessionSummary["status"] }) {
switch (status) {
case "generating":
return <span>Generating</span>;
case "awaiting_input":
return <span>Needs input</span>;
case "complete":
return <span>Complete</span>;
case "error":
return <span>Error</span>;
default:
return <span>{status}</span>;
}
}
function formatRelativeTime(iso: string): string {
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms) || ms < 0) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return "just now";
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const days = Math.floor(hr / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 4) return `${weeks}w ago`;
return new Date(iso).toLocaleDateString();
}

View File

@@ -103,17 +103,6 @@ function parseTimestampToMs(value?: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
function getInProgressTimeIndicatorStartMs(task: Task): number | null {
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
const parsed = parseTimestampToMs(timestamp);
if (parsed == null) return null;
const now = Date.now();
if (parsed > now) return null;
return parsed;
}
function getDoneCompletionMs(task: Task): number | null {
const completionMs = parseTimestampToMs(task.columnMovedAt ?? task.updatedAt);
if (completionMs == null) return null;
@@ -124,32 +113,40 @@ function getDoneCompletionMs(task: Task): number | null {
return completionMs;
}
function getDoneProcessingStartMs(task: Task, completionMs: number): number | null {
const startCandidates = [task.createdAt]
.map(parseTimestampToMs)
.filter((value): value is number => value != null);
const validStart = startCandidates.find((startMs) => startMs <= completionMs);
return validStart ?? null;
}
function getDoneWorkflowRuntimeMs(task: Task): number | null {
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
const results = task.workflowStepResults;
if (!results || results.length === 0) return null;
let total = 0;
let counted = 0;
for (const step of results) {
if (!step.startedAt || !step.completedAt) continue;
if (!step.startedAt) continue;
const startedMs = parseTimestampToMs(step.startedAt);
const completedMs = parseTimestampToMs(step.completedAt);
if (startedMs == null || completedMs == null || completedMs < startedMs) continue;
total += completedMs - startedMs;
if (startedMs == null) continue;
let endMs: number;
if (step.completedAt) {
const completedMs = parseTimestampToMs(step.completedAt);
if (completedMs == null || completedMs < startedMs) continue;
endMs = completedMs;
} else {
endMs = Math.max(startedMs, nowMs);
}
total += endMs - startedMs;
counted += 1;
}
return counted > 0 ? total : null;
}
function getInstrumentedDurationMs(task: Task, nowMs: number): number | null {
const timed = getTimedDurationMs(task.log);
const workflow = getWorkflowRuntimeMs(task, nowMs);
if (timed == null && workflow == null) return null;
return (timed ?? 0) + (workflow ?? 0);
}
function formatElapsedDuration(elapsedMs: number): string {
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
@@ -659,8 +656,10 @@ function TaskCardComponent({
return;
}
const startMs = getInProgressTimeIndicatorStartMs(task);
if (startMs == null) {
const hasInProgressStep = (task.workflowStepResults ?? []).some(
(step) => step.startedAt && !step.completedAt,
);
if (!hasInProgressStep) {
return;
}
@@ -670,98 +669,47 @@ function TaskCardComponent({
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt]);
}, [task.column, task.workflowStepResults]);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
return null;
}
if (task.column === "in-progress") {
const timedDurationMs = getTimedDurationMs(task.log);
if (timedDurationMs != null) {
const elapsedLabel = formatElapsedDuration(timedDurationMs);
if (elapsedLabel) {
return {
label: elapsedLabel,
title: `Timed duration ${elapsedLabel}`,
ariaLabel: `Timed duration ${elapsedLabel}`,
};
}
}
const startMs = getInProgressTimeIndicatorStartMs(task);
if (startMs == null) {
return null;
}
const elapsedLabel = formatElapsedDuration(timeIndicatorNowMs - startMs);
if (!elapsedLabel) {
return null;
}
return {
label: elapsedLabel,
title: `In progress since ${new Date(startMs).toLocaleString()}`,
ariaLabel: `Elapsed time ${elapsedLabel}. In progress since ${new Date(startMs).toLocaleString()}`,
};
}
// Done cards report the same "Timed duration" metric shown in the stats tab
// (sum of [timing]-tagged log events). Fall back to workflow step runtime,
// then to wallclock processing duration when no instrumentation exists.
const completionMs = getDoneCompletionMs(task);
if (completionMs == null) {
const instrumentedMs = getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (instrumentedMs == null) {
return null;
}
const timedDurationMs = getTimedDurationMs(task.log);
if (timedDurationMs != null) {
const elapsedLabel = formatElapsedDuration(timedDurationMs);
if (!elapsedLabel) {
return null;
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Timed duration ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Timed duration ${elapsedLabel}. Completed ${completedAt}`,
};
}
const workflowRuntimeMs = getDoneWorkflowRuntimeMs(task);
if (workflowRuntimeMs != null) {
const elapsedLabel = formatElapsedDuration(workflowRuntimeMs);
if (!elapsedLabel) {
return null;
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Workflow runtime ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Workflow runtime ${elapsedLabel}. Completed ${completedAt}`,
};
}
const startMs = getDoneProcessingStartMs(task, completionMs);
if (startMs == null) {
return null;
}
const elapsedLabel = formatElapsedDuration(completionMs - startMs);
const elapsedLabel = formatElapsedDuration(instrumentedMs);
if (!elapsedLabel) {
return null;
}
if (task.column === "in-progress") {
return {
label: elapsedLabel,
title: `Execution time ${elapsedLabel}`,
ariaLabel: `Execution time ${elapsedLabel}`,
};
}
const completionMs = getDoneCompletionMs(task);
if (completionMs == null) {
return {
label: elapsedLabel,
title: `Execution time ${elapsedLabel}`,
ariaLabel: `Execution time ${elapsedLabel}`,
};
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Processing took ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Completed processing duration ${elapsedLabel}. Completed ${completedAt}`,
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
};
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
}, [task.column, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {

View File

@@ -140,6 +140,10 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
<span className="task-token-stats-panel__label">Workflow runtime</span>
<span className="task-token-stats-panel__value">{formatDuration(workflowTiming.totalDurationMs)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">Total execution time</span>
<span className="task-token-stats-panel__value">{formatDuration(totalTimingDurationMs + workflowTiming.totalDurationMs)}</span>
</div>
</div>
<dl className="task-token-stats-panel__timestamps">

View File

@@ -460,7 +460,6 @@ describe("PlanningModeModal", () => {
expect(blockMatch).toBeTruthy();
const maxHeightValue = blockMatch![1].trim();
expect(maxHeightValue).toContain("min(");
expect(maxHeightValue).toContain("calc(");
expect(maxHeightValue).toContain("100dvh");
expect(maxHeightValue).toContain("--overlay-padding-top");
@@ -1765,7 +1764,7 @@ describe("PlanningModeModal", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("closes active question session and abandons server session", async () => {
it("closes active question session WITHOUT abandoning the server session", async () => {
render(
<PlanningModeModal
isOpen={true}
@@ -1787,12 +1786,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Closing the modal should leave the server session intact so it stays in the sidebar list
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes summary view and abandons server session", async () => {
it("closes summary view WITHOUT abandoning the server session", async () => {
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.(mockSummary);
@@ -1825,12 +1824,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Completed sessions remain available to resume; closing must not cancel them
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes via overlay and abandons server session", async () => {
it("closes via overlay WITHOUT abandoning the server session", async () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
@@ -1854,12 +1853,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(overlay!);
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Sessions persist in the sidebar; overlay click should not cancel
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("closes during loading state and abandons server session", async () => {
it("closes during loading state WITHOUT abandoning the server session", async () => {
mockConnectPlanningStream.mockImplementationOnce(() => ({
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
@@ -1886,8 +1885,8 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(mockConfirm).not.toHaveBeenCalled();
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// Loading state means the session is still being generated server-side; preserve it
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
@@ -1924,7 +1923,7 @@ describe("PlanningModeModal", () => {
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("disconnects SSE stream and abandons session on close", async () => {
it("disconnects the SSE stream on close (but keeps the server session)", async () => {
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce(() => ({
@@ -1953,8 +1952,9 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByLabelText("Close"));
expect(closeSpy).toHaveBeenCalledTimes(1);
// Closing an active session should abandon it on the server
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
// The local SSE stream closes on modal close, but the server session is preserved
// for later resume from the sidebar list.
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -455,17 +455,28 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
});
it("shows timer chip for in-progress cards when timestamp fields exist", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));
it("shows timer chip for in-progress cards summing workflow runtime + timed events", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "2026-04-25T12:18:00.000Z",
updatedAt: "2026-04-25T12:10:00.000Z",
createdAt: "2026-04-25T12:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:08:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T12:09:00.000Z",
action: "[timing] llm_call in 240000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
@@ -474,15 +485,12 @@ describe("TaskCard", () => {
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
// 8m workflow + 4m timed = 12m
expect(timer?.textContent).toContain("12m");
expect(timer?.getAttribute("title")).toContain("In progress since");
expect(timer?.getAttribute("aria-label")).toContain("Elapsed time 12m");
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
});
it("shows fixed processing-duration timer chip for done cards", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
it("shows timer chip for done cards summing workflow runtime + timed events", () => {
const { container } = render(
<TaskCard
task={makeTask({
@@ -490,6 +498,23 @@ describe("TaskCard", () => {
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T14:00:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T14:30:00.000Z",
action: "[timing] llm_call in 3600000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
@@ -498,15 +523,13 @@ describe("TaskCard", () => {
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
// 1h workflow + 1h timed = 2h
expect(timer?.textContent).toContain("2h");
expect(timer?.getAttribute("title")).toContain("Processing took 2h");
expect(timer?.getAttribute("aria-label")).toContain("Completed processing duration 2h");
expect(timer?.getAttribute("title")).toContain("Execution time 2h");
expect(timer?.getAttribute("title")).toContain("Completed");
});
it("renders files-changed metadata and timer chip in footer row", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
@@ -514,6 +537,16 @@ describe("TaskCard", () => {
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T15:00:00.000Z",
},
],
mergeDetails: {
commitSha: "abc123",
filesChanged: 4,
@@ -543,19 +576,24 @@ describe("TaskCard", () => {
expect(header?.contains(timer)).toBe(false);
expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged, timer]);
});
it.each(["triage", "todo", "in-review", "archived"] as const)(
"does not render timer chip for %s cards",
(column) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column,
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T14:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T15:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
@@ -566,17 +604,14 @@ describe("TaskCard", () => {
},
);
it("suppresses timer chip when all timestamp fallbacks are invalid or missing", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
it("does not render timer chip when no instrumentation data is recorded", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "not-a-date",
updatedAt: "also-not-a-date",
createdAt: undefined as unknown as string,
columnMovedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:00:00.000Z",
createdAt: "2026-04-25T11:58:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
@@ -586,77 +621,7 @@ describe("TaskCard", () => {
expect(container.querySelector(".card-time-indicator")).toBeNull();
});
it.each([
{
createdAt: "2026-04-25T09:00:00.000Z",
columnMovedAt: "2026-04-25T09:00:59.000Z",
expected: "<1m",
},
{
createdAt: "2026-04-25T09:00:00.000Z",
columnMovedAt: "2026-04-25T10:00:00.000Z",
expected: "1h",
},
{
createdAt: "2026-04-25T09:00:00.000Z",
columnMovedAt: "2026-04-26T09:00:00.000Z",
expected: "1d",
},
])(
"formats done processing-duration label as $expected at boundary",
({ createdAt, columnMovedAt, expected }) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt,
updatedAt: columnMovedAt,
createdAt,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain(expected);
},
);
it("keeps done processing-duration timer stable when clock advances", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2h");
act(() => {
vi.advanceTimersByTime(2 * 60 * 60_000);
});
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2h");
});
it("uses createdAt for done duration when updatedAt equals completion timestamp", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
it("does not render timer chip on done card without instrumentation, even with old timestamps", () => {
const { container } = render(
<TaskCard
task={makeTask({
@@ -670,14 +635,10 @@ describe("TaskCard", () => {
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("2h");
expect(timer?.textContent).not.toContain("<1m");
expect(timer?.getAttribute("title")).toContain("Processing took 2h");
expect(container.querySelector(".card-time-indicator")).toBeNull();
});
it("refreshes in-progress timer chip on 30s cadence", () => {
it("live-ticks workflow runtime for in-progress steps", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:00:30.000Z"));
@@ -685,9 +646,15 @@ describe("TaskCard", () => {
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T11:59:00.000Z",
createdAt: "2026-04-25T11:58:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "pending" as const,
startedAt: "2026-04-25T12:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}

View File

@@ -116,7 +116,7 @@ describe("mobile CSS foundation", () => {
const css = loadAllAppCss();
const matches = [...css.matchAll(/@media\s*\(max-width:\s*(\d+)px\)/g)];
const foundValues = new Set(matches.map((match) => Number(match[1])));
const allowedValues = new Set([480, 640, 768, 860]);
const allowedValues = new Set([480, 640, 720, 768, 860]);
expect(foundValues.size).toBeGreaterThan(0);
for (const value of foundValues) {