Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ function runtimeEventToActivities(
payload: {
itemType: event.payload.itemType,
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,13 +462,52 @@ function classifyRequestType(toolName: string): CanonicalRequestType {
: "dynamic_tool_call";
}

function isTodoTool(toolName: string): boolean {
return toolName.toLowerCase().includes("todowrite");
}

type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" };

function extractPlanStepsFromTodoInput(input: Record<string, unknown>): PlanStep[] | null {
// TodoWrite format: { todos: [{ content, status, activeForm? }] }
const todos = input.todos;
if (!Array.isArray(todos) || todos.length === 0) {
return null;
}
return todos
.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object")
.map((todo) => ({
step: typeof todo.content === "string" ? todo.content : "Task",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty todo content produces invalid plan step string

Low Severity

extractPlanStepsFromTodoInput sets step to todo.content when it's a string, but doesn't guard against empty or whitespace-only strings. The RuntimePlanStep schema requires step to be a TrimmedNonEmptyString. If Claude sends a todo with content: "" or content: " ", the resulting plan step would fail schema validation, potentially disrupting the streaming event pipeline. The fallback "Task" only applies when content is not a string at all.

Fix in Cursor Fix in Web

status:
todo.status === "completed"
? "completed"
: todo.status === "in_progress"
? "inProgress"
: "pending",
}));
}

function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
const commandValue = input.command ?? input.cmd;
const command = typeof commandValue === "string" ? commandValue : undefined;
if (command && command.trim().length > 0) {
return `${toolName}: ${command.trim().slice(0, 400)}`;
}

// For agent/subagent tools, prefer human-readable description or prompt over raw JSON
const itemType = classifyToolItemType(toolName);
if (itemType === "collab_agent_tool_call") {
const description =
typeof input.description === "string" ? input.description.trim() : undefined;
const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined;
const subagentType =
typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined;
const label = description || (prompt ? prompt.slice(0, 200) : undefined);
if (label) {
return subagentType ? `${subagentType}: ${label}` : label;
}
}

const serialized = JSON.stringify(input);
if (serialized.length <= 400) {
return `${toolName}: ${serialized}`;
Expand Down Expand Up @@ -1617,6 +1656,26 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
payload: message,
},
});

// Emit plan update when TodoWrite input is parsed
if (parsedInput && isTodoTool(nextTool.toolName)) {
const planSteps = extractPlanStepsFromTodoInput(parsedInput);
if (planSteps && planSteps.length > 0) {
const planStamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "turn.plan.updated",
eventId: planStamp.eventId,
provider: PROVIDER,
createdAt: planStamp.createdAt,
threadId: context.session.threadId,
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
payload: {
plan: planSteps,
},
providerRefs: nativeProviderRefs(context),
});
}
}
}
return;
}
Expand Down
33 changes: 23 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
[activeLatestTurn?.turnId, threadActivities],
);
const planSidebarLabel = (sidebarProposedPlan || interactionMode === "plan") ? "Plan" : "Tasks";
const showPlanFollowUpPrompt =
pendingUserInputs.length === 0 &&
interactionMode === "plan" &&
Expand Down Expand Up @@ -1673,10 +1674,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
const togglePlanSidebar = useCallback(() => {
setPlanSidebarOpen((open) => {
if (open) {
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
if (turnKey) {
planSidebarDismissedForTurnRef.current = turnKey;
}
planSidebarDismissedForTurnRef.current =
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
} else {
planSidebarDismissedForTurnRef.current = null;
}
Expand Down Expand Up @@ -1946,6 +1945,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
planSidebarDismissedForTurnRef.current = null;
}, [activeThread?.id]);

// Auto-open the plan sidebar when plan/todo steps arrive for the current turn.
// Don't auto-open for plans carried over from a previous turn (the user can open manually).
useEffect(() => {
if (!activePlan) return;
if (planSidebarOpen) return;
const latestTurnId = activeLatestTurn?.turnId ?? null;
if (latestTurnId && activePlan.turnId !== latestTurnId) return;
const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
if (planSidebarDismissedForTurnRef.current === turnKey) return;
setPlanSidebarOpen(true);
}, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]);

useEffect(() => {
if (!composerMenuOpen) {
setComposerHighlightedItemId(null);
Expand Down Expand Up @@ -3902,6 +3913,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
activePlan || sidebarProposedPlan || planSidebarOpen,
)}
interactionMode={interactionMode}
planSidebarLabel={planSidebarLabel}
planSidebarOpen={planSidebarOpen}
runtimeMode={runtimeMode}
traitsMenuContent={providerTraitsMenuContent}
Expand Down Expand Up @@ -3991,11 +4003,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="button"
onClick={togglePlanSidebar}
title={
planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"
planSidebarOpen
? `Hide ${planSidebarLabel.toLowerCase()} sidebar`
: `Show ${planSidebarLabel.toLowerCase()} sidebar`
}
>
<ListTodoIcon />
<span className="sr-only sm:not-sr-only">Plan</span>
<span className="sr-only sm:not-sr-only">{planSidebarLabel}</span>
</Button>
</>
) : null}
Expand Down Expand Up @@ -4207,16 +4221,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeProject?.cwd ?? undefined}
timestampFormat={timestampFormat}
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;
if (turnKey) {
planSidebarDismissedForTurnRef.current = turnKey;
}
planSidebarDismissedForTurnRef.current =
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
}}
/>
) : null}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/PlanSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function stepStatusIcon(status: string): React.ReactNode {
interface PlanSidebarProps {
activePlan: ActivePlanState | null;
activeProposedPlan: LatestProposedPlanState | null;
label?: string;
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
timestampFormat: TimestampFormat;
Expand All @@ -62,6 +63,7 @@ interface PlanSidebarProps {
const PlanSidebar = memo(function PlanSidebar({
activePlan,
activeProposedPlan,
label = "Plan",
markdownCwd,
workspaceRoot,
timestampFormat,
Expand Down Expand Up @@ -126,7 +128,7 @@ const PlanSidebar = memo(function PlanSidebar({
variant="secondary"
className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase"
>
Plan
{label}
</Badge>
{activePlan ? (
<span className="text-[11px] text-muted-foreground/60">
Expand Down Expand Up @@ -167,7 +169,7 @@ const PlanSidebar = memo(function PlanSidebar({
size="icon-xs"
variant="ghost"
onClick={onClose}
aria-label="Close plan sidebar"
aria-label={`Close ${label.toLowerCase()} sidebar`}
className="text-muted-foreground/50 hover:text-foreground/70"
>
<PanelRightCloseIcon className="size-3.5" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
<CompactComposerControlsMenu
activePlan={false}
interactionMode="default"
planSidebarLabel="Plan"
planSidebarOpen={false}
runtimeMode="approval-required"
traitsMenuContent={
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
activePlan: boolean;
interactionMode: ProviderInteractionMode;
planSidebarLabel: string;
planSidebarOpen: boolean;
runtimeMode: RuntimeMode;
traitsMenuContent?: ReactNode;
Expand Down Expand Up @@ -71,7 +72,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
<MenuDivider />
<MenuItem onClick={props.onTogglePlanSidebar}>
<ListTodoIcon className="size-4 shrink-0" />
{props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
{props.planSidebarOpen
? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
: `Show ${props.planSidebarLabel.toLowerCase()} sidebar`}
</MenuItem>
</>
) : null}
Expand Down
61 changes: 59 additions & 2 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,30 @@ describe("deriveActivePlanState", () => {
steps: [{ step: "Implement Codex user input", status: "inProgress" }],
});
});

it("falls back to the most recent plan from a previous turn", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "plan-from-turn-1",
createdAt: "2026-02-23T00:00:01.000Z",
kind: "turn.plan.updated",
summary: "Plan updated",
tone: "info",
turnId: "turn-1",
payload: {
plan: [{ step: "Write tests", status: "completed" }],
},
}),
];

// Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan
const result = deriveActivePlanState(activities, TurnId.makeUnsafe("turn-2"));
expect(result).toEqual({
createdAt: "2026-02-23T00:00:01.000Z",
turnId: "turn-1",
steps: [{ step: "Write tests", status: "completed" }],
});
});
});

describe("findLatestProposedPlan", () => {
Expand Down Expand Up @@ -563,7 +587,7 @@ describe("deriveWorkLogEntries", () => {
expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
});

it("omits task start and completion lifecycle entries", () => {
it("omits task.started but shows task.progress and task.completed", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "task-start",
Expand All @@ -589,7 +613,40 @@ describe("deriveWorkLogEntries", () => {
];

const entries = deriveWorkLogEntries(activities, undefined);
expect(entries.map((entry) => entry.id)).toEqual(["task-progress"]);
expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]);
});

it("uses payload summary as label for task entries when available", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "task-progress-with-summary",
createdAt: "2026-02-23T00:00:02.000Z",
kind: "task.progress",
summary: "Reasoning update",
tone: "info",
payload: { summary: "Searching for API endpoints" },
}),
];

const entries = deriveWorkLogEntries(activities, undefined);
expect(entries[0]?.label).toBe("Searching for API endpoints");
});

it("uses payload detail as label for task.completed and preserves error tone", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "task-completed-failed",
createdAt: "2026-02-23T00:00:03.000Z",
kind: "task.completed",
summary: "Task failed",
tone: "error",
payload: { detail: "Failed to deploy changes" },
}),
];

const entries = deriveWorkLogEntries(activities, undefined);
expect(entries[0]?.label).toBe("Failed to deploy changes");
expect(entries[0]?.tone).toBe("error");
});

it("filters by turn id when provided", () => {
Expand Down
50 changes: 36 additions & 14 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,16 +338,15 @@ export function deriveActivePlanState(
latestTurnId: TurnId | undefined,
): ActivePlanState | null {
const ordered = [...activities].toSorted(compareActivitiesByOrder);
const candidates = ordered.filter((activity) => {
if (activity.kind !== "turn.plan.updated") {
return false;
}
if (!latestTurnId) {
return true;
}
return activity.turnId === latestTurnId;
});
const latest = candidates.at(-1);
const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
// Prefer plan from the current turn; fall back to the most recent plan from any turn
// so that TodoWrite tasks persist across follow-up messages.
const latest =
(latestTurnId
? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1)
: undefined) ??
allPlanActivities.at(-1) ??
null;
if (!latest) {
return null;
}
Expand Down Expand Up @@ -462,7 +461,7 @@ export function deriveWorkLogEntries(
const entries = ordered
.filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
.filter((activity) => activity.kind !== "tool.started")
.filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed")
.filter((activity) => activity.kind !== "task.started")
.filter((activity) => activity.kind !== "context-window.updated")
.filter((activity) => activity.summary !== "Checkpoint captured")
.filter((activity) => !isPlanBoundaryToolActivity(activity))
Expand Down Expand Up @@ -492,16 +491,39 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const command = extractToolCommand(payload);
const changedFiles = extractChangedFiles(payload);
const title = extractToolTitle(payload);
const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
const taskSummary =
isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
? payload.summary
: null;
const taskDetailAsLabel =
isTaskActivity &&
!taskSummary &&
typeof payload?.detail === "string" &&
payload.detail.length > 0
? payload.detail
: null;
const taskLabel = taskSummary || taskDetailAsLabel;
const entry: DerivedWorkLogEntry = {
id: activity.id,
createdAt: activity.createdAt,
label: activity.summary,
tone: activity.tone === "approval" ? "info" : activity.tone,
label: taskLabel || activity.summary,
tone:
activity.kind === "task.progress"
? "thinking"
: activity.tone === "approval"
? "info"
: activity.tone,
activityKind: activity.kind,
};
const itemType = extractWorkLogItemType(payload);
const requestKind = extractWorkLogRequestKind(payload);
if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
if (
!taskDetailAsLabel &&
payload &&
typeof payload.detail === "string" &&
payload.detail.length > 0
) {
const detail = stripTrailingExitCode(payload.detail).output;
if (detail) {
entry.detail = detail;
Expand Down
Loading