Skip to content

Commit e32e0f3

Browse files
authored
Surface interactive plan feedback in the sidebar (#136)
- Normalize snake_case plan statuses across server and web - Show pending user input directly in the plan sidebar - Improve checklist progress/status copy and add effect peer-install patch
1 parent 3cbfe4a commit e32e0f3

9 files changed

Lines changed: 471 additions & 43 deletions

File tree

apps/server/src/provider/Layers/CodexAdapter.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
366366
}),
367367
);
368368

369+
it.effect("maps snake_case plan statuses to canonical turn.plan.updated events", () =>
370+
Effect.gen(function* () {
371+
const adapter = yield* CodexAdapter;
372+
const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
373+
374+
lifecycleManager.emit("event", {
375+
id: asEventId("evt-plan-updated"),
376+
kind: "notification",
377+
provider: "codex",
378+
createdAt: new Date().toISOString(),
379+
method: "turn/plan/updated",
380+
threadId: asThreadId("thread-1"),
381+
turnId: asTurnId("turn-1"),
382+
payload: {
383+
explanation: "Working through the plan",
384+
plan: [
385+
{ step: "Inspect files", status: "completed" },
386+
{ step: "Apply patch", status: "in_progress" },
387+
],
388+
},
389+
} satisfies ProviderEvent);
390+
391+
const firstEvent = yield* Fiber.join(firstEventFiber);
392+
393+
assert.equal(firstEvent._tag, "Some");
394+
if (firstEvent._tag !== "Some") {
395+
return;
396+
}
397+
assert.equal(firstEvent.value.type, "turn.plan.updated");
398+
if (firstEvent.value.type !== "turn.plan.updated") {
399+
return;
400+
}
401+
assert.deepStrictEqual(firstEvent.value.payload.plan, [
402+
{ step: "Inspect files", status: "completed" },
403+
{ step: "Apply patch", status: "inProgress" },
404+
]);
405+
}),
406+
);
407+
369408
it.effect("maps plan deltas to canonical proposed-plan delta events", () =>
370409
Effect.gen(function* () {
371410
const adapter = yield* CodexAdapter;

apps/server/src/provider/Layers/CodexAdapter.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,16 @@ function extractProposedPlanMarkdown(text: string | undefined): string | undefin
443443
return planMarkdown && planMarkdown.length > 0 ? planMarkdown : undefined;
444444
}
445445

446+
function normalizePlanStepStatus(status: unknown): "pending" | "inProgress" | "completed" {
447+
if (status === "completed") {
448+
return "completed";
449+
}
450+
if (status === "inProgress" || status === "in_progress") {
451+
return "inProgress";
452+
}
453+
return "pending";
454+
}
455+
446456
function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId {
447457
return RuntimeItemId.makeUnsafe(itemId);
448458
}
@@ -833,10 +843,7 @@ function mapToRuntimeEvents(
833843
.filter((entry): entry is Record<string, unknown> => entry !== undefined)
834844
.map((entry) => ({
835845
step: asString(entry.step) ?? "step",
836-
status:
837-
entry.status === "completed" || entry.status === "inProgress"
838-
? entry.status
839-
: "pending",
846+
status: normalizePlanStepStatus(entry.status),
840847
})),
841848
},
842849
},

apps/web/src/components/ChatView.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
874874
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
875875
[activeLatestTurn?.turnId, threadActivities],
876876
);
877+
const activePlanTurnId = activePlan?.turnId ?? null;
878+
const activePendingUserInputRequestId = activePendingUserInput?.requestId ?? null;
879+
const hasPendingPlanFeedback =
880+
activePendingUserInputRequestId !== null &&
881+
(activePlanTurnId !== null || interactionMode === "plan");
877882
const showPlanFollowUpPrompt =
878883
pendingUserInputs.length === 0 &&
879884
interactionMode === "plan" &&
@@ -928,6 +933,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
928933
activePendingUserInput?.requestId,
929934
activePendingProgress?.activeQuestion?.id,
930935
]);
936+
useEffect(() => {
937+
if (!hasPendingPlanFeedback) {
938+
return;
939+
}
940+
const turnKey =
941+
activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
942+
if (!turnKey || planSidebarDismissedForTurnRef.current === turnKey) {
943+
return;
944+
}
945+
setPlanSidebarOpen(true);
946+
}, [
947+
activeLatestTurn?.turnId,
948+
activePlanTurnId,
949+
hasPendingPlanFeedback,
950+
sidebarProposedPlan?.turnId,
951+
]);
952+
931953
useEffect(() => {
932954
attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
933955
}, [attachmentPreviewHandoffByMessageId]);
@@ -1921,7 +1943,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
19211943
const togglePlanSidebar = useCallback(() => {
19221944
setPlanSidebarOpen((open) => {
19231945
if (open) {
1924-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
1946+
const turnKey =
1947+
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
19251948
if (turnKey) {
19261949
planSidebarDismissedForTurnRef.current = turnKey;
19271950
}
@@ -1930,7 +1953,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19301953
}
19311954
return !open;
19321955
});
1933-
}, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
1956+
}, [activeLatestTurn?.turnId, activePlan?.turnId, sidebarProposedPlan?.turnId]);
19341957

19351958
const persistThreadSettingsForNextTurn = useCallback(
19361959
async (input: {
@@ -5079,14 +5102,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
50795102
{planSidebarOpen ? (
50805103
<PlanSidebar
50815104
activePlan={activePlan}
5105+
activePendingIsResponding={activePendingIsResponding}
5106+
activePendingProgress={activePendingProgress}
5107+
activePendingUserInput={activePendingUserInput}
50825108
activeProposedPlan={sidebarProposedPlan}
50835109
markdownCwd={gitCwd ?? undefined}
5110+
onAdvancePendingUserInput={onAdvanceActivePendingUserInput}
50845111
workspaceRoot={activeProject?.cwd ?? undefined}
5112+
onFocusComposer={scheduleComposerFocus}
50855113
timestampFormat={timestampFormat}
5114+
onSelectPendingUserInputOption={onSelectActivePendingUserInputOption}
50865115
onClose={() => {
50875116
setPlanSidebarOpen(false);
50885117
// Track that the user explicitly dismissed for this turn so auto-open won't fight them.
5089-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
5118+
const turnKey =
5119+
activePlan?.turnId ??
5120+
sidebarProposedPlan?.turnId ??
5121+
activeLatestTurn?.turnId ??
5122+
null;
50905123
if (turnKey) {
50915124
planSidebarDismissedForTurnRef.current = turnKey;
50925125
}

apps/web/src/components/PlanChecklist.tsx

Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memo } from "react";
22
import { CheckIcon, LoaderIcon } from "lucide-react";
33
import { cn } from "~/lib/utils";
4+
import { Badge } from "./ui/badge";
45

56
// ---------------------------------------------------------------------------
67
// Types
@@ -11,6 +12,12 @@ export interface PlanChecklistItemData {
1112
text: string;
1213
/** Execution status. */
1314
status: "pending" | "inProgress" | "completed";
15+
/** Optional supporting note shown below the step. */
16+
note?: string;
17+
/** Optional status label override shown beside the step. */
18+
statusText?: string;
19+
/** Optional badge tone for the status label override. */
20+
statusTone?: "success" | "info" | "warning";
1421
}
1522

1623
interface PlanChecklistProps {
@@ -50,14 +57,22 @@ const PlanChecklist = memo(function PlanChecklist({
5057
{items.length === 1 ? "To-do" : "To-dos"}
5158
<span className="mx-1.5 text-muted-foreground/30">&middot;</span>
5259
<span>{completionMode}</span>
60+
{completedCount > 0 ? (
61+
<>
62+
<span className="mx-1.5 text-muted-foreground/30">&middot;</span>
63+
<span className="font-medium text-emerald-700/80 dark:text-emerald-300/85">
64+
{completedCount === items.length ? "All done" : `${completedCount} done`}
65+
</span>
66+
</>
67+
) : null}
5368
</p>
5469
</div>
5570

5671
{/* Items */}
5772
<div className="rounded-xl border border-border/50 bg-background/40">
5873
{items.map((item, index) => (
5974
<PlanChecklistRow
60-
key={`${item.status}:${item.text}`}
75+
key={`${item.text}:${item.note ?? ""}:${item.statusText ?? item.status}`}
6176
item={item}
6277
index={index}
6378
isLast={index === items.length - 1}
@@ -67,7 +82,7 @@ const PlanChecklist = memo(function PlanChecklist({
6782
</div>
6883

6984
{/* Progress summary */}
70-
{completedCount > 0 && completedCount < items.length ? (
85+
{completedCount > 0 ? (
7186
<div className="flex items-center gap-2.5 px-1">
7287
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/50">
7388
<div
@@ -77,8 +92,8 @@ const PlanChecklist = memo(function PlanChecklist({
7792
}}
7893
/>
7994
</div>
80-
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
81-
{completedCount}/{items.length}
95+
<span className="shrink-0 text-[10px] tabular-nums text-emerald-700/80 dark:text-emerald-300/80">
96+
{completedCount === items.length ? "Done" : `${completedCount}/${items.length} done`}
8297
</span>
8398
</div>
8499
) : null}
@@ -101,34 +116,58 @@ const PlanChecklistRow = memo(function PlanChecklistRow({
101116
isLast: boolean;
102117
live: boolean;
103118
}) {
119+
const statusBadge = resolveChecklistStatusBadge(item);
120+
104121
return (
105122
<div
106123
data-slot="plan-checklist-item"
107124
data-status={item.status}
108125
className={cn(
109126
"flex items-start gap-3 px-3.5 py-2.5 transition-colors duration-150",
110127
!isLast && "border-b border-border/30",
111-
item.status === "inProgress" && "bg-blue-500/[0.03]",
128+
item.status === "completed" && "bg-emerald-500/[0.04]",
129+
item.status === "inProgress" &&
130+
(item.statusTone === "warning" ? "bg-amber-500/[0.06]" : "bg-blue-500/[0.03]"),
112131
)}
113132
>
114133
{/* Status indicator */}
115134
<div className="mt-0.5 flex size-5 shrink-0 items-center justify-center">
116-
<ChecklistStatusIndicator status={item.status} live={live} />
135+
<ChecklistStatusIndicator status={item.status} statusTone={item.statusTone} live={live} />
117136
</div>
118137

119138
{/* Text */}
120-
<p
121-
className={cn(
122-
"min-w-0 flex-1 text-[13px] leading-snug",
123-
item.status === "completed"
124-
? "text-muted-foreground/45 line-through decoration-muted-foreground/20"
125-
: item.status === "inProgress"
126-
? "text-foreground/90 font-medium"
127-
: "text-foreground/70",
128-
)}
129-
>
130-
{item.text}
131-
</p>
139+
<div className="min-w-0 flex-1">
140+
<p
141+
className={cn(
142+
"text-[13px] leading-snug",
143+
item.status === "completed"
144+
? "text-emerald-800 dark:text-emerald-200/95"
145+
: item.status === "inProgress"
146+
? "font-medium text-foreground/90"
147+
: "text-foreground/70",
148+
)}
149+
>
150+
{item.text}
151+
</p>
152+
{item.note ? (
153+
<p
154+
className={cn(
155+
"mt-1 text-[11px] leading-relaxed",
156+
item.statusTone === "warning"
157+
? "text-amber-800/80 dark:text-amber-200/80"
158+
: "text-muted-foreground/65",
159+
)}
160+
>
161+
{item.note}
162+
</p>
163+
) : null}
164+
</div>
165+
166+
{statusBadge ? (
167+
<Badge size="sm" variant={statusBadge.variant} className="mt-0.5 shrink-0">
168+
{statusBadge.label}
169+
</Badge>
170+
) : null}
132171

133172
{/* Item number */}
134173
<span className="mt-0.5 shrink-0 text-[10px] tabular-nums text-muted-foreground/25">
@@ -144,9 +183,11 @@ const PlanChecklistRow = memo(function PlanChecklistRow({
144183

145184
function ChecklistStatusIndicator({
146185
status,
186+
statusTone,
147187
live,
148188
}: {
149189
status: PlanChecklistItemData["status"];
190+
statusTone?: PlanChecklistItemData["statusTone"];
150191
live: boolean;
151192
}) {
152193
if (status === "completed") {
@@ -158,6 +199,13 @@ function ChecklistStatusIndicator({
158199
}
159200

160201
if (status === "inProgress") {
202+
if (statusTone === "warning") {
203+
return (
204+
<span className="flex size-[18px] items-center justify-center rounded-full bg-amber-500/10 text-amber-700 ring-1 ring-amber-400/30 dark:text-amber-300">
205+
<span className="text-[11px] font-semibold leading-none">?</span>
206+
</span>
207+
);
208+
}
161209
return (
162210
<span className="flex size-[18px] items-center justify-center rounded-full bg-blue-500/10 text-blue-400 ring-1 ring-blue-400/30">
163211
{live ? (
@@ -177,5 +225,26 @@ function ChecklistStatusIndicator({
177225
);
178226
}
179227

228+
function resolveChecklistStatusBadge(
229+
item: PlanChecklistItemData,
230+
): { label: string; variant: "success" | "info" | "warning" } | null {
231+
if (item.statusText && item.statusTone) {
232+
return {
233+
label: item.statusText,
234+
variant: item.statusTone,
235+
};
236+
}
237+
238+
if (item.status === "completed") {
239+
return { label: "Done", variant: "success" };
240+
}
241+
242+
if (item.status === "inProgress") {
243+
return { label: "Working", variant: "info" };
244+
}
245+
246+
return null;
247+
}
248+
180249
export default PlanChecklist;
181250
export type { PlanChecklistProps };

0 commit comments

Comments
 (0)