Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
84deaea
fix(claude): emit plan events for TodoWrite during input streaming
TimCrooker Mar 29, 2026
fe30dce
Persist plan sidebar across turns and simplify isTodoTool
TimCrooker Mar 29, 2026
689906f
Fix task.completed tone and label handling
TimCrooker Mar 29, 2026
25fb9d1
Merge branch 'main' into fix/claude-todowrite-plan-events
TimCrooker Mar 29, 2026
a7a82be
Fix sidebar dismiss when plan turnId is null
TimCrooker Mar 29, 2026
5f86883
Fix sidebar X button dismiss for null turnId
TimCrooker Mar 29, 2026
6bd7754
Merge branch 'main' into fix/claude-todowrite-plan-events
TimCrooker Mar 29, 2026
3bc417b
Align dismiss key computation in auto-open effect
TimCrooker Mar 29, 2026
6d703af
Show "Tasks" instead of "Plan" when no plan is active
TimCrooker Mar 30, 2026
7964460
Fix auto-open on thread switch, add parentheses, deduplicate label/de…
TimCrooker Mar 30, 2026
21b1aa4
Add explicit parentheses to planSidebarLabel ternary
TimCrooker Mar 30, 2026
b22f5db
Merge branch 'main' into fix/claude-todowrite-plan-events
TimCrooker Mar 30, 2026
d374687
Merge branch 'main' into fix/claude-todowrite-plan-events
TimCrooker Mar 30, 2026
8147fcd
Merge remote-tracking branch 'origin/main' into fix/claude-todowrite-…
TimCrooker Mar 30, 2026
80dfdbd
Merge remote-tracking branch 'fork/fix/claude-todowrite-plan-events' …
TimCrooker Mar 30, 2026
162c424
Merge branch 'main' into fix/claude-todowrite-plan-events
TimCrooker Apr 6, 2026
b30d6ba
Merge branch 'main' into fix/claude-todowrite-plan-events
juliusmarminge Apr 6, 2026
080c0cc
Merge origin/main into fix/claude-todowrite-plan-events
juliusmarminge Apr 14, 2026
af3e93a
fix(claude): guard blank todo plan steps
juliusmarminge Apr 14, 2026
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",
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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
21 changes: 13 additions & 8 deletions apps/web/src/components/ChatView.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1673,10 +1673,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 +1944,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
planSidebarDismissedForTurnRef.current = null;
}, [activeThread?.id]);

// Auto-open the plan sidebar when plan/todo steps arrive (unless user dismissed it for this turn).
useEffect(() => {
if (!activePlan) return;
if (planSidebarOpen) return;
const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
if (planSidebarDismissedForTurnRef.current === turnKey) return;
setPlanSidebarOpen(true);
}, [activePlan, planSidebarOpen, sidebarProposedPlan?.turnId]);
Comment thread
TimCrooker marked this conversation as resolved.
Outdated

useEffect(() => {
if (!composerMenuOpen) {
setComposerHighlightedItemId(null);
Expand Down Expand Up @@ -4213,10 +4220,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
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
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");
});

Comment thread
TimCrooker marked this conversation as resolved.
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
35 changes: 22 additions & 13 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,11 +491,21 @@ 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 taskLabel =
isTaskActivity &&
((typeof payload?.summary === "string" && payload.summary.length > 0 && payload.summary) ||
(typeof payload?.detail === "string" && payload.detail.length > 0 && payload.detail));
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,
Comment thread
TimCrooker marked this conversation as resolved.
activityKind: activity.kind,
};
const itemType = extractWorkLogItemType(payload);
Expand Down
Loading