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..7cae2ff302 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -462,6 +462,31 @@ 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): 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 +494,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 +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; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..14727475fb 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" && @@ -1673,10 +1674,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; } @@ -1946,6 +1945,18 @@ export default function ChatView({ threadId }: ChatViewProps) { 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(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); @@ -3902,6 +3913,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activePlan || sidebarProposedPlan || planSidebarOpen, )} interactionMode={interactionMode} + planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} traitsMenuContent={providerTraitsMenuContent} @@ -3991,11 +4003,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} @@ -4207,16 +4221,15 @@ export default function ChatView({ threadId }: ChatViewProps) { { 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} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 01341dc803..aa8ad0fb83 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -53,6 +53,7 @@ function stepStatusIcon(status: string): React.ReactNode { interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; + label?: string; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; @@ -62,6 +63,7 @@ interface PlanSidebarProps { const PlanSidebar = memo(function PlanSidebar({ activePlan, activeProposedPlan, + label = "Plan", markdownCwd, workspaceRoot, timestampFormat, @@ -126,7 +128,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} {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} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c786ffc72b..3588009c6d 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", () => { @@ -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", @@ -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"); + }); + + 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", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d6313..56947a4296 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; } @@ -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)) @@ -492,16 +491,39 @@ 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 taskDetailAsLabel = + isTaskActivity && + !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, - 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, activityKind: activity.kind, }; 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;