From 84deaea1e60387f9476fe7405c99137a9e874163 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 12:18:38 -0400 Subject: [PATCH 1/9] 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 b479d1c28a..0fa6d951ef 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 6b50bd4fbb..c7fbb5fb78 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 fbd332354a..dcdb20ae5f 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 c786ffc72b..0568608118 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 83a95d6313..7d02cead8c 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 2/9] 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 c7fbb5fb78..7cae2ff302 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 0568608118..4b504149a8 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 7d02cead8c..91b2f0a11a 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 3/9] 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 4b504149a8..3588009c6d 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 91b2f0a11a..3252391acc 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 4/9] 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 dcdb20ae5f..1d427ea50f 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 5/9] 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 1d427ea50f..dfa330fe5d 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 6/9] 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 dfa330fe5d..fab505fde5 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 7/9] 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 fab505fde5..28e7edd3c3 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 eee6f885e9..4c66f26ec6 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 8/9] 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 28e7edd3c3..01897b1131 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 3252391acc..56947a4296 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 9/9] 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 01897b1131..14727475fb 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" &&