diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8a5e11e1afafb1ae9cb196d8dbdb0439ed0b344e..d0216d0e40c5b4b8bfae3ae64b999987a48616d5 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,8 +20,10 @@ describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( hasUnseenCompletion({ + interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", + proposedPlans: [], session: null, }), ).toBe(true); @@ -30,8 +32,10 @@ describe("hasUnseenCompletion", () => { describe("resolveThreadStatusPill", () => { const baseThread = { + interactionMode: "plan" as const, latestTurn: null, lastVisitedAt: undefined, + proposedPlans: [], session: { provider: "codex" as const, status: "running" as const, @@ -71,11 +75,39 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Working", pulse: true }); }); + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + latestTurn: makeLatestTurn(), + proposedPlans: [ + { + id: "plan-1" as never, + turnId: "turn-1" as never, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + planMarkdown: "# Plan", + }, + ], + session: { + ...baseThread.session, + status: "ready", + orchestrationStatus: "ready", + }, + }, + hasPendingApprovals: false, + hasPendingUserInput: false, + }), + ).toMatchObject({ label: "Plan Ready", pulse: false }); + }); + it("shows completed when there is an unseen completion and no active blocker", () => { expect( resolveThreadStatusPill({ thread: { ...baseThread, + interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", session: { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 44626cf14738dae2707d611749810768cb925d28..e950d8de6e06de3fdb0beab3e176b5f4cd89fd20 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,13 +1,23 @@ import type { Thread } from "../types"; +import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input"; + label: + | "Working" + | "Connecting" + | "Completed" + | "Pending Approval" + | "Awaiting Input" + | "Plan Ready"; colorClass: string; dotClass: string; pulse: boolean; } -type ThreadStatusInput = Pick; +type ThreadStatusInput = Pick< + Thread, + "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" +>; export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; @@ -63,6 +73,20 @@ export function resolveThreadStatusPill(input: { }; } + const hasPlanReadyPrompt = + !hasPendingUserInput && + thread.interactionMode === "plan" && + isLatestTurnSettled(thread.latestTurn, thread.session) && + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null; + if (hasPlanReadyPrompt) { + return { + label: "Plan Ready", + colorClass: "text-violet-600 dark:text-violet-300/90", + dotClass: "bg-violet-500 dark:bg-violet-300/90", + pulse: false, + }; + } + if (hasUnseenCompletion(thread)) { return { label: "Completed",