From 5eef4b7e83597f4e70f44cc6b933730c84d6a292 Mon Sep 17 00:00:00 2001 From: Fotis Stamatelopoulos Date: Mon, 18 May 2026 18:52:51 -0700 Subject: [PATCH 1/2] feat(status): "ready/iterating" label + flip on standalone SA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-loop projects (PA + SA driving the workspace through several sequential milestone loops) had a misleading "COMPLETED" badge between loops — the project wasn't done, it was iterating. Fix: one new explicit transition + one label rename. Transition: {completed, failed, stopped} + cfcf review (standalone) -> idle. Triggered at the top of architect-runner's startReview() path, BEFORE the SA spawn. Narrow trigger by design — only SA (scope work). Not reflect (retrospection), document (finalizing), or spec (PA, independent surface with its own chip from v0.24.5). Paused stays paused — preserves resume mechanics + refine_plan action. In-loop architect (runReviewSync) doesn't flip — it transitions through "running" already. failed and stopped stay distinct internally for audit-trail preservation. Label rename: idle -> "ready/iterating" in StatusBadge.tsx (web) and a new formatStatus() helper in cfcf status CLI. The slashed label covers both intents (fresh workspace = "ready", post-terminal + SA run = "iterating") without derive-from- history complexity. Internal value stays "idle" — zero state-machine churn. Implementation: TERMINAL_LOOP_STATUSES constant + flipTerminalStatusToIdle(workspace) helper (returns bool for testability + best-effort error handling). startReview() calls it before ensureWorkspaceLogDir. Test coverage: 8 new tests covering all status transitions (completed/failed/stopped flip; paused/running/idle don't flip; undefined status defensive case; TERMINAL_LOOP_STATUSES set exhaustiveness). All 1079 tests pass; typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 96 +++++++++++++++ packages/cli/src/commands/status.ts | 17 ++- packages/core/src/architect-runner.test.ts | 122 +++++++++++++++++++- packages/core/src/architect-runner.ts | 67 +++++++++++ packages/web/src/components/StatusBadge.tsx | 7 ++ 5 files changed, 306 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55dc1e7..ec099ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,102 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here. ## [Unreleased] +### Changed — Workspace status: `idle` → `"ready/iterating"`; new transition on standalone SA + +Multi-loop projects (PA + SA drive the workspace through several +sequential loops, each completing a milestone) hit a labelling +problem: the dashboard badge read `"completed"` after every loop +ended, even though the project was still actively being iterated. +*"COMPLETED"* carries finality that's misleading when the user is +about to start the next loop. + +**Fix**: one new explicit transition + one label rename. + +**1. New transition** — `{completed, failed, stopped} + +cfcf review (standalone)` → `idle`. + +When the user invokes `cfcf review` (standalone SA) on a workspace +whose loop has terminated, `workspace.status` flips back to `idle` +before SA spawns. The trigger is deliberately narrow: + + - **SA is scope work** — running it after a completed loop + signals "we're preparing for the next loop." Matches the + iterating semantics exactly. + - `cfcf reflect` doesn't flip (retrospection — no scope change). + - `cfcf document` doesn't flip (finalizing — not iterating + forward). + - `cfcf spec` (PA) doesn't flip (independent surface; already + has its own chip on the workspace card from v0.24.5's PR #51). + - `cfcf run` continues to flip directly to `running` (the loop + engine's existing transition, unchanged). + +**Paused is deliberately excluded** from the terminal-status set: +a paused loop can still be resumed (via `cfcf resume` or its +`refine_plan` action which runs SA in-loop). Flipping +paused → idle would lose the pause state and break the resume +mechanics. Standalone SA from a paused workspace leaves the pause +intact — the user can finish SA, then resume the loop with the +updated plan. + +**The in-loop architect path doesn't flip either**: pre-loop +review (`autoReviewSpecs=true`) and the `refine_plan` resume +action both call `runReviewSync` from inside the loop engine — +those paths transition through `workspace.status = "running"` +already and don't need (or want) the flip. + +**Failed and stopped stay distinct internally**: the audit trail +benefits from separating harness crash (`failed`) from user +intervention (`stopped`). The StatusBadge can render them with +shared muted color if desired in a future pass; the internal +value preservation enables downstream queries to distinguish the +two outcomes. + +**2. Label rename** — `idle` → `"ready/iterating"`. + +The internal value stays `idle` (zero state-machine churn). The +display label is renamed: + + - Web dashboard (`StatusBadge.tsx`): adds an entry to the + label map mapping `idle` → `"ready/iterating"`. + - CLI status command (`cfcf status` + `cfcf status --workspace + `): same rename via a small `formatStatus()` helper. + +The slashed label captures both intents in one string: + - **"ready"** — fresh workspace, hasn't run yet (the original + `idle` meaning). + - **"iterating"** — post-terminal, user has resumed work by + running standalone SA. Loop isn't running RIGHT NOW, but the + workspace is alive again. + +No derivation from history is needed — the label is the same +regardless of prior loop count. If dogfood shows the slashed +label feels ambiguous, future work can derive `"ready"` vs +`"iterating"` from `workspace.currentIteration > 0`. The simple +form is shipping first. + +**Implementation** (~80 LoC + tests): + +- `packages/core/src/architect-runner.ts`: + - New `TERMINAL_LOOP_STATUSES = Set(["completed", "failed", + "stopped"])` constant (exported for test exhaustiveness). + - New `flipTerminalStatusToIdle(workspace)` helper — returns + `true` if flipped, `false` if not. Best-effort: update + failures are logged but never fail the SA run. + - `startReview()` calls the helper at the top of the standalone + review path (before any review side effects). +- `packages/web/src/components/StatusBadge.tsx`: adds `idle: + "ready/iterating"` to the label map. +- `packages/cli/src/commands/status.ts`: adds `formatStatus()` + helper for the same translation; called in the list view + + detailed-workspace-status view. + +**Test coverage** (8 new tests in `architect-runner.test.ts`, +all 1079 total pass): `TERMINAL_LOOP_STATUSES` contains exactly +the three values; flips from completed; flips from failed; flips +from stopped; does NOT flip paused (preserves resume mechanics); +does NOT flip running; does NOT flip idle (no-op); handles +undefined status defensively (older workspaces without the field). + ### Changed — History tab: separate section for interactive agents (PA + HA) Dogfood feedback from the gmbot run: PA sessions can stay alive diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index b6eae39..f9a0e60 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -86,12 +86,25 @@ async function showWorkspaceOverview(): Promise { console.log("Workspaces:"); for (const w of res.data) { - const status = w.status ?? "idle"; + const status = formatStatus(w.status); const iter = w.currentIteration > 0 ? ` (iteration ${w.currentIteration})` : ""; console.log(` ${w.name}: ${status}${iter}`); } } +/** + * Display label for a workspace status. v0.24.5: `"idle"` is + * rendered as `"ready/iterating"` to capture both the fresh- + * workspace case ("ready") and the post-terminal case ("iterating" + * between loops, after running standalone SA). Mirrors the + * web's StatusBadge label map. Keep these in sync. + */ +function formatStatus(status?: string): string { + const s = status ?? "idle"; + if (s === "idle") return "ready/iterating"; + return s; +} + async function showWorkspaceStatus(workspace: string): Promise { const loopRes = await get( `/api/workspaces/${encodeURIComponent(workspace)}/loop/status`, @@ -108,7 +121,7 @@ async function showWorkspaceStatus(workspace: string): Promise { } const w = wsRes.data!; console.log(`Workspace: ${w.name}`); - console.log(`Status: ${w.status ?? "idle"}`); + console.log(`Status: ${formatStatus(w.status)}`); console.log(`Iterations: ${w.currentIteration}`); console.log(); console.log("No active loop. Start with: cfcf run --workspace " + w.name); diff --git a/packages/core/src/architect-runner.test.ts b/packages/core/src/architect-runner.test.ts index 0ad1b65..28aa9ce 100644 --- a/packages/core/src/architect-runner.test.ts +++ b/packages/core/src/architect-runner.test.ts @@ -5,14 +5,19 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { join } from "path"; import { mkdir, writeFile, rm } from "fs/promises"; +import { mkdtemp, rm as rmTmp, mkdir as mkdirTmp } from "fs/promises"; +import { tmpdir } from "os"; import { writeArchitectInstructions, resetArchitectSignals, parseArchitectSignals, countPlanItems, diagnoseFailedArchitectSignals, + flipTerminalStatusToIdle, + TERMINAL_LOOP_STATUSES, } from "./architect-runner.js"; -import type { WorkspaceConfig, ArchitectSignals } from "./types.js"; +import { createWorkspace, getWorkspace, updateWorkspace } from "./workspaces.js"; +import type { WorkspaceConfig, ArchitectSignals, WorkspaceStatus } from "./types.js"; const TEST_DIR = join(import.meta.dir, "..", ".test-architect-runner"); @@ -456,3 +461,118 @@ And [bracketed text] without checkboxes isn't either. expect(countPlanItems("# Plan\n\nNo checkboxes here.\n")).toEqual({ pending: 0, completed: 0 }); }); }); + +// ── flipTerminalStatusToIdle (v0.24.5) ─────────────────────────────────── +// +// Tests the explicit-trigger transition for the +// "ready/iterating" status. When a user runs `cfcf review` on a +// workspace whose loop has already terminated (completed / failed +// / stopped), the workspace.status flips back to `idle` so the +// dashboard badge accurately reflects "we're preparing new scope." +// +// Tests use a real tmpdir-backed CFCF_CONFIG_DIR + createWorkspace +// because the flip uses the real updateWorkspace path. Cheap +// enough (~ms per test) for the fidelity gained. + +describe("flipTerminalStatusToIdle (v0.24.5 status-iterating transition)", () => { + let configDir: string; + let repoDir: string; + const originalConfigDir = process.env.CFCF_CONFIG_DIR; + + beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), "cfcf-flip-test-")); + process.env.CFCF_CONFIG_DIR = configDir; + repoDir = join(configDir, "fake-repo"); + await mkdirTmp(join(repoDir, ".git"), { recursive: true }); + }); + + afterEach(async () => { + process.env.CFCF_CONFIG_DIR = originalConfigDir; + await rmTmp(configDir, { recursive: true, force: true }); + }); + + test("TERMINAL_LOOP_STATUSES contains exactly completed, failed, stopped", () => { + // Lock the set so future edits can't silently expand it. Each + // entry is a deliberate inclusion — see the helper's docstring. + expect([...TERMINAL_LOOP_STATUSES].sort()).toEqual(["completed", "failed", "stopped"]); + }); + + test("flips from 'completed' to 'idle' (the user-reported gmbot case)", async () => { + const ws = await createWorkspace({ name: "gmbot-test", repoPath: repoDir }); + await updateWorkspace(ws.id, { status: "completed" }); + const refreshed = await getWorkspace(ws.id); + expect(refreshed?.status).toBe("completed"); + + const flipped = await flipTerminalStatusToIdle(refreshed!); + expect(flipped).toBe(true); + + const after = await getWorkspace(ws.id); + expect(after?.status).toBe("idle"); + }); + + test("flips from 'failed' to 'idle'", async () => { + const ws = await createWorkspace({ name: "failed-test", repoPath: repoDir }); + await updateWorkspace(ws.id, { status: "failed" }); + const refreshed = await getWorkspace(ws.id); + + const flipped = await flipTerminalStatusToIdle(refreshed!); + expect(flipped).toBe(true); + expect((await getWorkspace(ws.id))?.status).toBe("idle"); + }); + + test("flips from 'stopped' to 'idle'", async () => { + const ws = await createWorkspace({ name: "stopped-test", repoPath: repoDir }); + await updateWorkspace(ws.id, { status: "stopped" }); + const refreshed = await getWorkspace(ws.id); + + const flipped = await flipTerminalStatusToIdle(refreshed!); + expect(flipped).toBe(true); + expect((await getWorkspace(ws.id))?.status).toBe("idle"); + }); + + test("does NOT flip 'paused' (paused stays paused — preserves resume mechanics)", async () => { + // The load-bearing non-flip case: a paused loop awaiting user + // input must NOT be reset by a standalone `cfcf review` — + // otherwise the resume mechanics + `refine_plan` action break. + // The user wants SA output WHILE the loop stays pause-resumable. + const ws = await createWorkspace({ name: "paused-test", repoPath: repoDir }); + await updateWorkspace(ws.id, { status: "paused" }); + const refreshed = await getWorkspace(ws.id); + + const flipped = await flipTerminalStatusToIdle(refreshed!); + expect(flipped).toBe(false); + expect((await getWorkspace(ws.id))?.status).toBe("paused"); + }); + + test("does NOT flip 'running' (no-op on already-running loop)", async () => { + const ws = await createWorkspace({ name: "running-test", repoPath: repoDir }); + await updateWorkspace(ws.id, { status: "running" }); + const refreshed = await getWorkspace(ws.id); + + const flipped = await flipTerminalStatusToIdle(refreshed!); + expect(flipped).toBe(false); + expect((await getWorkspace(ws.id))?.status).toBe("running"); + }); + + test("does NOT flip 'idle' (already idle — no-op, safe to call unconditionally)", async () => { + const ws = await createWorkspace({ name: "idle-test", repoPath: repoDir }); + // workspaces default to idle on creation — no explicit update needed. + const refreshed = await getWorkspace(ws.id); + expect(refreshed?.status).toBe("idle"); + + const flipped = await flipTerminalStatusToIdle(refreshed!); + expect(flipped).toBe(false); + expect((await getWorkspace(ws.id))?.status).toBe("idle"); + }); + + test("handles workspace with undefined status (defensive — older workspaces)", async () => { + // Defensive: a workspace persisted without `status` (very old + // workspaces, or a corrupted config). Should not flip — undefined + // isn't a terminal status. No throw. + const ws = await createWorkspace({ name: "undef-test", repoPath: repoDir }); + const wsWithoutStatus = { ...ws, status: undefined } as WorkspaceConfig & { status?: WorkspaceStatus }; + + const flipped = await flipTerminalStatusToIdle(wsWithoutStatus); + expect(flipped).toBe(false); + }); +}); diff --git a/packages/core/src/architect-runner.ts b/packages/core/src/architect-runner.ts index 0c64d9b..0137279 100644 --- a/packages/core/src/architect-runner.ts +++ b/packages/core/src/architect-runner.ts @@ -18,6 +18,7 @@ import { registerProcess } from "./active-processes.js"; import { dispatchForWorkspace, makeEvent } from "./notifications/index.js"; import { getAgentRunLogPath, nextAgentRunSequence, ensureWorkspaceLogDir } from "./log-storage.js"; import { appendHistoryEvent, updateHistoryEvent } from "./workspace-history.js"; +import { updateWorkspace } from "./workspaces.js"; import { persistAgentState, loadAgentState } from "./agent-state-store.js"; import { randomBytes } from "crypto"; import { readProblemPack, validateProblemPack } from "./problem-pack.js"; @@ -357,6 +358,68 @@ export async function parseArchitectSignals( } } +/** + * Status values that indicate the loop has terminated — running a + * standalone SA from any of these signals "the user is preparing + * new scope" and the workspace re-enters the iterating state. See + * `flipTerminalStatusToIdle` below. + * + * Exported for `flipTerminalStatusToIdle` test exhaustiveness. + */ +export const TERMINAL_LOOP_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "stopped", +]); + +/** + * When a standalone `cfcf review` is invoked on a workspace whose + * loop has already terminated, flip `workspace.status` back to + * `"idle"` so the dashboard badge ("ready/iterating") reflects the + * user's resumed work on the spec. v0.24.5. + * + * Trigger is deliberately narrow: only the standalone SA path. The + * in-loop architect spawn (`runReviewSync` from `iteration-loop.ts` + * for `pre_loop_reviewing` / `refine_plan`) doesn't call this — + * those paths transition through `workspace.status = "running"` + * via the loop engine and don't need the flip. + * + * Why SA specifically (not reflect / document / spec): + * - SA is **scope work** — running it after a completed loop + * signals "we're preparing for the next loop." Matches the + * user's "iterating" framing exactly. + * - reflect = retrospection (no scope change). + * - document = finalizing (not iterating forward). + * - spec/PA = independent surface; already shown via the PA + * chip on the workspace card. + * + * Why `paused` is NOT in the set: a paused loop can still be + * resumed (via `cfcf resume` or its `refine_plan` action which + * already runs SA in-loop). Flipping paused → idle would lose the + * pause state and break the resume mechanics. + * + * Best-effort: a workspace-update failure is logged but doesn't + * fail the review run. The flip is a UX nicety, not a correctness + * requirement. + * + * Returns `true` if a flip happened, `false` otherwise. Surfaces + * the outcome for testability + callers that want to observe. + */ +export async function flipTerminalStatusToIdle( + workspace: WorkspaceConfig, +): Promise { + if (!TERMINAL_LOOP_STATUSES.has(workspace.status ?? "")) return false; + try { + await updateWorkspace(workspace.id, { status: "idle" }); + return true; + } catch (err) { + console.warn( + `[architect-runner] flipTerminalStatusToIdle failed for ${workspace.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } +} + /** * Start an architect review for a workspace. * Runs asynchronously -- returns the initial state immediately. @@ -365,6 +428,10 @@ export async function startReview( workspace: WorkspaceConfig, opts?: { problemPackPath?: string }, ): Promise { + // v0.24.5: terminal-status reset. Runs BEFORE log-dir setup so + // the workspace state is consistent with "we're about to iterate" + // before any review side effects. See helper for the full rationale. + await flipTerminalStatusToIdle(workspace); await ensureWorkspaceLogDir(workspace.id); const sequence = await nextAgentRunSequence(workspace.id, "architect"); const logFile = getAgentRunLogPath(workspace.id, "architect", sequence); diff --git a/packages/web/src/components/StatusBadge.tsx b/packages/web/src/components/StatusBadge.tsx index 33ab1b8..fe258e0 100644 --- a/packages/web/src/components/StatusBadge.tsx +++ b/packages/web/src/components/StatusBadge.tsx @@ -17,6 +17,13 @@ const statusColors: Record = { }; const statusLabels: Record = { + // v0.24.5: "idle" covers both the fresh-workspace case ("ready" + // to run for first time) AND the post-terminal case ("iterating" + // between loops after running standalone SA). Single label + // captures both intents without extra derive-from-history logic. + // See architect-runner.ts:flipTerminalStatusToIdle for the + // explicit-trigger transition. + idle: "ready/iterating", dev_executing: "dev running", documenting: "documenting", user_input_needed: "needs input", From a7d353dd5671a374d2efc218a7bd43377f4f35bb Mon Sep 17 00:00:00 2001 From: Fotis Stamatelopoulos Date: Mon, 18 May 2026 19:11:11 -0700 Subject: [PATCH 2/2] ux(dashboard): history counts, top-bar iteration, card timer + layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small dogfood-driven UX polish items bundled together: 1. History tab section headers: split count into "(active: N | total: M)" instead of just "(N)". Surfaces "is something running RIGHT NOW" without scanning rows. 2. Status tab PhaseIndicator subtitle: "Iteration 24 (max: 28)" instead of just "Iteration 24". Ceiling visible next to the live timer without a click into Config. 3. Status tab Loop State block: trimmed. Iteration/max now in PhaseIndicator above; pause-every is in Config tab. Block replaced with conditional "Stall warning" that only renders when consecutiveStalled > 0. 4. Workspace card three changes: - Live elapsed timer for running loop. New workspace.loopStartedAt field (server-enriched from loopState.startedAt when activeAgent === "loop"), rendered inside the loop chip via useElapsed hook. - Chips moved BELOW title+badge row (was crowded when loop + PA both active). Two-row layout, conditional rendering — no chips = no row. - Agents row adds Reflect (per-workspace). PA intentionally NOT added (global config = same on every card). Architect + Documenter omitted to keep row scannable. Server enrichment: /api/workspaces gets loopStartedAt populated from loopState.startedAt when active. One getLoopState() call per running workspace, in-memory cached. No new tests — pure presentation. All 1079 tests still pass. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 67 +++++++++++++++++++ packages/server/src/app.ts | 25 +++++-- packages/web/src/components/WorkspaceCard.tsx | 50 +++++++++++++- .../web/src/components/WorkspaceHistory.tsx | 13 +++- packages/web/src/pages/WorkspaceDetail.tsx | 26 +++---- packages/web/src/types.ts | 8 +++ 6 files changed, 168 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec099ab..7e042a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,73 @@ from stopped; does NOT flip paused (preserves resume mechanics); does NOT flip running; does NOT flip idle (no-op); handles undefined status defensively (older workspaces without the field). +### Changed — UX polish: history counts, top-bar iteration, card timer + layout + +Four small dogfood-driven refinements bundled together — none big +enough for their own entry, all in the same surface. + +**1. History tab section headers**: counts split into +`(active: N | total: M)` instead of just `(N)`. Surfaces "is +something running RIGHT NOW in this section" without scanning +the rows. For the interactive section, `active` = PA sessions +with `status === "running"`; for the loop section, same applies +to iteration / review / document / reflection events. + +**2. Status tab top-bar**: PhaseIndicator's iteration subtitle +now shows `"Iteration 24 (max: 28)"` instead of just +`"Iteration 24"`. The `(max: N)` lives next to the live elapsed +timer in one place (between buttons and tabs), making the +ceiling visible without a click into the Config tab. + +**3. Status tab Loop State block removed**: the standalone "Loop +State" block in the Status panel had three pieces — iteration +count / max (now in PhaseIndicator above), pause every (already +in Config tab), consecutive stalled (real warning signal). The +block is replaced with a compact "Stall warning" section that +only renders when `consecutiveStalled > 0` — common case +renders nothing, abnormal case stays prominent. + +**4. Workspace card**: three changes. + - **Live elapsed timer** when loop is running. Pulled from + new `workspace.loopStartedAt` field (server-enriched via + `getLoopState` when `activeAgent === "loop"`). Renders as + `"● loop running · 47m 12s"` inside the chip. Mirrors the + workspace-detail PhaseIndicator's timer so the dashboard + answers "how long has this loop been alive?" without + click-through. Updates every 1s via the existing + `useElapsed` hook. + - **Chip layout**: chips moved BELOW the title+badge row + instead of crowding the same line. With loop + PA chips + both possible, the original single-row header was tight; + two-row layout gives each chip room. The chip row is + conditionally rendered — when there are no chips, the card + looks exactly like before. + - **Agents row**: added Reflect alongside Dev + Judge. + Reflect is per-workspace and was previously only visible in + the Config tab. PA is intentionally NOT added — it's a + global config, would be identical on every card. Architect + and Documenter omitted to keep the row scannable; can be + added later if dogfood demands. + +**Server enrichment** for the card timer: +`/api/workspaces` response gets `loopStartedAt?: string | null` +populated from `loopState.startedAt` when `activeAgent === "loop"`. +One `getLoopState()` call per running workspace; cached +in-memory after first read. + +**Implementation** (~95 LoC): +- `packages/web/src/components/WorkspaceHistory.tsx` — active + count derivation + header format +- `packages/web/src/pages/WorkspaceDetail.tsx` — PhaseIndicator + title format + Loop State block trim +- `packages/server/src/app.ts` — `loopStartedAt` enrichment +- `packages/web/src/types.ts` — mirror the field +- `packages/web/src/components/WorkspaceCard.tsx` — timer, chip + row, agents row + +No new tests — pure presentation tweaks. Existing 1079 tests +still pass; typecheck clean. + ### Changed — History tab: separate section for interactive agents (PA + HA) Dogfood feedback from the gmbot run: PA sessions can stay alive diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index a73ac82..18b2eb1 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -378,16 +378,31 @@ export function createApp() { // "PA active" chip. PA can run concurrently with the loop / // standalone runs, so it's a parallel field — not a new // `activeAgent` enum value. Two chips can coexist on the card. + // v0.24.5 follow-up: also include `loopStartedAt` so the card + // can render a live elapsed-time counter when the loop is + // running. Pulled from loopState.startedAt; null when no + // active loop. Single getLoopState read per loop-running + // workspace (cheap — cached in-memory after first access). const ids = workspaces.map((w) => w.id); const [activeAgents, paSessions] = await Promise.all([ getActiveAgentsForWorkspaces(ids), getPaSessionsForWorkspaces(ids), ]); - const enriched = workspaces.map((w) => ({ - ...w, - activeAgent: activeAgents[w.id] ?? null, - paSession: paSessions[w.id] ?? null, - })); + const enriched = await Promise.all( + workspaces.map(async (w) => { + let loopStartedAt: string | null = null; + if (activeAgents[w.id] === "loop") { + const ls = await getLoopState(w.id); + loopStartedAt = ls?.startedAt ?? null; + } + return { + ...w, + activeAgent: activeAgents[w.id] ?? null, + paSession: paSessions[w.id] ?? null, + loopStartedAt, + }; + }), + ); return c.json(enriched); }); diff --git a/packages/web/src/components/WorkspaceCard.tsx b/packages/web/src/components/WorkspaceCard.tsx index 7ff42c9..4f979c2 100644 --- a/packages/web/src/components/WorkspaceCard.tsx +++ b/packages/web/src/components/WorkspaceCard.tsx @@ -1,6 +1,7 @@ import type { WorkspaceConfig } from "../types"; import { StatusBadge } from "./StatusBadge"; import { navigateTo } from "../hooks/useRoute"; +import { useElapsed } from "../hooks/useElapsed"; function formatAgent(agent?: { adapter: string; model?: string }): string { if (!agent?.adapter) return "n/a"; @@ -33,21 +34,57 @@ export function WorkspaceCard({ workspace }: { workspace: WorkspaceConfig }) { const paTooltip = pa ? `PA session ${pa.sessionId} alive since ${new Date(pa.startedAt).toLocaleString()} (launcher PID ${pa.launcherPid})` : ""; + + // v0.24.5 follow-up: live elapsed timer for the running loop. + // Mirrors the workspace-detail PhaseIndicator's timer so the + // dashboard answers "how long has this loop been alive?" without + // a click-through. Returns null when not running — the timer span + // is conditionally rendered. + const loopElapsed = useElapsed( + workspace.loopStartedAt ?? undefined, + workspace.activeAgent === "loop", + ); + return (
navigateTo(`/workspaces/${workspace.id}`)} > + {/* v0.24.5 layout change: chips moved BELOW the title+badge + row. With two possible chips (loop + PA) coexisting, the + original single-row header got crowded. Two-row layout + gives each chip room to breathe. */}

{workspace.name}

+
+
+ {(activeLabel || pa) && ( +
{activeLabel && ( ● {activeLabel} + {workspace.activeAgent === "loop" && loopElapsed && ( + + · {loopElapsed} + + )} )} {pa && ( @@ -66,7 +103,7 @@ export function WorkspaceCard({ workspace }: { workspace: WorkspaceConfig }) { )}
-
+ )}
{workspace.repoPath} @@ -75,9 +112,18 @@ export function WorkspaceCard({ workspace }: { workspace: WorkspaceConfig }) { Iteration {workspace.currentIteration || 0} / {workspace.maxIterations}
+ {/* v0.24.5: agents row extended with Reflect (per-workspace, + previously only visible deep in Config tab). PA is NOT + shown here because it's a global config — would be + identical on every card. Architect + Documenter omitted + for now to keep the row scannable; can be added if + dogfood shows they're useful at a glance. */}
Dev: {formatAgent(workspace.devAgent)} Judge: {formatAgent(workspace.judgeAgent)} + {workspace.reflectionAgent && ( + Reflect: {formatAgent(workspace.reflectionAgent)} + )}
); diff --git a/packages/web/src/components/WorkspaceHistory.tsx b/packages/web/src/components/WorkspaceHistory.tsx index 12d51d6..d4d626e 100644 --- a/packages/web/src/components/WorkspaceHistory.tsx +++ b/packages/web/src/components/WorkspaceHistory.tsx @@ -94,6 +94,15 @@ export function WorkspaceHistory({ // `utils/history-partition.ts` for the full rationale. const { interactive, loop } = partitionInteractiveEvents(events); + // Per-section active count for the header (e.g. + // "Interactive sessions (active: 1 | total: 3)"). "Active" = + // events still claiming `running` status. For the interactive + // section this surfaces "PA still alive right now" at a + // glance — paired with the total so the user knows how much + // history is below. + const interactiveActive = interactive.filter((e) => e.status === "running").length; + const loopActive = loop.filter((e) => e.status === "running").length; + // Shared summary style: visual hierarchy + disclosure-triangle // affordance via the native
/ element (no // React state needed — browser handles open/close + persisted @@ -124,7 +133,7 @@ export function WorkspaceHistory({ }} > - Interactive sessions ({interactive.length}) + Interactive sessions (active: {interactiveActive} | total: {interactive.length}) @@ -167,7 +176,7 @@ export function WorkspaceHistory({ }} > - Loop history ({loop.length}) + Loop history (active: {loopActive} | total: {loop.length}) 0 - ? `Iteration ${loopState.currentIteration}` + ? `Iteration ${loopState.currentIteration} (max: ${loopState.maxIterations})` : loopState.phase === "pre_loop_reviewing" ? "Pre-loop review" : undefined @@ -443,19 +443,21 @@ export function WorkspaceDetail({ workspaceId }: { workspaceId: string }) { )} - {loopState && ( + {/* The full "Loop State" block was removed in v0.24.5 + (UX dogfood): iteration count + max moved into the + PhaseIndicator subtitle above, where it lives next + to the live elapsed timer. "Pause every" is already + in the Config tab. The block here remains ONLY for + the consecutive-stalled warning — that's an active + signal the user should see prominently when it + triggers. Renders nothing in the common case. */} + {loopState && loopState.consecutiveStalled > 0 && (
-

Loop State

+

Stall warning

- Iterations: {loopState.currentIteration} / {loopState.maxIterations} - {loopState.pauseEvery > 0 && ( - Pause every: {loopState.pauseEvery} - )} - {loopState.consecutiveStalled > 0 && ( - - Consecutive stalled: {loopState.consecutiveStalled} - - )} + + Consecutive stalled iterations: {loopState.consecutiveStalled} +
)} diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 5a6648a..0d08112 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -69,6 +69,14 @@ export interface WorkspaceConfig { launcherPid: number; eventId: string; } | null; + /** + * Loop's overall `startedAt` (loopState.startedAt) when the + * loop is actively running, otherwise null. Lets the workspace + * card render a live elapsed-time counter for the running loop + * (v0.24.5). Server populates this only when `activeAgent === + * "loop"`; absent / null for any other state. + */ + loopStartedAt?: string | null; } // Keep in sync with packages/core/src/iteration-loop.ts