| author | |
| committer | |
| log | b946f00f1a31f16a51f9bd1f579baf40c8eaed83 |
| tree | 9d6c8471c3f3c8c6f0120da673b785dfdd493e95 |
| parent | b030f69a833802fae9222781ef2dc822dfe81aac |
- add "Plan Ready" sidebar status when a plan-mode turn is settled with a proposed plan
- update sidebar logic tests to cover the new status and expanded thread inputs2 files changed, 58 insertions(+), 2 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+32| ... | ... | @@ -20,8 +20,10 @@ describe("hasUnseenCompletion", () => { |
| 20 | 20 | it("returns true when a thread completed after its last visit", () => { |
| 21 | 21 | expect( |
| 22 | 22 | hasUnseenCompletion({ |
| 23 | interactionMode: "default", | |
| 23 | 24 | latestTurn: makeLatestTurn(), |
| 24 | 25 | lastVisitedAt: "2026-03-09T10:04:00.000Z", |
| 26 | proposedPlans: [], | |
| 25 | 27 | session: null, |
| 26 | 28 | }), |
| 27 | 29 | ).toBe(true); |
| ... | ... | @@ -30,8 +32,10 @@ describe("hasUnseenCompletion", () => { |
| 30 | 32 | |
| 31 | 33 | describe("resolveThreadStatusPill", () => { |
| 32 | 34 | const baseThread = { |
| 35 | interactionMode: "plan" as const, | |
| 33 | 36 | latestTurn: null, |
| 34 | 37 | lastVisitedAt: undefined, |
| 38 | proposedPlans: [], | |
| 35 | 39 | session: { |
| 36 | 40 | provider: "codex" as const, |
| 37 | 41 | status: "running" as const, |
| ... | ... | @@ -71,11 +75,39 @@ describe("resolveThreadStatusPill", () => { |
| 71 | 75 | ).toMatchObject({ label: "Working", pulse: true }); |
| 72 | 76 | }); |
| 73 | 77 | |
| 78 | it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { | |
| 79 | expect( | |
| 80 | resolveThreadStatusPill({ | |
| 81 | thread: { | |
| 82 | ...baseThread, | |
| 83 | latestTurn: makeLatestTurn(), | |
| 84 | proposedPlans: [ | |
| 85 | { | |
| 86 | id: "plan-1" as never, | |
| 87 | turnId: "turn-1" as never, | |
| 88 | createdAt: "2026-03-09T10:00:00.000Z", | |
| 89 | updatedAt: "2026-03-09T10:05:00.000Z", | |
| 90 | planMarkdown: "# Plan", | |
| 91 | }, | |
| 92 | ], | |
| 93 | session: { | |
| 94 | ...baseThread.session, | |
| 95 | status: "ready", | |
| 96 | orchestrationStatus: "ready", | |
| 97 | }, | |
| 98 | }, | |
| 99 | hasPendingApprovals: false, | |
| 100 | hasPendingUserInput: false, | |
| 101 | }), | |
| 102 | ).toMatchObject({ label: "Plan Ready", pulse: false }); | |
| 103 | }); | |
| 104 | ||
| 74 | 105 | it("shows completed when there is an unseen completion and no active blocker", () => { |
| 75 | 106 | expect( |
| 76 | 107 | resolveThreadStatusPill({ |
| 77 | 108 | thread: { |
| 78 | 109 | ...baseThread, |
| 110 | interactionMode: "default", | |
| 79 | 111 | latestTurn: makeLatestTurn(), |
| 80 | 112 | lastVisitedAt: "2026-03-09T10:04:00.000Z", |
| 81 | 113 | session: { |
apps/web/src/components/Sidebar.logic.ts+26-2| ... | ... | @@ -1,13 +1,23 @@ |
| 1 | 1 | import type { Thread } from "../types"; |
| 2 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; | |
| 2 | 3 | |
| 3 | 4 | export interface ThreadStatusPill { |
| 4 | label: "Working" | "Connecting" | "Completed" | "Pending Approval" | "Awaiting Input"; | |
| 5 | label: | |
| 6 | | "Working" | |
| 7 | | "Connecting" | |
| 8 | | "Completed" | |
| 9 | | "Pending Approval" | |
| 10 | | "Awaiting Input" | |
| 11 | | "Plan Ready"; | |
| 5 | 12 | colorClass: string; |
| 6 | 13 | dotClass: string; |
| 7 | 14 | pulse: boolean; |
| 8 | 15 | } |
| 9 | 16 | |
| 10 | type ThreadStatusInput = Pick<Thread, "latestTurn" | "lastVisitedAt" | "session">; | |
| 17 | type ThreadStatusInput = Pick< | |
| 18 | Thread, | |
| 19 | "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" | |
| 20 | >; | |
| 11 | 21 | |
| 12 | 22 | export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { |
| 13 | 23 | if (!thread.latestTurn?.completedAt) return false; |
| ... | ... | @@ -63,6 +73,20 @@ export function resolveThreadStatusPill(input: { |
| 63 | 73 | }; |
| 64 | 74 | } |
| 65 | 75 | |
| 76 | const hasPlanReadyPrompt = | |
| 77 | !hasPendingUserInput && | |
| 78 | thread.interactionMode === "plan" && | |
| 79 | isLatestTurnSettled(thread.latestTurn, thread.session) && | |
| 80 | findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null; | |
| 81 | if (hasPlanReadyPrompt) { | |
| 82 | return { | |
| 83 | label: "Plan Ready", | |
| 84 | colorClass: "text-violet-600 dark:text-violet-300/90", | |
| 85 | dotClass: "bg-violet-500 dark:bg-violet-300/90", | |
| 86 | pulse: false, | |
| 87 | }; | |
| 88 | } | |
| 89 | ||
| 66 | 90 | if (hasUnseenCompletion(thread)) { |
| 67 | 91 | return { |
| 68 | 92 | label: "Completed", |