From 84deaea1e60387f9476fe7405c99137a9e874163 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 12:18:38 -0400 Subject: [PATCH 01/10] fix(claude): emit plan events for TodoWrite during input streaming When Claude calls TodoWrite, emit turn.plan.updated events during input streaming so the plan sidebar displays Claude's todos the same way it already works for Codex plan steps. Events are emitted alongside existing tool lifecycle events, not as a replacement. Also passes through the data field on item.completed activities to match item.updated behavior, and auto-opens the plan sidebar when plan steps arrive. Closes #1539 --- .../Layers/ProviderRuntimeIngestion.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 60 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 9 +++ apps/web/src/session-logic.test.ts | 20 ++++++- apps/web/src/session-logic.ts | 11 +++- 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b479d1c28ac..0fa6d951ef9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -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, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6b50bd4fbba..c7fbb5fb786 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -462,6 +462,32 @@ function classifyRequestType(toolName: string): CanonicalRequestType { : "dynamic_tool_call"; } +function isTodoTool(toolName: string): boolean { + const normalized = toolName.toLowerCase(); + return normalized === "todowrite" || normalized.includes("todowrite"); +} + +type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; + +function extractPlanStepsFromTodoInput(input: Record): 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 => t !== null && typeof t === "object") + .map((todo) => ({ + step: typeof todo.content === "string" ? todo.content : "Task", + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "inProgress" + : "pending", + })); +} + function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; @@ -469,6 +495,20 @@ function summarizeToolRequest(toolName: string, input: Record): 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}`; @@ -1617,6 +1657,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; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a6..dcdb20ae5fd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1946,6 +1946,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; + if (turnKey && planSidebarDismissedForTurnRef.current === turnKey) return; + setPlanSidebarOpen(true); + }, [activePlan, planSidebarOpen]); + useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c786ffc72bf..05686081181 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -563,7 +563,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", @@ -589,7 +589,23 @@ 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("filters by turn id when provided", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d63132..7d02cead8c4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -462,7 +462,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)) @@ -492,11 +492,16 @@ 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 entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: activity.summary, - tone: activity.tone === "approval" ? "info" : activity.tone, + label: taskSummary ?? activity.summary, + tone: isTaskActivity ? "thinking" : activity.tone === "approval" ? "info" : activity.tone, activityKind: activity.kind, }; const itemType = extractWorkLogItemType(payload); From fe30dce0bcb389f354900a80f8fb69455540b663 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 13:59:36 -0400 Subject: [PATCH 02/10] Persist plan sidebar across turns and simplify isTodoTool Plan state now falls back to the most recent plan from any previous turn when the current turn has no plan activity, so TodoWrite tasks stay visible across follow-up messages. Simplified redundant isTodoTool check. --- .../src/provider/Layers/ClaudeAdapter.ts | 3 +-- apps/web/src/session-logic.test.ts | 24 +++++++++++++++++++ apps/web/src/session-logic.ts | 19 +++++++-------- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c7fbb5fb786..7cae2ff302e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -463,8 +463,7 @@ function classifyRequestType(toolName: string): CanonicalRequestType { } function isTodoTool(toolName: string): boolean { - const normalized = toolName.toLowerCase(); - return normalized === "todowrite" || normalized.includes("todowrite"); + return toolName.toLowerCase().includes("todowrite"); } type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 05686081181..4b504149a86 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -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", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7d02cead8c4..91b2f0a11a7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -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; } From 689906f0b22976c2ebc443e658ddf478a4144027 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 14:30:59 -0400 Subject: [PATCH 03/10] Fix task.completed tone and label handling Only force "thinking" tone for task.progress, not task.completed, so failed tasks preserve their error tone. Also check payload.detail for task labels since task.completed stores its summary there. Add regression test for failed task.completed rendering. --- apps/web/src/session-logic.test.ts | 17 +++++++++++++++++ apps/web/src/session-logic.ts | 17 +++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4b504149a86..3588009c6d3 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -632,6 +632,23 @@ describe("deriveWorkLogEntries", () => { 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", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 91b2f0a11a7..3252391acc6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -492,15 +492,20 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo 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 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: taskSummary ?? activity.summary, - tone: isTaskActivity ? "thinking" : 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); From a7a82befddae06868ddfbfc24794aa555ec33334 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 14:41:14 -0400 Subject: [PATCH 04/10] Fix sidebar dismiss when plan turnId is null Use a sentinel string when turnId is null so the dismissed ref still gets set, preventing the auto-open effect from immediately reopening the sidebar. --- apps/web/src/components/ChatView.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dcdb20ae5fd..1d427ea50f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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; } @@ -1950,8 +1948,8 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activePlan) return; if (planSidebarOpen) return; - const turnKey = activePlan.turnId; - if (turnKey && planSidebarDismissedForTurnRef.current === turnKey) return; + const turnKey = activePlan.turnId ?? "__dismissed__"; + if (planSidebarDismissedForTurnRef.current === turnKey) return; setPlanSidebarOpen(true); }, [activePlan, planSidebarOpen]); From 5f868835b8f0525aee3c75881c99c86e4da0b3b1 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 14:46:43 -0400 Subject: [PATCH 05/10] Fix sidebar X button dismiss for null turnId Apply the same __dismissed__ sentinel to the onClose handler on the plan sidebar X button, matching the fix already applied to togglePlanSidebar. --- apps/web/src/components/ChatView.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d427ea50f3..dfa330fe5d3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4220,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} From 3bc417b4be079b63836ed02c4768ca5fe47e5d36 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 16:23:18 -0400 Subject: [PATCH 06/10] Align dismiss key computation in auto-open effect Use the same turnKey fallback chain (activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__") in both the auto-open effect and the dismiss handlers so they always match. --- apps/web/src/components/ChatView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dfa330fe5d3..fab505fde5a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1948,10 +1948,10 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activePlan) return; if (planSidebarOpen) return; - const turnKey = activePlan.turnId ?? "__dismissed__"; + const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; setPlanSidebarOpen(true); - }, [activePlan, planSidebarOpen]); + }, [activePlan, planSidebarOpen, sidebarProposedPlan?.turnId]); useEffect(() => { if (!composerMenuOpen) { From 6d703af0120f9dc83247829a9d6ecb4c6b74dd7f Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 20:08:08 -0400 Subject: [PATCH 07/10] Show "Tasks" instead of "Plan" when no plan is active Dynamically switch the sidebar label between "Plan" and "Tasks" based on context. When a proposed plan exists or the user is in plan mode, the label reads "Plan". Otherwise it reads "Tasks". Applies to the composer button, compact menu, sidebar badge, and aria labels. --- apps/web/src/components/ChatView.tsx | 9 +++++++-- apps/web/src/components/PlanSidebar.tsx | 6 ++++-- .../chat/CompactComposerControlsMenu.browser.tsx | 1 + .../src/components/chat/CompactComposerControlsMenu.tsx | 5 ++++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fab505fde5a..28e7edd3c38 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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" && @@ -3909,6 +3910,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activePlan || sidebarProposedPlan || planSidebarOpen, )} interactionMode={interactionMode} + planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} traitsMenuContent={providerTraitsMenuContent} @@ -3998,11 +4000,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` } > - Plan + {planSidebarLabel} ) : null} @@ -4214,6 +4218,7 @@ export default function ChatView({ threadId }: ChatViewProps) { - Plan + {label} {activePlan ? ( @@ -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" > diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index eee6f885e94..4c66f26ec61 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -115,6 +115,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str - {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} + {props.planSidebarOpen + ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` + : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} ) : null} From 79644607df9ad831bb8a7d438a78e4e35af009ba Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 20:23:36 -0400 Subject: [PATCH 08/10] Fix auto-open on thread switch, add parentheses, deduplicate label/detail Only auto-open the sidebar for plans from the current turn, not fallbacks from previous turns. Add explicit parentheses to the label ternary for clarity. Skip detail assignment when the detail text is already used as the label to avoid duplication in the work log. --- apps/web/src/components/ChatView.tsx | 7 +++++-- apps/web/src/session-logic.ts | 21 +++++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 28e7edd3c38..01897b11315 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1945,14 +1945,17 @@ 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). + // 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, planSidebarOpen, sidebarProposedPlan?.turnId]); + }, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]); useEffect(() => { if (!composerMenuOpen) { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 3252391acc6..56947a4296c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -492,10 +492,18 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; - const taskLabel = + const taskSummary = + isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 + ? payload.summary + : null; + const taskDetailAsLabel = isTaskActivity && - ((typeof payload?.summary === "string" && payload.summary.length > 0 && payload.summary) || - (typeof payload?.detail === "string" && payload.detail.length > 0 && payload.detail)); + !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, @@ -510,7 +518,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }; 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; From 21b1aa4d177f994834fc2f9bd17ccfd6c9d07f99 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 20:59:00 -0400 Subject: [PATCH 09/10] Add explicit parentheses to planSidebarLabel ternary Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 01897b11315..14727475fb3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -746,7 +746,7 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; + const planSidebarLabel = (sidebarProposedPlan || interactionMode === "plan") ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && From af3e93a2b5c374705802ccce937b20e9749d233d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 19:12:28 -0700 Subject: [PATCH 10/10] fix(claude): guard blank todo plan steps Co-authored-by: codex --- .../src/provider/Layers/ClaudeAdapter.test.ts | 91 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 5 +- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 8b1f1389c41..b9bf61dca2b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -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* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 98e640ec337..508325e7d41 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -474,7 +474,10 @@ function extractPlanStepsFromTodoInput(input: Record): PlanStep return todos .filter((t): t is Record => t !== null && typeof t === "object") .map((todo) => ({ - step: typeof todo.content === "string" ? todo.content : "Task", + step: + typeof todo.content === "string" && todo.content.trim().length > 0 + ? todo.content.trim() + : "Task", status: todo.status === "completed" ? "completed"