Skip to content

Commit 90e30ae

Browse files
committed
feat(plan): integrate todos into plan sidebar via turn.plan.updated even
- Remove showTodosInComposer setting and ComposerActiveTasksPanel component - Generate turn.plan.updated events when TodoWrite tool invokes with content - Support dynamic plan sidebar labels ("Plan" vs "Tasks") - Add plan fallback logic to persist steps across follow-up messages - Auto-open plan sidebar when new plan/todo steps arrive for current turn - Improve search query detection in ExplorationCard (toolsearch, query keys)
1 parent 0754390 commit 90e30ae

16 files changed

Lines changed: 298 additions & 238 deletions

apps/desktop/src/clientPersistence.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ const clientSettings: ClientSettings = {
5555
sidebarProjectSortOrder: "manual",
5656
sidebarThreadSortOrder: "created_at",
5757
timestampFormat: "24-hour",
58-
showTodosInComposer: true,
5958
turnNotificationMode: "off",
6059
turnNotificationSoundId: "default",
6160
turnNotificationCustomSounds: [],

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,97 @@ describe("ClaudeAdapterLive", () => {
10151015
);
10161016
});
10171017

1018+
it.effect("falls back to a default plan step label for blank TodoWrite content", () => {
1019+
const harness = makeHarness();
1020+
return Effect.gen(function* () {
1021+
const adapter = yield* ClaudeAdapter;
1022+
1023+
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe(
1024+
Stream.runCollect,
1025+
Effect.forkChild,
1026+
);
1027+
1028+
const session = yield* adapter.startSession({
1029+
threadId: THREAD_ID,
1030+
provider: "claudeAgent",
1031+
runtimeMode: "full-access",
1032+
});
1033+
1034+
const turn = yield* adapter.sendTurn({
1035+
threadId: session.threadId,
1036+
input: "hello",
1037+
attachments: [],
1038+
});
1039+
1040+
harness.query.emit({
1041+
type: "stream_event",
1042+
session_id: "sdk-session-todo-plan",
1043+
uuid: "stream-todo-start",
1044+
parent_tool_use_id: null,
1045+
event: {
1046+
type: "content_block_start",
1047+
index: 1,
1048+
content_block: {
1049+
type: "tool_use",
1050+
id: "tool-todo-1",
1051+
name: "TodoWrite",
1052+
input: {},
1053+
},
1054+
},
1055+
} as unknown as SDKMessage);
1056+
1057+
harness.query.emit({
1058+
type: "stream_event",
1059+
session_id: "sdk-session-todo-plan",
1060+
uuid: "stream-todo-input",
1061+
parent_tool_use_id: null,
1062+
event: {
1063+
type: "content_block_delta",
1064+
index: 1,
1065+
delta: {
1066+
type: "input_json_delta",
1067+
partial_json:
1068+
'{"todos":[{"content":" ","status":"in_progress"},{"content":"Ship it","status":"completed"}]}',
1069+
},
1070+
},
1071+
} as unknown as SDKMessage);
1072+
1073+
harness.query.emit({
1074+
type: "stream_event",
1075+
session_id: "sdk-session-todo-plan",
1076+
uuid: "stream-todo-stop",
1077+
parent_tool_use_id: null,
1078+
event: {
1079+
type: "content_block_stop",
1080+
index: 1,
1081+
},
1082+
} as unknown as SDKMessage);
1083+
1084+
harness.query.emit({
1085+
type: "result",
1086+
subtype: "success",
1087+
is_error: false,
1088+
errors: [],
1089+
session_id: "sdk-session-todo-plan",
1090+
uuid: "result-todo-plan",
1091+
} as unknown as SDKMessage);
1092+
1093+
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1094+
const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated");
1095+
assert.equal(planUpdated?.type, "turn.plan.updated");
1096+
if (planUpdated?.type === "turn.plan.updated") {
1097+
assert.equal(String(planUpdated.turnId), String(turn.turnId));
1098+
assert.deepEqual(planUpdated.payload.plan, [
1099+
{ step: "Task", status: "inProgress" },
1100+
{ step: "Ship it", status: "completed" },
1101+
]);
1102+
}
1103+
}).pipe(
1104+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
1105+
Effect.provide(harness.layer),
1106+
);
1107+
});
1108+
10181109
it.effect("classifies Claude Task tool invocations as collaboration agent work", () => {
10191110
const harness = makeHarness();
10201111
return Effect.gen(function* () {

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,34 @@ function classifyRequestType(toolName: string): CanonicalRequestType {
520520
: "dynamic_tool_call";
521521
}
522522

523+
function isTodoTool(toolName: string): boolean {
524+
return toolName.toLowerCase().includes("todowrite");
525+
}
526+
527+
type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
528+
529+
function extractPlanStepsFromTodoInput(input: Record<string, unknown>): PlanStep[] | null {
530+
// TodoWrite format: { todos: [{ content, status, activeForm? }] }
531+
const todos = input.todos;
532+
if (!Array.isArray(todos) || todos.length === 0) {
533+
return null;
534+
}
535+
return todos
536+
.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object")
537+
.map((todo) => ({
538+
step:
539+
typeof todo.content === "string" && todo.content.trim().length > 0
540+
? todo.content.trim()
541+
: "Task",
542+
status:
543+
todo.status === "completed"
544+
? "completed"
545+
: todo.status === "in_progress"
546+
? "inProgress"
547+
: "pending",
548+
}));
549+
}
550+
523551
function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
524552
const commandValue = input.command ?? input.cmd;
525553
const command = typeof commandValue === "string" ? commandValue : undefined;
@@ -1804,6 +1832,25 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
18041832
payload: message,
18051833
},
18061834
});
1835+
1836+
if (parsedInput && isTodoTool(nextTool.toolName)) {
1837+
const planSteps = extractPlanStepsFromTodoInput(parsedInput);
1838+
if (planSteps && planSteps.length > 0) {
1839+
const planStamp = yield* makeEventStamp();
1840+
yield* offerRuntimeEvent({
1841+
type: "turn.plan.updated",
1842+
eventId: planStamp.eventId,
1843+
provider: PROVIDER,
1844+
createdAt: planStamp.createdAt,
1845+
threadId: context.session.threadId,
1846+
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1847+
payload: {
1848+
plan: planSteps,
1849+
},
1850+
providerRefs: nativeProviderRefs(context),
1851+
});
1852+
}
1853+
}
18071854
}
18081855
return;
18091856
}
@@ -1883,6 +1930,23 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
18831930
payload: message,
18841931
},
18851932
});
1933+
1934+
if (isTodoTool(tool.toolName) && Object.keys(toolInput).length > 0) {
1935+
const planSteps = extractPlanStepsFromTodoInput(toolInput);
1936+
if (planSteps && planSteps.length > 0) {
1937+
const planStamp = yield* makeEventStamp();
1938+
yield* offerRuntimeEvent({
1939+
type: "turn.plan.updated",
1940+
eventId: planStamp.eventId,
1941+
provider: PROVIDER,
1942+
createdAt: planStamp.createdAt,
1943+
threadId: context.session.threadId,
1944+
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1945+
payload: { plan: planSteps },
1946+
providerRefs: nativeProviderRefs(context),
1947+
});
1948+
}
1949+
}
18861950
return;
18871951
}
18881952

apps/web/src/components/ChatView.tsx

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ import {
7575
findSidebarProposedPlan,
7676
findLatestProposedPlan,
7777
deriveWorkLogEntries,
78-
deriveTodoItems,
7978
hasActionableProposedPlan,
8079
hasToolActivityForTurn,
8180
isLatestTurnSettled,
@@ -237,7 +236,6 @@ import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions";
237236
import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
238237
import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
239238
import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
240-
import { ComposerTodoListPanel } from "./chat/ComposerActiveTasksPanel";
241239
import { SubagentDetailDrawer } from "./chat/SubagentDetailDrawer";
242240
import {
243241
getComposerProviderState,
@@ -748,7 +746,6 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
748746
(store) => store.setStickyModelSelection,
749747
);
750748
const timestampFormat = settings.timestampFormat;
751-
const showTodosInComposer = settings.showTodosInComposer;
752749
const navigate = useNavigate();
753750
const queryClient = useQueryClient();
754751
const rawSearch = useSearch({
@@ -1296,20 +1293,12 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
12961293
const phase = derivePhase(activeThread?.session ?? null);
12971294
const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
12981295
const timelineThreadActivities = timelineThread?.activities ?? EMPTY_ACTIVITIES;
1299-
const activeTodoItems = useMemo(
1300-
() =>
1301-
showTodosInComposer
1302-
? deriveTodoItems(threadActivities, activeLatestTurn?.turnId ?? undefined)
1303-
: [],
1304-
[showTodosInComposer, threadActivities, activeLatestTurn?.turnId],
1305-
);
13061296
const workLogEntries = useMemo(
13071297
() =>
13081298
deriveWorkLogEntries(timelineThreadActivities, timelineLatestTurn?.turnId ?? undefined, {
1309-
excludeTodoToolCalls: showTodosInComposer,
13101299
isSessionRunning: phase === "running",
13111300
}),
1312-
[timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer, phase],
1301+
[timelineLatestTurn?.turnId, timelineThreadActivities, phase],
13131302
);
13141303
const timelineLatestTurnHasToolActivity = useMemo(
13151304
() => hasToolActivityForTurn(timelineThreadActivities, timelineLatestTurn?.turnId),
@@ -1383,6 +1372,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
13831372
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
13841373
[activeLatestTurn?.turnId, threadActivities],
13851374
);
1375+
const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks";
13861376
const showPlanFollowUpPrompt =
13871377
pendingUserInputs.length === 0 &&
13881378
interactionMode === "plan" &&
@@ -1403,7 +1393,9 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
14031393
activePendingUserInput: activePendingUserInput?.requestId ?? null,
14041394
threadError: activeThread?.error,
14051395
});
1406-
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
1396+
const hasInFlightTurn = Boolean(activeLatestTurn && !activeLatestTurn.completedAt);
1397+
const isWorking =
1398+
phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || hasInFlightTurn;
14071399
const isThreadHydrating = activeThread !== undefined && !isThreadHydrated(activeThread);
14081400
const nowIso = new Date(nowTick).toISOString();
14091401
const activeWorkStartedAt = deriveActiveWorkStartedAt(
@@ -1415,8 +1407,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
14151407
const hasComposerHeader =
14161408
isComposerApprovalState ||
14171409
pendingUserInputs.length > 0 ||
1418-
(showPlanFollowUpPrompt && activeProposedPlan !== null) ||
1419-
activeTodoItems.length > 0;
1410+
(showPlanFollowUpPrompt && activeProposedPlan !== null);
14201411
const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
14211412
const composerFooterActionLayoutKey = useMemo(() => {
14221413
if (activePendingProgress) {
@@ -2352,10 +2343,8 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
23522343
const togglePlanSidebar = useCallback(() => {
23532344
setPlanSidebarOpen((open) => {
23542345
if (open) {
2355-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
2356-
if (turnKey) {
2357-
planSidebarDismissedForTurnRef.current = turnKey;
2358-
}
2346+
planSidebarDismissedForTurnRef.current =
2347+
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
23592348
} else {
23602349
planSidebarDismissedForTurnRef.current = null;
23612350
}
@@ -2738,6 +2727,18 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
27382727
planSidebarDismissedForTurnRef.current = null;
27392728
}, [activeThread?.id]);
27402729

2730+
// Auto-open the plan sidebar when plan/todo steps arrive for the current turn.
2731+
// Don't auto-open for plans carried over from a previous turn (the user can open manually).
2732+
useEffect(() => {
2733+
if (!activePlan) return;
2734+
if (planSidebarOpen) return;
2735+
const latestTurnId = activeLatestTurn?.turnId ?? null;
2736+
if (latestTurnId && activePlan.turnId !== latestTurnId) return;
2737+
const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
2738+
if (planSidebarDismissedForTurnRef.current === turnKey) return;
2739+
setPlanSidebarOpen(true);
2740+
}, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]);
2741+
27412742
useEffect(() => {
27422743
if (!composerMenuOpen) {
27432744
setComposerHighlightedItemId(null);
@@ -4837,6 +4838,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
48374838
diffOpen={diffOpen}
48384839
hasPlan={Boolean(activePlan || sidebarProposedPlan)}
48394840
planSidebarOpen={planSidebarOpen}
4841+
planSidebarLabel={planSidebarLabel}
48404842
onRunProjectScript={runProjectScript}
48414843
onAddProjectScript={saveProjectScript}
48424844
onUpdateProjectScript={updateProjectScript}
@@ -4984,10 +4986,6 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
49844986
planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
49854987
/>
49864988
</div>
4987-
) : activeTodoItems.length > 0 ? (
4988-
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
4989-
<ComposerTodoListPanel items={activeTodoItems} />
4990-
</div>
49914989
) : null}
49924990
<div
49934991
className={cn(
@@ -5156,7 +5154,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
51565154
data-chat-composer-footer="true"
51575155
data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
51585156
className={cn(
5159-
"flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-hidden px-2.5 pb-2.5 sm:px-3 sm:pb-3",
5157+
"flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3",
51605158
isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
51615159
)}
51625160
>
@@ -5207,6 +5205,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
52075205
activePlan={activePlan !== null}
52085206
interactionMode={interactionMode}
52095207
planSidebarOpen={planSidebarOpen}
5208+
planSidebarLabel={planSidebarLabel}
52105209
traitsMenuContent={providerTraitsMenuContent}
52115210
onToggleInteractionMode={toggleInteractionMode}
52125211
onTogglePlanSidebar={togglePlanSidebar}
@@ -5337,16 +5336,15 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
53375336
environmentId={activeThreadEnvironmentId!}
53385337
activePlan={activePlan}
53395338
activeProposedPlan={sidebarProposedPlan}
5339+
label={planSidebarLabel}
53405340
markdownCwd={gitCwd ?? undefined}
53415341
workspaceRoot={activeWorkspaceRoot}
53425342
timestampFormat={timestampFormat}
53435343
onClose={() => {
53445344
setPlanSidebarOpen(false);
53455345
// Track that the user explicitly dismissed for this turn so auto-open won't fight them.
5346-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
5347-
if (turnKey) {
5348-
planSidebarDismissedForTurnRef.current = turnKey;
5349-
}
5346+
planSidebarDismissedForTurnRef.current =
5347+
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
53505348
}}
53515349
/>
53525350
) : null}

apps/web/src/components/PlanSidebar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode {
5454
interface PlanSidebarProps {
5555
activePlan: ActivePlanState | null;
5656
activeProposedPlan: LatestProposedPlanState | null;
57+
label?: string;
5758
environmentId: EnvironmentId;
5859
markdownCwd: string | undefined;
5960
workspaceRoot: string | undefined;
@@ -64,6 +65,7 @@ interface PlanSidebarProps {
6465
const PlanSidebar = memo(function PlanSidebar({
6566
activePlan,
6667
activeProposedPlan,
68+
label = "Plan",
6769
environmentId,
6870
markdownCwd,
6971
workspaceRoot,
@@ -129,7 +131,7 @@ const PlanSidebar = memo(function PlanSidebar({
129131
variant="secondary"
130132
className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase"
131133
>
132-
Plan
134+
{label}
133135
</Badge>
134136
{activePlan ? (
135137
<span className="text-[11px] text-muted-foreground/60">
@@ -170,7 +172,7 @@ const PlanSidebar = memo(function PlanSidebar({
170172
size="icon-xs"
171173
variant="ghost"
172174
onClick={onClose}
173-
aria-label="Close plan sidebar"
175+
aria-label={`Close ${label.toLowerCase()} sidebar`}
174176
className="text-muted-foreground/50 hover:text-foreground/70"
175177
>
176178
<PanelRightCloseIcon className="size-3.5" />

0 commit comments

Comments
 (0)