Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
91 changes: 91 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,97 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("falls back to a default plan step label for blank TodoWrite content", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe(
Stream.runCollect,
Effect.forkChild,
);

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
runtimeMode: "full-access",
});

const turn = yield* adapter.sendTurn({
threadId: session.threadId,
input: "hello",
attachments: [],
});

harness.query.emit({
type: "stream_event",
session_id: "sdk-session-todo-plan",
uuid: "stream-todo-start",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 1,
content_block: {
type: "tool_use",
id: "tool-todo-1",
name: "TodoWrite",
input: {},
},
},
} as unknown as SDKMessage);

harness.query.emit({
type: "stream_event",
session_id: "sdk-session-todo-plan",
uuid: "stream-todo-input",
parent_tool_use_id: null,
event: {
type: "content_block_delta",
index: 1,
delta: {
type: "input_json_delta",
partial_json:
'{"todos":[{"content":" ","status":"in_progress"},{"content":"Ship it","status":"completed"}]}',
},
},
} as unknown as SDKMessage);

harness.query.emit({
type: "stream_event",
session_id: "sdk-session-todo-plan",
uuid: "stream-todo-stop",
parent_tool_use_id: null,
event: {
type: "content_block_stop",
index: 1,
},
} as unknown as SDKMessage);

harness.query.emit({
type: "result",
subtype: "success",
is_error: false,
errors: [],
session_id: "sdk-session-todo-plan",
uuid: "result-todo-plan",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated");
assert.equal(planUpdated?.type, "turn.plan.updated");
if (planUpdated?.type === "turn.plan.updated") {
assert.equal(String(planUpdated.turnId), String(turn.turnId));
assert.deepEqual(planUpdated.payload.plan, [
{ step: "Task", status: "inProgress" },
{ step: "Ship it", status: "completed" },
]);
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("classifies Claude Task tool invocations as collaboration agent work", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,55 @@ 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.trim().length > 0
? todo.content.trim()
: "Task",
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 @@ -1616,6 +1658,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
27 changes: 19 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,7 @@
() => 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 @@ -1536,7 +1537,7 @@
);

const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();

Check warning on line 1540 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const scheduleComposerFocus = useCallback(() => {
window.requestAnimationFrame(() => {
Expand All @@ -1544,7 +1545,7 @@
});
}, [focusComposer]);
const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
composerRef.current?.addTerminalContext(selection);

Check warning on line 1548 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const setTerminalOpen = useCallback(
(open: boolean) => {
Expand Down Expand Up @@ -1888,10 +1889,8 @@
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 @@ -1989,6 +1988,18 @@
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(() => {
setIsRevertingCheckpoint(false);
}, [activeThread?.id]);
Expand Down Expand Up @@ -2726,7 +2737,7 @@
};
});
promptRef.current = "";
composerRef.current?.resetCursorState({ cursor: 0 });

Check warning on line 2740 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
},
[activePendingProgress?.activeQuestion, activePendingUserInput],
);
Expand All @@ -2753,7 +2764,7 @@
),
},
}));
const snapshot = composerRef.current?.readSnapshot();

Check warning on line 2767 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (
snapshot?.value !== value ||
snapshot.cursor !== nextCursor ||
Expand Down Expand Up @@ -2816,7 +2827,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 2830 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -2951,7 +2962,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 2965 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3298,6 +3309,7 @@
activeProposedPlan={activeProposedPlan}
activePlan={activePlan as { turnId?: TurnId } | null}
sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
planSidebarLabel={planSidebarLabel}
planSidebarOpen={planSidebarOpen}
runtimeMode={runtimeMode}
interactionMode={interactionMode}
Expand Down Expand Up @@ -3386,17 +3398,16 @@
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
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 @@ -54,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode {
interface PlanSidebarProps {
activePlan: ActivePlanState | null;
activeProposedPlan: LatestProposedPlanState | null;
label?: string;
environmentId: EnvironmentId;
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
Expand All @@ -64,6 +65,7 @@ interface PlanSidebarProps {
const PlanSidebar = memo(function PlanSidebar({
activePlan,
activeProposedPlan,
label = "Plan",
environmentId,
markdownCwd,
workspaceRoot,
Expand Down Expand Up @@ -129,7 +131,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 @@ -170,7 +172,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
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
interactionMode: ProviderInteractionMode;
runtimeMode: RuntimeMode;
showPlanToggle: boolean;
planSidebarLabel: string;
planSidebarOpen: boolean;
onToggleInteractionMode: () => void;
onRuntimeModeChange: (mode: RuntimeMode) => void;
Expand Down Expand Up @@ -243,10 +244,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
size="sm"
type="button"
onClick={props.onTogglePlanSidebar}
title={props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
title={
props.planSidebarOpen
? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
: `Show ${props.planSidebarLabel.toLowerCase()} sidebar`
}
>
<ListTodoIcon />
<span className="sr-only sm:not-sr-only">Plan</span>
<span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>
</Button>
</>
) : null}
Expand Down Expand Up @@ -380,6 +385,7 @@ export interface ChatComposerProps {
activeProposedPlan: Thread["proposedPlans"][number] | null;
activePlan: { turnId?: TurnId } | null;
sidebarProposedPlan: { turnId?: TurnId } | null;
planSidebarLabel: string;
planSidebarOpen: boolean;

// Mode
Expand Down Expand Up @@ -474,6 +480,7 @@ export const ChatComposer = memo(
activeProposedPlan,
activePlan,
sidebarProposedPlan,
planSidebarLabel,
planSidebarOpen,
runtimeMode,
interactionMode,
Expand Down Expand Up @@ -1923,6 +1930,7 @@ export const ChatComposer = memo(
<CompactComposerControlsMenu
activePlan={showPlanSidebarToggle}
interactionMode={interactionMode}
planSidebarLabel={planSidebarLabel}
planSidebarOpen={planSidebarOpen}
runtimeMode={runtimeMode}
traitsMenuContent={providerTraitsMenuContent}
Expand All @@ -1945,6 +1953,7 @@ export const ChatComposer = memo(
interactionMode={interactionMode}
runtimeMode={runtimeMode}
showPlanToggle={showPlanSidebarToggle}
planSidebarLabel={planSidebarLabel}
planSidebarOpen={planSidebarOpen}
onToggleInteractionMode={toggleInteractionMode}
onRuntimeModeChange={handleRuntimeModeChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,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 @@ -72,7 +73,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
Loading
Loading