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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user