Skip to content

fix(claude): emit plan events for TodoWrite during input streaming#1541

Open
TimCrooker wants to merge 15 commits intopingdotgg:mainfrom
TimCrooker:fix/claude-todowrite-plan-events
Open

fix(claude): emit plan events for TodoWrite during input streaming#1541
TimCrooker wants to merge 15 commits intopingdotgg:mainfrom
TimCrooker:fix/claude-todowrite-plan-events

Conversation

@TimCrooker
Copy link
Copy Markdown

@TimCrooker TimCrooker commented Mar 29, 2026

What Changed

Claude's TodoWrite tool calls now emit turn.plan.updated events during input streaming so the plan sidebar shows task progress in real-time. The plan event fires alongside the existing tool lifecycle events, not instead of them.

Related fixes:

  • item.completed activities now forward the data field to the UI, matching item.updated
  • task.completed entries show up in the work log (previously filtered out with task.started)
  • Task activity labels use payload.summary when available instead of the generic activity summary
  • Agent/subagent tool summaries show the human-readable description instead of raw JSON
  • Plan state persists across follow-up turns so tasks don't vanish when you send a message
  • Sidebar auto-opens when task steps arrive, respects user dismiss
  • Sidebar shows "Tasks" outside plan mode, "Plan" inside it -- context-aware, no new UI components

Closes #1539

Why

TodoWrite gets classified as file_change in classifyToolItemType because the name contains "write". It renders as a generic "File change - TodoWrite: {raw JSON}" line in the work log. No turn.plan.updated event gets emitted, so the plan sidebar never activates for Claude sessions. This is core functionality that works for Codex but is completely broken for Claude.

There's an existing PR for this (#1387) that intercepts at tool result time and replaces the normal item.updated/content.delta emissions. This PR takes a different approach:

  1. Doesn't suppress existing events. The plan event fires alongside normal tool lifecycle, so downstream consumers aren't affected.
  2. Streams in real-time. Emitting during input_json_delta means the sidebar populates as Claude writes the input, not after the full round-trip.
  3. Persists across turns. deriveActivePlanState falls back to the most recent plan from any previous turn. Without this, the sidebar clears every time you send a follow-up.
  4. Context-aware labeling. Instead of forcing users into plan mode to see tasks, the sidebar dynamically labels itself "Tasks" vs "Plan" based on whether you're in plan mode. Same component, same UX, just the right label for the context.

Validated with bun fmt, bun lint, bun typecheck, and bun run test (all passing).

UI Changes

Before -- TodoWrite is invisible to the sidebar

Tasks exist but are completely invisible. There is no way to see them.

Before: no sidebar, TodoWrite tasks hidden

After -- Tasks stream in live without plan mode

Tasks are now streamed in real-time and persist between turns.

After: sidebar open showing task steps with status

After -- Button label adapts to context

When the session has tasks but no plan, the button shows "Tasks":

Composer toolbar showing Tasks button

When the session is in plan mode, the same button shows "Plan":

Screenshot 2026-03-29 at 9 11 52 PM

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Note

Medium Risk
Touches provider event emission and client-side derivation/rendering of plan/work-log state, which can affect real-time UI behavior across turns. Changes are localized but involve streaming/event ordering and sidebar auto-open logic that could introduce regressions in activity display.

Overview
Enables real-time plan/task step updates for Claude by parsing TodoWrite input_json_delta during streaming and emitting turn.plan.updated alongside existing tool lifecycle events.

Improves activity payloads and labeling: item.completed now forwards payload.data, agent/subagent tool requests prefer human-readable description/prompt, and task work-log entries now include task.completed, use payload.summary/payload.detail as the label, and render task.progress with a thinking tone.

Updates the web plan sidebar UX by persisting the latest plan across turns, auto-opening the sidebar when new steps arrive (while respecting per-turn dismissal), and dynamically labeling the sidebar/button as “Tasks” outside plan mode vs “Plan” in plan/proposed-plan contexts.

Written by Cursor Bugbot for commit 80dfdbd. This will update automatically on new commits. Configure here.

Note

Emit turn.plan.updated events for TodoWrite tool calls during Claude input streaming

  • The Claude adapter now detects TodoWrite tool calls during input streaming and emits turn.plan.updated runtime events with extracted plan steps, parsed from input.todos.
  • The plan sidebar auto-opens when plan steps for the current turn arrive, unless the user has already dismissed it, and dynamically shows 'Plan' or 'Tasks' as the label depending on context.
  • deriveActivePlanState falls back to the most recent plan from a previous turn when no plan exists for the active turn.
  • Work log entries now include task.completed items, and task progress entries show a 'thinking' tone with payload.summary/payload.detail as the label.

Macroscope summarized 80dfdbd.

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 pingdotgg#1539
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 29, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a24af4e4-d9f0-46c2-a18c-6fcbcefa7bf0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Mar 29, 2026
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.
@TimCrooker TimCrooker marked this pull request as ready for review March 29, 2026 18:21
Copilot AI review requested due to automatic review settings March 29, 2026 18:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR wires Claude’s TodoWrite tool into the existing plan sidebar by emitting turn.plan.updated events during Claude input streaming, and includes several related UX/data-flow fixes so todos and task activity render consistently in the UI.

Changes:

  • Emit turn.plan.updated during Claude input_json_delta processing for TodoWrite, without suppressing existing tool lifecycle events.
  • Persist/restore plan state across turns and auto-open the plan sidebar when plan steps arrive.
  • Improve work log rendering (include task.completed, prefer task payload summaries) and forward data for item.completed activities.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
apps/web/src/session-logic.ts Persist active plan across turns; include task.completed; adjust task entry label/tone logic.
apps/web/src/session-logic.test.ts Add coverage for plan fallback and updated work log filtering/label behavior.
apps/web/src/components/ChatView.tsx Auto-open plan sidebar when an active plan appears (respecting dismiss state).
apps/server/src/provider/Layers/ClaudeAdapter.ts Emit turn.plan.updated events for TodoWrite during streamed tool input parsing; improve tool request summaries for agent tools.
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts Forward payload.data for item.completed tool lifecycle activities.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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.
@github-actions github-actions bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Mar 29, 2026
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.
Apply the same __dismissed__ sentinel to the onClose handler on the
plan sidebar X button, matching the fix already applied to
togglePlanSidebar.
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.
@TimCrooker TimCrooker closed this Mar 29, 2026
@TimCrooker TimCrooker reopened this Mar 30, 2026
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.
TimCrooker and others added 2 commits March 29, 2026 20:23
…tail

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.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@TimCrooker
Copy link
Copy Markdown
Author

TimCrooker commented Mar 30, 2026

Quick summary of the iteration here since there are a few rounds of commits:

Started by wiring TodoWrite into the existing plan event path so the sidebar actually shows Claude's tasks. Bugbot caught a few real issues (dismiss key mismatch, error tone override, label/detail duplication, auto-open on thread switch) each one got a focused fix.

Midway through I thought this would need a dedicated task UI component separate from the plan sidebar, since the plan panel was designed around Codex's plan mode. Almost closed it to go rethink.

Then realized the simpler answer: just dynamically label the sidebar "Tasks" vs "Plan" based on context. Same component, no new UI, no plan mode required. Users get task visibility without being forced into a workflow they didn't ask for.

Current state: all Bugbot feedback resolved, tests passing, parentheses fix for the label ternary pushed. Ready for review.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Claude TodoWrite updates don't flow to the plan sidebar

2 participants