Skip to content

Commit 0275008

Browse files
committed
feat(orchestration): consolidate status display into unified plan sideba
- Auto-extract plan steps from TodoWrite tool invocations - Auto-open plan sidebar when new steps arrive for the current turn - Replace todo checklist UI with unified plan/task sidebar display - Remove showTodosInComposer setting and ComposerActiveTasksPanel - Dynamic sidebar label ("Plan" vs "Tasks") based on interaction mode - Fall back to "Task" label for blank todo content - Improve exploration card search term detection and formatting
2 parents dbc88c0 + 90e30ae commit 0275008

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
@@ -521,6 +521,34 @@ function classifyRequestType(toolName: string): CanonicalRequestType {
521521
: "dynamic_tool_call";
522522
}
523523

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

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 isCompacting = activeThread?.session?.compacting === true;
14081400
const isThreadHydrating = activeThread !== undefined && !isThreadHydrated(activeThread);
14091401
const nowIso = new Date(nowTick).toISOString();
@@ -1416,8 +1408,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
14161408
const hasComposerHeader =
14171409
isComposerApprovalState ||
14181410
pendingUserInputs.length > 0 ||
1419-
(showPlanFollowUpPrompt && activeProposedPlan !== null) ||
1420-
activeTodoItems.length > 0;
1411+
(showPlanFollowUpPrompt && activeProposedPlan !== null);
14211412
const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
14221413
const composerFooterActionLayoutKey = useMemo(() => {
14231414
if (activePendingProgress) {
@@ -2353,10 +2344,8 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
23532344
const togglePlanSidebar = useCallback(() => {
23542345
setPlanSidebarOpen((open) => {
23552346
if (open) {
2356-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
2357-
if (turnKey) {
2358-
planSidebarDismissedForTurnRef.current = turnKey;
2359-
}
2347+
planSidebarDismissedForTurnRef.current =
2348+
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
23602349
} else {
23612350
planSidebarDismissedForTurnRef.current = null;
23622351
}
@@ -2739,6 +2728,18 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
27392728
planSidebarDismissedForTurnRef.current = null;
27402729
}, [activeThread?.id]);
27412730

2731+
// Auto-open the plan sidebar when plan/todo steps arrive for the current turn.
2732+
// Don't auto-open for plans carried over from a previous turn (the user can open manually).
2733+
useEffect(() => {
2734+
if (!activePlan) return;
2735+
if (planSidebarOpen) return;
2736+
const latestTurnId = activeLatestTurn?.turnId ?? null;
2737+
if (latestTurnId && activePlan.turnId !== latestTurnId) return;
2738+
const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
2739+
if (planSidebarDismissedForTurnRef.current === turnKey) return;
2740+
setPlanSidebarOpen(true);
2741+
}, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]);
2742+
27422743
useEffect(() => {
27432744
if (!composerMenuOpen) {
27442745
setComposerHighlightedItemId(null);
@@ -4838,6 +4839,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
48384839
diffOpen={diffOpen}
48394840
hasPlan={Boolean(activePlan || sidebarProposedPlan)}
48404841
planSidebarOpen={planSidebarOpen}
4842+
planSidebarLabel={planSidebarLabel}
48414843
onRunProjectScript={runProjectScript}
48424844
onAddProjectScript={saveProjectScript}
48434845
onUpdateProjectScript={updateProjectScript}
@@ -4986,10 +4988,6 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
49864988
planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
49874989
/>
49884990
</div>
4989-
) : activeTodoItems.length > 0 ? (
4990-
<div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
4991-
<ComposerTodoListPanel items={activeTodoItems} />
4992-
</div>
49934991
) : null}
49944992
<div
49954993
className={cn(
@@ -5158,7 +5156,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
51585156
data-chat-composer-footer="true"
51595157
data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
51605158
className={cn(
5161-
"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",
5159+
"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",
51625160
isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
51635161
)}
51645162
>
@@ -5209,6 +5207,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
52095207
activePlan={activePlan !== null}
52105208
interactionMode={interactionMode}
52115209
planSidebarOpen={planSidebarOpen}
5210+
planSidebarLabel={planSidebarLabel}
52125211
traitsMenuContent={providerTraitsMenuContent}
52135212
onToggleInteractionMode={toggleInteractionMode}
52145213
onTogglePlanSidebar={togglePlanSidebar}
@@ -5339,16 +5338,15 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }:
53395338
environmentId={activeThreadEnvironmentId!}
53405339
activePlan={activePlan}
53415340
activeProposedPlan={sidebarProposedPlan}
5341+
label={planSidebarLabel}
53425342
markdownCwd={gitCwd ?? undefined}
53435343
workspaceRoot={activeWorkspaceRoot}
53445344
timestampFormat={timestampFormat}
53455345
onClose={() => {
53465346
setPlanSidebarOpen(false);
53475347
// Track that the user explicitly dismissed for this turn so auto-open won't fight them.
5348-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
5349-
if (turnKey) {
5350-
planSidebarDismissedForTurnRef.current = turnKey;
5351-
}
5348+
planSidebarDismissedForTurnRef.current =
5349+
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
53525350
}}
53535351
/>
53545352
) : 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)