Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
}),
);

it.effect("maps snake_case plan statuses to canonical turn.plan.updated events", () =>
Effect.gen(function* () {
const adapter = yield* CodexAdapter;
const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);

lifecycleManager.emit("event", {
id: asEventId("evt-plan-updated"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "turn/plan/updated",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
payload: {
explanation: "Working through the plan",
plan: [
{ step: "Inspect files", status: "completed" },
{ step: "Apply patch", status: "in_progress" },
],
},
} satisfies ProviderEvent);

const firstEvent = yield* Fiber.join(firstEventFiber);

assert.equal(firstEvent._tag, "Some");
if (firstEvent._tag !== "Some") {
return;
}
assert.equal(firstEvent.value.type, "turn.plan.updated");
if (firstEvent.value.type !== "turn.plan.updated") {
return;
}
assert.deepStrictEqual(firstEvent.value.payload.plan, [
{ step: "Inspect files", status: "completed" },
{ step: "Apply patch", status: "inProgress" },
]);
}),
);

it.effect("maps plan deltas to canonical proposed-plan delta events", () =>
Effect.gen(function* () {
const adapter = yield* CodexAdapter;
Expand Down
15 changes: 11 additions & 4 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,16 @@ function extractProposedPlanMarkdown(text: string | undefined): string | undefin
return planMarkdown && planMarkdown.length > 0 ? planMarkdown : undefined;
}

function normalizePlanStepStatus(status: unknown): "pending" | "inProgress" | "completed" {
if (status === "completed") {
return "completed";
}
if (status === "inProgress" || status === "in_progress") {
return "inProgress";
}
return "pending";
}

function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId {
return RuntimeItemId.makeUnsafe(itemId);
}
Expand Down Expand Up @@ -833,10 +843,7 @@ function mapToRuntimeEvents(
.filter((entry): entry is Record<string, unknown> => entry !== undefined)
.map((entry) => ({
step: asString(entry.step) ?? "step",
status:
entry.status === "completed" || entry.status === "inProgress"
? entry.status
: "pending",
status: normalizePlanStepStatus(entry.status),
})),
},
},
Expand Down
39 changes: 36 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
[activeLatestTurn?.turnId, threadActivities],
);
const activePlanTurnId = activePlan?.turnId ?? null;
const activePendingUserInputRequestId = activePendingUserInput?.requestId ?? null;
const hasPendingPlanFeedback =
activePendingUserInputRequestId !== null &&
(activePlanTurnId !== null || interactionMode === "plan");
const showPlanFollowUpPrompt =
pendingUserInputs.length === 0 &&
interactionMode === "plan" &&
Expand Down Expand Up @@ -928,6 +933,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
activePendingUserInput?.requestId,
activePendingProgress?.activeQuestion?.id,
]);
useEffect(() => {
if (!hasPendingPlanFeedback) {
return;
}
const turnKey =
activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
if (!turnKey || planSidebarDismissedForTurnRef.current === turnKey) {
return;
}
setPlanSidebarOpen(true);
}, [
activeLatestTurn?.turnId,
activePlanTurnId,
hasPendingPlanFeedback,
sidebarProposedPlan?.turnId,
]);

useEffect(() => {
attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
}, [attachmentPreviewHandoffByMessageId]);
Expand Down Expand Up @@ -1921,7 +1943,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
const togglePlanSidebar = useCallback(() => {
setPlanSidebarOpen((open) => {
if (open) {
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
const turnKey =
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null;
if (turnKey) {
planSidebarDismissedForTurnRef.current = turnKey;
}
Expand All @@ -1930,7 +1953,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
return !open;
});
}, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
}, [activeLatestTurn?.turnId, activePlan?.turnId, sidebarProposedPlan?.turnId]);

const persistThreadSettingsForNextTurn = useCallback(
async (input: {
Expand Down Expand Up @@ -5048,14 +5071,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
{planSidebarOpen ? (
<PlanSidebar
activePlan={activePlan}
activePendingIsResponding={activePendingIsResponding}
activePendingProgress={activePendingProgress}
activePendingUserInput={activePendingUserInput}
activeProposedPlan={sidebarProposedPlan}
markdownCwd={gitCwd ?? undefined}
onAdvancePendingUserInput={onAdvanceActivePendingUserInput}
workspaceRoot={activeProject?.cwd ?? undefined}
onFocusComposer={scheduleComposerFocus}
timestampFormat={timestampFormat}
onSelectPendingUserInputOption={onSelectActivePendingUserInputOption}
onClose={() => {
setPlanSidebarOpen(false);
// Track that the user explicitly dismissed for this turn so auto-open won't fight them.
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
const turnKey =
activePlan?.turnId ??
sidebarProposedPlan?.turnId ??
activeLatestTurn?.turnId ??
null;
if (turnKey) {
planSidebarDismissedForTurnRef.current = turnKey;
}
Expand Down
105 changes: 87 additions & 18 deletions apps/web/src/components/PlanChecklist.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo } from "react";
import { CheckIcon, LoaderIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Badge } from "./ui/badge";

// ---------------------------------------------------------------------------
// Types
Expand All @@ -11,6 +12,12 @@ export interface PlanChecklistItemData {
text: string;
/** Execution status. */
status: "pending" | "inProgress" | "completed";
/** Optional supporting note shown below the step. */
note?: string;
/** Optional status label override shown beside the step. */
statusText?: string;
/** Optional badge tone for the status label override. */
statusTone?: "success" | "info" | "warning";
}

interface PlanChecklistProps {
Expand Down Expand Up @@ -50,14 +57,22 @@ const PlanChecklist = memo(function PlanChecklist({
{items.length === 1 ? "To-do" : "To-dos"}
<span className="mx-1.5 text-muted-foreground/30">&middot;</span>
<span>{completionMode}</span>
{completedCount > 0 ? (
<>
<span className="mx-1.5 text-muted-foreground/30">&middot;</span>
<span className="font-medium text-emerald-700/80 dark:text-emerald-300/85">
{completedCount === items.length ? "All done" : `${completedCount} done`}
</span>
</>
) : null}
</p>
</div>

{/* Items */}
<div className="rounded-xl border border-border/50 bg-background/40">
{items.map((item, index) => (
<PlanChecklistRow
key={`${item.status}:${item.text}`}
key={`${item.text}:${item.note ?? ""}:${item.statusText ?? item.status}`}
item={item}
index={index}
isLast={index === items.length - 1}
Expand All @@ -67,7 +82,7 @@ const PlanChecklist = memo(function PlanChecklist({
</div>

{/* Progress summary */}
{completedCount > 0 && completedCount < items.length ? (
{completedCount > 0 ? (
<div className="flex items-center gap-2.5 px-1">
<div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/50">
<div
Expand All @@ -77,8 +92,8 @@ const PlanChecklist = memo(function PlanChecklist({
}}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
{completedCount}/{items.length}
<span className="shrink-0 text-[10px] tabular-nums text-emerald-700/80 dark:text-emerald-300/80">
{completedCount === items.length ? "Done" : `${completedCount}/${items.length} done`}
</span>
</div>
) : null}
Expand All @@ -101,34 +116,58 @@ const PlanChecklistRow = memo(function PlanChecklistRow({
isLast: boolean;
live: boolean;
}) {
const statusBadge = resolveChecklistStatusBadge(item);

return (
<div
data-slot="plan-checklist-item"
data-status={item.status}
className={cn(
"flex items-start gap-3 px-3.5 py-2.5 transition-colors duration-150",
!isLast && "border-b border-border/30",
item.status === "inProgress" && "bg-blue-500/[0.03]",
item.status === "completed" && "bg-emerald-500/[0.04]",
item.status === "inProgress" &&
(item.statusTone === "warning" ? "bg-amber-500/[0.06]" : "bg-blue-500/[0.03]"),
)}
>
{/* Status indicator */}
<div className="mt-0.5 flex size-5 shrink-0 items-center justify-center">
<ChecklistStatusIndicator status={item.status} live={live} />
<ChecklistStatusIndicator status={item.status} statusTone={item.statusTone} live={live} />
</div>

{/* Text */}
<p
className={cn(
"min-w-0 flex-1 text-[13px] leading-snug",
item.status === "completed"
? "text-muted-foreground/45 line-through decoration-muted-foreground/20"
: item.status === "inProgress"
? "text-foreground/90 font-medium"
: "text-foreground/70",
)}
>
{item.text}
</p>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-[13px] leading-snug",
item.status === "completed"
? "text-emerald-800 dark:text-emerald-200/95"
: item.status === "inProgress"
? "font-medium text-foreground/90"
: "text-foreground/70",
)}
>
{item.text}
</p>
{item.note ? (
<p
className={cn(
"mt-1 text-[11px] leading-relaxed",
item.statusTone === "warning"
? "text-amber-800/80 dark:text-amber-200/80"
: "text-muted-foreground/65",
)}
>
{item.note}
</p>
) : null}
</div>

{statusBadge ? (
<Badge size="sm" variant={statusBadge.variant} className="mt-0.5 shrink-0">
{statusBadge.label}
</Badge>
) : null}

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

function ChecklistStatusIndicator({
status,
statusTone,
live,
}: {
status: PlanChecklistItemData["status"];
statusTone?: PlanChecklistItemData["statusTone"];
live: boolean;
}) {
if (status === "completed") {
Expand All @@ -158,6 +199,13 @@ function ChecklistStatusIndicator({
}

if (status === "inProgress") {
if (statusTone === "warning") {
return (
<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">
<span className="text-[11px] font-semibold leading-none">?</span>
</span>
);
}
return (
<span className="flex size-[18px] items-center justify-center rounded-full bg-blue-500/10 text-blue-400 ring-1 ring-blue-400/30">
{live ? (
Expand All @@ -177,5 +225,26 @@ function ChecklistStatusIndicator({
);
}

function resolveChecklistStatusBadge(
item: PlanChecklistItemData,
): { label: string; variant: "success" | "info" | "warning" } | null {
if (item.statusText && item.statusTone) {
return {
label: item.statusText,
variant: item.statusTone,
};
}

if (item.status === "completed") {
return { label: "Done", variant: "success" };
}

if (item.status === "inProgress") {
return { label: "Working", variant: "info" };
}

return null;
}

export default PlanChecklist;
export type { PlanChecklistProps };
Loading
Loading