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", () => {...@@ -20,8 +20,10 @@ describe("hasUnseenCompletion", () => {
20 it("returns true when a thread completed after its last visit", () => {20 it("returns true when a thread completed after its last visit", () => {
21 expect(21 expect(
22 hasUnseenCompletion({22 hasUnseenCompletion({
23 interactionMode: "default",
23 latestTurn: makeLatestTurn(),24 latestTurn: makeLatestTurn(),
24 lastVisitedAt: "2026-03-09T10:04:00.000Z",25 lastVisitedAt: "2026-03-09T10:04:00.000Z",
26 proposedPlans: [],
25 session: null,27 session: null,
26 }),28 }),
27 ).toBe(true);29 ).toBe(true);
...@@ -30,8 +32,10 @@ describe("hasUnseenCompletion", () => {...@@ -30,8 +32,10 @@ describe("hasUnseenCompletion", () => {
3032
31describe("resolveThreadStatusPill", () => {33describe("resolveThreadStatusPill", () => {
32 const baseThread = {34 const baseThread = {
35 interactionMode: "plan" as const,
33 latestTurn: null,36 latestTurn: null,
34 lastVisitedAt: undefined,37 lastVisitedAt: undefined,
38 proposedPlans: [],
35 session: {39 session: {
36 provider: "codex" as const,40 provider: "codex" as const,
37 status: "running" as const,41 status: "running" as const,
...@@ -71,11 +75,39 @@ describe("resolveThreadStatusPill", () => {...@@ -71,11 +75,39 @@ describe("resolveThreadStatusPill", () => {
71 ).toMatchObject({ label: "Working", pulse: true });75 ).toMatchObject({ label: "Working", pulse: true });
72 });76 });
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
74 it("shows completed when there is an unseen completion and no active blocker", () => {105 it("shows completed when there is an unseen completion and no active blocker", () => {
75 expect(106 expect(
76 resolveThreadStatusPill({107 resolveThreadStatusPill({
77 thread: {108 thread: {
78 ...baseThread,109 ...baseThread,
110 interactionMode: "default",
79 latestTurn: makeLatestTurn(),111 latestTurn: makeLatestTurn(),
80 lastVisitedAt: "2026-03-09T10:04:00.000Z",112 lastVisitedAt: "2026-03-09T10:04:00.000Z",
81 session: {113 session: {
apps/web/src/components/Sidebar.logic.ts+26-2
...@@ -1,13 +1,23 @@...@@ -1,13 +1,23 @@
1import type { Thread } from "../types";1import type { Thread } from "../types";
2import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
23
3export interface ThreadStatusPill {4export 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 colorClass: string;12 colorClass: string;
6 dotClass: string;13 dotClass: string;
7 pulse: boolean;14 pulse: boolean;
8}15}
916
10type ThreadStatusInput = Pick<Thread, "latestTurn" | "lastVisitedAt" | "session">;17type ThreadStatusInput = Pick<
18 Thread,
19 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
20>;
1121
12export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {22export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
13 if (!thread.latestTurn?.completedAt) return false;23 if (!thread.latestTurn?.completedAt) return false;
...@@ -63,6 +73,20 @@ export function resolveThreadStatusPill(input: {...@@ -63,6 +73,20 @@ export function resolveThreadStatusPill(input: {
63 };73 };
64 }74 }
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
66 if (hasUnseenCompletion(thread)) {90 if (hasUnseenCompletion(thread)) {
67 return {91 return {
68 label: "Completed",92 label: "Completed",