authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 09:42:19-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 09:46:33-07:00
logb946f00f1a31f16a51f9bd1f579baf40c8eaed83
tree9d6c8471c3f3c8c6f0120da673b785dfdd493e95
parentb030f69a833802fae9222781ef2dc822dfe81aac

Show Plan Ready status pill for settled plan threads

- 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 inputs

2 files changed, 58 insertions(+), 2 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+32
......@@ -20,8 +20,10 @@ describe("hasUnseenCompletion", () => {
2020 it("returns true when a thread completed after its last visit", () => {
2121 expect(
2222 hasUnseenCompletion({
23 interactionMode: "default",
2324 latestTurn: makeLatestTurn(),
2425 lastVisitedAt: "2026-03-09T10:04:00.000Z",
26 proposedPlans: [],
2527 session: null,
2628 }),
2729 ).toBe(true);
......@@ -30,8 +32,10 @@ describe("hasUnseenCompletion", () => {
3032
3133describe("resolveThreadStatusPill", () => {
3234 const baseThread = {
35 interactionMode: "plan" as const,
3336 latestTurn: null,
3437 lastVisitedAt: undefined,
38 proposedPlans: [],
3539 session: {
3640 provider: "codex" as const,
3741 status: "running" as const,
......@@ -71,11 +75,39 @@ describe("resolveThreadStatusPill", () => {
7175 ).toMatchObject({ label: "Working", pulse: true });
7276 });
7377
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
74105 it("shows completed when there is an unseen completion and no active blocker", () => {
75106 expect(
76107 resolveThreadStatusPill({
77108 thread: {
78109 ...baseThread,
110 interactionMode: "default",
79111 latestTurn: makeLatestTurn(),
80112 lastVisitedAt: "2026-03-09T10:04:00.000Z",
81113 session: {
apps/web/src/components/Sidebar.logic.ts+26-2
......@@ -1,13 +1,23 @@
11import type { Thread } from "../types";
2import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
23
34export 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";
512 colorClass: string;
613 dotClass: string;
714 pulse: boolean;
815}
916
10type ThreadStatusInput = Pick<Thread, "latestTurn" | "lastVisitedAt" | "session">;
17type ThreadStatusInput = Pick<
18 Thread,
19 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
20>;
1121
1222export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
1323 if (!thread.latestTurn?.completedAt) return false;
......@@ -63,6 +73,20 @@ export function resolveThreadStatusPill(input: {
6373 };
6474 }
6575
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
6690 if (hasUnseenCompletion(thread)) {
6791 return {
6892 label: "Completed",