| author | |
| committer | |
| log | 0d280262f6c9dcaf529376b2007dc7447f86fa3c |
| tree | 2bfa577e6ad1302c1a2d3d706b0203192075655a |
| parent | c9b07d665d11ff19fdb655be56a014cd60d9166b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>10 files changed, 288 insertions(+), 29 deletions(-)
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1| ... | @@ -465,6 +465,7 @@ function runtimeEventToActivities( | ... | @@ -465,6 +465,7 @@ function runtimeEventToActivities( |
| 465 | payload: { | 465 | payload: { |
| 466 | itemType: event.payload.itemType, | 466 | itemType: event.payload.itemType, |
| 467 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), | 467 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 468 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | ||
| 468 | }, | 469 | }, |
| 469 | turnId: toTurnId(event.turnId) ?? null, | 470 | turnId: toTurnId(event.turnId) ?? null, |
| 470 | ...maybeSequence, | 471 | ...maybeSequence, |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+91| ... | @@ -966,6 +966,97 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -966,6 +966,97 @@ describe("ClaudeAdapterLive", () => { |
| 966 | ); | 966 | ); |
| 967 | }); | 967 | }); |
| 968 | 968 | ||
| 969 | it.effect("falls back to a default plan step label for blank TodoWrite content", () => { | ||
| 970 | const harness = makeHarness(); | ||
| 971 | return Effect.gen(function* () { | ||
| 972 | const adapter = yield* ClaudeAdapter; | ||
| 973 | |||
| 974 | const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( | ||
| 975 | Stream.runCollect, | ||
| 976 | Effect.forkChild, | ||
| 977 | ); | ||
| 978 | |||
| 979 | const session = yield* adapter.startSession({ | ||
| 980 | threadId: THREAD_ID, | ||
| 981 | provider: "claudeAgent", | ||
| 982 | runtimeMode: "full-access", | ||
| 983 | }); | ||
| 984 | |||
| 985 | const turn = yield* adapter.sendTurn({ | ||
| 986 | threadId: session.threadId, | ||
| 987 | input: "hello", | ||
| 988 | attachments: [], | ||
| 989 | }); | ||
| 990 | |||
| 991 | harness.query.emit({ | ||
| 992 | type: "stream_event", | ||
| 993 | session_id: "sdk-session-todo-plan", | ||
| 994 | uuid: "stream-todo-start", | ||
| 995 | parent_tool_use_id: null, | ||
| 996 | event: { | ||
| 997 | type: "content_block_start", | ||
| 998 | index: 1, | ||
| 999 | content_block: { | ||
| 1000 | type: "tool_use", | ||
| 1001 | id: "tool-todo-1", | ||
| 1002 | name: "TodoWrite", | ||
| 1003 | input: {}, | ||
| 1004 | }, | ||
| 1005 | }, | ||
| 1006 | } as unknown as SDKMessage); | ||
| 1007 | |||
| 1008 | harness.query.emit({ | ||
| 1009 | type: "stream_event", | ||
| 1010 | session_id: "sdk-session-todo-plan", | ||
| 1011 | uuid: "stream-todo-input", | ||
| 1012 | parent_tool_use_id: null, | ||
| 1013 | event: { | ||
| 1014 | type: "content_block_delta", | ||
| 1015 | index: 1, | ||
| 1016 | delta: { | ||
| 1017 | type: "input_json_delta", | ||
| 1018 | partial_json: | ||
| 1019 | '{"todos":[{"content":" ","status":"in_progress"},{"content":"Ship it","status":"completed"}]}', | ||
| 1020 | }, | ||
| 1021 | }, | ||
| 1022 | } as unknown as SDKMessage); | ||
| 1023 | |||
| 1024 | harness.query.emit({ | ||
| 1025 | type: "stream_event", | ||
| 1026 | session_id: "sdk-session-todo-plan", | ||
| 1027 | uuid: "stream-todo-stop", | ||
| 1028 | parent_tool_use_id: null, | ||
| 1029 | event: { | ||
| 1030 | type: "content_block_stop", | ||
| 1031 | index: 1, | ||
| 1032 | }, | ||
| 1033 | } as unknown as SDKMessage); | ||
| 1034 | |||
| 1035 | harness.query.emit({ | ||
| 1036 | type: "result", | ||
| 1037 | subtype: "success", | ||
| 1038 | is_error: false, | ||
| 1039 | errors: [], | ||
| 1040 | session_id: "sdk-session-todo-plan", | ||
| 1041 | uuid: "result-todo-plan", | ||
| 1042 | } as unknown as SDKMessage); | ||
| 1043 | |||
| 1044 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); | ||
| 1045 | const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated"); | ||
| 1046 | assert.equal(planUpdated?.type, "turn.plan.updated"); | ||
| 1047 | if (planUpdated?.type === "turn.plan.updated") { | ||
| 1048 | assert.equal(String(planUpdated.turnId), String(turn.turnId)); | ||
| 1049 | assert.deepEqual(planUpdated.payload.plan, [ | ||
| 1050 | { step: "Task", status: "inProgress" }, | ||
| 1051 | { step: "Ship it", status: "completed" }, | ||
| 1052 | ]); | ||
| 1053 | } | ||
| 1054 | }).pipe( | ||
| 1055 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 1056 | Effect.provide(harness.layer), | ||
| 1057 | ); | ||
| 1058 | }); | ||
| 1059 | |||
| 969 | it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { | 1060 | it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { |
| 970 | const harness = makeHarness(); | 1061 | const harness = makeHarness(); |
| 971 | return Effect.gen(function* () { | 1062 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+62| ... | @@ -459,6 +459,34 @@ function classifyRequestType(toolName: string): CanonicalRequestType { | ... | @@ -459,6 +459,34 @@ function classifyRequestType(toolName: string): CanonicalRequestType { |
| 459 | : "dynamic_tool_call"; | 459 | : "dynamic_tool_call"; |
| 460 | } | 460 | } |
| 461 | 461 | ||
| 462 | function isTodoTool(toolName: string): boolean { | ||
| 463 | return toolName.toLowerCase().includes("todowrite"); | ||
| 464 | } | ||
| 465 | |||
| 466 | type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; | ||
| 467 | |||
| 468 | function extractPlanStepsFromTodoInput(input: Record<string, unknown>): PlanStep[] | null { | ||
| 469 | // TodoWrite format: { todos: [{ content, status, activeForm? }] } | ||
| 470 | const todos = input.todos; | ||
| 471 | if (!Array.isArray(todos) || todos.length === 0) { | ||
| 472 | return null; | ||
| 473 | } | ||
| 474 | return todos | ||
| 475 | .filter((t): t is Record<string, unknown> => t !== null && typeof t === "object") | ||
| 476 | .map((todo) => ({ | ||
| 477 | step: | ||
| 478 | typeof todo.content === "string" && todo.content.trim().length > 0 | ||
| 479 | ? todo.content.trim() | ||
| 480 | : "Task", | ||
| 481 | status: | ||
| 482 | todo.status === "completed" | ||
| 483 | ? "completed" | ||
| 484 | : todo.status === "in_progress" | ||
| 485 | ? "inProgress" | ||
| 486 | : "pending", | ||
| 487 | })); | ||
| 488 | } | ||
| 489 | |||
| 462 | function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string { | 490 | function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string { |
| 463 | const commandValue = input.command ?? input.cmd; | 491 | const commandValue = input.command ?? input.cmd; |
| 464 | const command = typeof commandValue === "string" ? commandValue : undefined; | 492 | const command = typeof commandValue === "string" ? commandValue : undefined; |
| ... | @@ -466,6 +494,20 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>): | ... | @@ -466,6 +494,20 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>): |
| 466 | return `${toolName}: ${command.trim().slice(0, 400)}`; | 494 | return `${toolName}: ${command.trim().slice(0, 400)}`; |
| 467 | } | 495 | } |
| 468 | 496 | ||
| 497 | // For agent/subagent tools, prefer human-readable description or prompt over raw JSON | ||
| 498 | const itemType = classifyToolItemType(toolName); | ||
| 499 | if (itemType === "collab_agent_tool_call") { | ||
| 500 | const description = | ||
| 501 | typeof input.description === "string" ? input.description.trim() : undefined; | ||
| 502 | const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; | ||
| 503 | const subagentType = | ||
| 504 | typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; | ||
| 505 | const label = description || (prompt ? prompt.slice(0, 200) : undefined); | ||
| 506 | if (label) { | ||
| 507 | return subagentType ? `${subagentType}: ${label}` : label; | ||
| 508 | } | ||
| 509 | } | ||
| 510 | |||
| 469 | const serialized = JSON.stringify(input); | 511 | const serialized = JSON.stringify(input); |
| 470 | if (serialized.length <= 400) { | 512 | if (serialized.length <= 400) { |
| 471 | return `${toolName}: ${serialized}`; | 513 | return `${toolName}: ${serialized}`; |
| ... | @@ -1616,6 +1658,26 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1616,6 +1658,26 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1616 | payload: message, | 1658 | payload: message, |
| 1617 | }, | 1659 | }, |
| 1618 | }); | 1660 | }); |
| 1661 | |||
| 1662 | // Emit plan update when TodoWrite input is parsed | ||
| 1663 | if (parsedInput && isTodoTool(nextTool.toolName)) { | ||
| 1664 | const planSteps = extractPlanStepsFromTodoInput(parsedInput); | ||
| 1665 | if (planSteps && planSteps.length > 0) { | ||
| 1666 | const planStamp = yield* makeEventStamp(); | ||
| 1667 | yield* offerRuntimeEvent({ | ||
| 1668 | type: "turn.plan.updated", | ||
| 1669 | eventId: planStamp.eventId, | ||
| 1670 | provider: PROVIDER, | ||
| 1671 | createdAt: planStamp.createdAt, | ||
| 1672 | threadId: context.session.threadId, | ||
| 1673 | ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), | ||
| 1674 | payload: { | ||
| 1675 | plan: planSteps, | ||
| 1676 | }, | ||
| 1677 | providerRefs: nativeProviderRefs(context), | ||
| 1678 | }); | ||
| 1679 | } | ||
| 1680 | } | ||
| 1619 | } | 1681 | } |
| 1620 | return; | 1682 | return; |
| 1621 | } | 1683 | } |
apps/web/src/components/ChatView.tsx+19-8| ... | @@ -1107,6 +1107,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1107,6 +1107,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1107 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), | 1107 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| 1108 | [activeLatestTurn?.turnId, threadActivities], | 1108 | [activeLatestTurn?.turnId, threadActivities], |
| 1109 | ); | 1109 | ); |
| 1110 | const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; | ||
| 1110 | const showPlanFollowUpPrompt = | 1111 | const showPlanFollowUpPrompt = |
| 1111 | pendingUserInputs.length === 0 && | 1112 | pendingUserInputs.length === 0 && |
| 1112 | interactionMode === "plan" && | 1113 | interactionMode === "plan" && |
| ... | @@ -1888,10 +1889,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1888,10 +1889,8 @@ export default function ChatView(props: ChatViewProps) { |
| 1888 | const togglePlanSidebar = useCallback(() => { | 1889 | const togglePlanSidebar = useCallback(() => { |
| 1889 | setPlanSidebarOpen((open) => { | 1890 | setPlanSidebarOpen((open) => { |
| 1890 | if (open) { | 1891 | if (open) { |
| 1891 | const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; | 1892 | planSidebarDismissedForTurnRef.current = |
| 1892 | if (turnKey) { | 1893 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; |
| 1893 | planSidebarDismissedForTurnRef.current = turnKey; | ||
| 1894 | } | ||
| 1895 | } else { | 1894 | } else { |
| 1896 | planSidebarDismissedForTurnRef.current = null; | 1895 | planSidebarDismissedForTurnRef.current = null; |
| 1897 | } | 1896 | } |
| ... | @@ -1989,6 +1988,18 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1989,6 +1988,18 @@ export default function ChatView(props: ChatViewProps) { |
| 1989 | planSidebarDismissedForTurnRef.current = null; | 1988 | planSidebarDismissedForTurnRef.current = null; |
| 1990 | }, [activeThread?.id]); | 1989 | }, [activeThread?.id]); |
| 1991 | 1990 | ||
| 1991 | // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. | ||
| 1992 | // Don't auto-open for plans carried over from a previous turn (the user can open manually). | ||
| 1993 | useEffect(() => { | ||
| 1994 | if (!activePlan) return; | ||
| 1995 | if (planSidebarOpen) return; | ||
| 1996 | const latestTurnId = activeLatestTurn?.turnId ?? null; | ||
| 1997 | if (latestTurnId && activePlan.turnId !== latestTurnId) return; | ||
| 1998 | const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; | ||
| 1999 | if (planSidebarDismissedForTurnRef.current === turnKey) return; | ||
| 2000 | setPlanSidebarOpen(true); | ||
| 2001 | }, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]); | ||
| 2002 | |||
| 1992 | useEffect(() => { | 2003 | useEffect(() => { |
| 1993 | setIsRevertingCheckpoint(false); | 2004 | setIsRevertingCheckpoint(false); |
| 1994 | }, [activeThread?.id]); | 2005 | }, [activeThread?.id]); |
| ... | @@ -3298,6 +3309,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3298,6 +3309,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3298 | activeProposedPlan={activeProposedPlan} | 3309 | activeProposedPlan={activeProposedPlan} |
| 3299 | activePlan={activePlan as { turnId?: TurnId } | null} | 3310 | activePlan={activePlan as { turnId?: TurnId } | null} |
| 3300 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | 3311 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} |
| 3312 | planSidebarLabel={planSidebarLabel} | ||
| 3301 | planSidebarOpen={planSidebarOpen} | 3313 | planSidebarOpen={planSidebarOpen} |
| 3302 | runtimeMode={runtimeMode} | 3314 | runtimeMode={runtimeMode} |
| 3303 | interactionMode={interactionMode} | 3315 | interactionMode={interactionMode} |
| ... | @@ -3386,6 +3398,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3386,6 +3398,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3386 | <PlanSidebar | 3398 | <PlanSidebar |
| 3387 | activePlan={activePlan} | 3399 | activePlan={activePlan} |
| 3388 | activeProposedPlan={sidebarProposedPlan} | 3400 | activeProposedPlan={sidebarProposedPlan} |
| 3401 | label={planSidebarLabel} | ||
| 3389 | environmentId={environmentId} | 3402 | environmentId={environmentId} |
| 3390 | markdownCwd={gitCwd ?? undefined} | 3403 | markdownCwd={gitCwd ?? undefined} |
| 3391 | workspaceRoot={activeWorkspaceRoot} | 3404 | workspaceRoot={activeWorkspaceRoot} |
| ... | @@ -3393,10 +3406,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3393,10 +3406,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3393 | onClose={() => { | 3406 | onClose={() => { |
| 3394 | setPlanSidebarOpen(false); | 3407 | setPlanSidebarOpen(false); |
| 3395 | // Track that the user explicitly dismissed for this turn so auto-open won't fight them. | 3408 | // Track that the user explicitly dismissed for this turn so auto-open won't fight them. |
| 3396 | const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; | 3409 | planSidebarDismissedForTurnRef.current = |
| 3397 | if (turnKey) { | 3410 | activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; |
| 3398 | planSidebarDismissedForTurnRef.current = turnKey; | ||
| 3399 | } | ||
| 3400 | }} | 3411 | }} |
| 3401 | /> | 3412 | /> |
| 3402 | ) : null} | 3413 | ) : null} |
apps/web/src/components/PlanSidebar.tsx+4-2| ... | @@ -54,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { | ... | @@ -54,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { |
| 54 | interface PlanSidebarProps { | 54 | interface PlanSidebarProps { |
| 55 | activePlan: ActivePlanState | null; | 55 | activePlan: ActivePlanState | null; |
| 56 | activeProposedPlan: LatestProposedPlanState | null; | 56 | activeProposedPlan: LatestProposedPlanState | null; |
| 57 | label?: string; | ||
| 57 | environmentId: EnvironmentId; | 58 | environmentId: EnvironmentId; |
| 58 | markdownCwd: string | undefined; | 59 | markdownCwd: string | undefined; |
| 59 | workspaceRoot: string | undefined; | 60 | workspaceRoot: string | undefined; |
| ... | @@ -64,6 +65,7 @@ interface PlanSidebarProps { | ... | @@ -64,6 +65,7 @@ interface PlanSidebarProps { |
| 64 | const PlanSidebar = memo(function PlanSidebar({ | 65 | const PlanSidebar = memo(function PlanSidebar({ |
| 65 | activePlan, | 66 | activePlan, |
| 66 | activeProposedPlan, | 67 | activeProposedPlan, |
| 68 | label = "Plan", | ||
| 67 | environmentId, | 69 | environmentId, |
| 68 | markdownCwd, | 70 | markdownCwd, |
| 69 | workspaceRoot, | 71 | workspaceRoot, |
| ... | @@ -129,7 +131,7 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -129,7 +131,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 129 | variant="secondary" | 131 | variant="secondary" |
| 130 | className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase" | 132 | className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase" |
| 131 | > | 133 | > |
| 132 | Plan | 134 | {label} |
| 133 | </Badge> | 135 | </Badge> |
| 134 | {activePlan ? ( | 136 | {activePlan ? ( |
| 135 | <span className="text-[11px] text-muted-foreground/60"> | 137 | <span className="text-[11px] text-muted-foreground/60"> |
| ... | @@ -170,7 +172,7 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -170,7 +172,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 170 | size="icon-xs" | 172 | size="icon-xs" |
| 171 | variant="ghost" | 173 | variant="ghost" |
| 172 | onClick={onClose} | 174 | onClick={onClose} |
| 173 | aria-label="Close plan sidebar" | 175 | aria-label={`Close ${label.toLowerCase()} sidebar`} |
| 174 | className="text-muted-foreground/50 hover:text-foreground/70" | 176 | className="text-muted-foreground/50 hover:text-foreground/70" |
| 175 | > | 177 | > |
| 176 | <PanelRightCloseIcon className="size-3.5" /> | 178 | <PanelRightCloseIcon className="size-3.5" /> |
apps/web/src/components/chat/ChatComposer.tsx+11-2| ... | @@ -162,6 +162,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop | ... | @@ -162,6 +162,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 162 | interactionMode: ProviderInteractionMode; | 162 | interactionMode: ProviderInteractionMode; |
| 163 | runtimeMode: RuntimeMode; | 163 | runtimeMode: RuntimeMode; |
| 164 | showPlanToggle: boolean; | 164 | showPlanToggle: boolean; |
| 165 | planSidebarLabel: string; | ||
| 165 | planSidebarOpen: boolean; | 166 | planSidebarOpen: boolean; |
| 166 | onToggleInteractionMode: () => void; | 167 | onToggleInteractionMode: () => void; |
| 167 | onRuntimeModeChange: (mode: RuntimeMode) => void; | 168 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| ... | @@ -243,10 +244,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop | ... | @@ -243,10 +244,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 243 | size="sm" | 244 | size="sm" |
| 244 | type="button" | 245 | type="button" |
| 245 | onClick={props.onTogglePlanSidebar} | 246 | onClick={props.onTogglePlanSidebar} |
| 246 | title={props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} | 247 | title={ |
| 248 | props.planSidebarOpen | ||
| 249 | ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` | ||
| 250 | : `Show ${props.planSidebarLabel.toLowerCase()} sidebar` | ||
| 251 | } | ||
| 247 | > | 252 | > |
| 248 | <ListTodoIcon /> | 253 | <ListTodoIcon /> |
| 249 | <span className="sr-only sm:not-sr-only">Plan</span> | 254 | <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> |
| 250 | </Button> | 255 | </Button> |
| 251 | </> | 256 | </> |
| 252 | ) : null} | 257 | ) : null} |
| ... | @@ -380,6 +385,7 @@ export interface ChatComposerProps { | ... | @@ -380,6 +385,7 @@ export interface ChatComposerProps { |
| 380 | activeProposedPlan: Thread["proposedPlans"][number] | null; | 385 | activeProposedPlan: Thread["proposedPlans"][number] | null; |
| 381 | activePlan: { turnId?: TurnId } | null; | 386 | activePlan: { turnId?: TurnId } | null; |
| 382 | sidebarProposedPlan: { turnId?: TurnId } | null; | 387 | sidebarProposedPlan: { turnId?: TurnId } | null; |
| 388 | planSidebarLabel: string; | ||
| 383 | planSidebarOpen: boolean; | 389 | planSidebarOpen: boolean; |
| 384 | 390 | ||
| 385 | // Mode | 391 | // Mode |
| ... | @@ -474,6 +480,7 @@ export const ChatComposer = memo( | ... | @@ -474,6 +480,7 @@ export const ChatComposer = memo( |
| 474 | activeProposedPlan, | 480 | activeProposedPlan, |
| 475 | activePlan, | 481 | activePlan, |
| 476 | sidebarProposedPlan, | 482 | sidebarProposedPlan, |
| 483 | planSidebarLabel, | ||
| 477 | planSidebarOpen, | 484 | planSidebarOpen, |
| 478 | runtimeMode, | 485 | runtimeMode, |
| 479 | interactionMode, | 486 | interactionMode, |
| ... | @@ -1923,6 +1930,7 @@ export const ChatComposer = memo( | ... | @@ -1923,6 +1930,7 @@ export const ChatComposer = memo( |
| 1923 | <CompactComposerControlsMenu | 1930 | <CompactComposerControlsMenu |
| 1924 | activePlan={showPlanSidebarToggle} | 1931 | activePlan={showPlanSidebarToggle} |
| 1925 | interactionMode={interactionMode} | 1932 | interactionMode={interactionMode} |
| 1933 | planSidebarLabel={planSidebarLabel} | ||
| 1926 | planSidebarOpen={planSidebarOpen} | 1934 | planSidebarOpen={planSidebarOpen} |
| 1927 | runtimeMode={runtimeMode} | 1935 | runtimeMode={runtimeMode} |
| 1928 | traitsMenuContent={providerTraitsMenuContent} | 1936 | traitsMenuContent={providerTraitsMenuContent} |
| ... | @@ -1945,6 +1953,7 @@ export const ChatComposer = memo( | ... | @@ -1945,6 +1953,7 @@ export const ChatComposer = memo( |
| 1945 | interactionMode={interactionMode} | 1953 | interactionMode={interactionMode} |
| 1946 | runtimeMode={runtimeMode} | 1954 | runtimeMode={runtimeMode} |
| 1947 | showPlanToggle={showPlanSidebarToggle} | 1955 | showPlanToggle={showPlanSidebarToggle} |
| 1956 | planSidebarLabel={planSidebarLabel} | ||
| 1948 | planSidebarOpen={planSidebarOpen} | 1957 | planSidebarOpen={planSidebarOpen} |
| 1949 | onToggleInteractionMode={toggleInteractionMode} | 1958 | onToggleInteractionMode={toggleInteractionMode} |
| 1950 | onRuntimeModeChange={handleRuntimeModeChange} | 1959 | onRuntimeModeChange={handleRuntimeModeChange} |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+1| ... | @@ -123,6 +123,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str | ... | @@ -123,6 +123,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 123 | <CompactComposerControlsMenu | 123 | <CompactComposerControlsMenu |
| 124 | activePlan={false} | 124 | activePlan={false} |
| 125 | interactionMode="default" | 125 | interactionMode="default" |
| 126 | planSidebarLabel="Plan" | ||
| 126 | planSidebarOpen={false} | 127 | planSidebarOpen={false} |
| 127 | runtimeMode="approval-required" | 128 | runtimeMode="approval-required" |
| 128 | traitsMenuContent={ | 129 | traitsMenuContent={ |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+4-1| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { | 15 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { |
| 16 | activePlan: boolean; | 16 | activePlan: boolean; |
| 17 | interactionMode: ProviderInteractionMode; | 17 | interactionMode: ProviderInteractionMode; |
| 18 | planSidebarLabel: string; | ||
| 18 | planSidebarOpen: boolean; | 19 | planSidebarOpen: boolean; |
| 19 | runtimeMode: RuntimeMode; | 20 | runtimeMode: RuntimeMode; |
| 20 | traitsMenuContent?: ReactNode; | 21 | traitsMenuContent?: ReactNode; |
| ... | @@ -72,7 +73,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls | ... | @@ -72,7 +73,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 72 | <MenuDivider /> | 73 | <MenuDivider /> |
| 73 | <MenuItem onClick={props.onTogglePlanSidebar}> | 74 | <MenuItem onClick={props.onTogglePlanSidebar}> |
| 74 | <ListTodoIcon className="size-4 shrink-0" /> | 75 | <ListTodoIcon className="size-4 shrink-0" /> |
| 75 | {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} | 76 | {props.planSidebarOpen |
| 77 | ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` | ||
| 78 | : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} | ||
| 76 | </MenuItem> | 79 | </MenuItem> |
| 77 | </> | 80 | </> |
| 78 | ) : null} | 81 | ) : null} |
apps/web/src/session-logic.test.ts+59-2| ... | @@ -343,6 +343,30 @@ describe("deriveActivePlanState", () => { | ... | @@ -343,6 +343,30 @@ describe("deriveActivePlanState", () => { |
| 343 | steps: [{ step: "Implement Codex user input", status: "inProgress" }], | 343 | steps: [{ step: "Implement Codex user input", status: "inProgress" }], |
| 344 | }); | 344 | }); |
| 345 | }); | 345 | }); |
| 346 | |||
| 347 | it("falls back to the most recent plan from a previous turn", () => { | ||
| 348 | const activities: OrchestrationThreadActivity[] = [ | ||
| 349 | makeActivity({ | ||
| 350 | id: "plan-from-turn-1", | ||
| 351 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 352 | kind: "turn.plan.updated", | ||
| 353 | summary: "Plan updated", | ||
| 354 | tone: "info", | ||
| 355 | turnId: "turn-1", | ||
| 356 | payload: { | ||
| 357 | plan: [{ step: "Write tests", status: "completed" }], | ||
| 358 | }, | ||
| 359 | }), | ||
| 360 | ]; | ||
| 361 | |||
| 362 | // Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan | ||
| 363 | const result = deriveActivePlanState(activities, TurnId.make("turn-2")); | ||
| 364 | expect(result).toEqual({ | ||
| 365 | createdAt: "2026-02-23T00:00:01.000Z", | ||
| 366 | turnId: "turn-1", | ||
| 367 | steps: [{ step: "Write tests", status: "completed" }], | ||
| 368 | }); | ||
| 369 | }); | ||
| 346 | }); | 370 | }); |
| 347 | 371 | ||
| 348 | describe("findLatestProposedPlan", () => { | 372 | describe("findLatestProposedPlan", () => { |
| ... | @@ -568,7 +592,7 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -568,7 +592,7 @@ describe("deriveWorkLogEntries", () => { |
| 568 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | 592 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 569 | }); | 593 | }); |
| 570 | 594 | ||
| 571 | it("omits task start and completion lifecycle entries", () => { | 595 | it("omits task.started but shows task.progress and task.completed", () => { |
| 572 | const activities: OrchestrationThreadActivity[] = [ | 596 | const activities: OrchestrationThreadActivity[] = [ |
| 573 | makeActivity({ | 597 | makeActivity({ |
| 574 | id: "task-start", | 598 | id: "task-start", |
| ... | @@ -594,7 +618,40 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -594,7 +618,40 @@ describe("deriveWorkLogEntries", () => { |
| 594 | ]; | 618 | ]; |
| 595 | 619 | ||
| 596 | const entries = deriveWorkLogEntries(activities, undefined); | 620 | const entries = deriveWorkLogEntries(activities, undefined); |
| 597 | expect(entries.map((entry) => entry.id)).toEqual(["task-progress"]); | 621 | expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]); |
| 622 | }); | ||
| 623 | |||
| 624 | it("uses payload summary as label for task entries when available", () => { | ||
| 625 | const activities: OrchestrationThreadActivity[] = [ | ||
| 626 | makeActivity({ | ||
| 627 | id: "task-progress-with-summary", | ||
| 628 | createdAt: "2026-02-23T00:00:02.000Z", | ||
| 629 | kind: "task.progress", | ||
| 630 | summary: "Reasoning update", | ||
| 631 | tone: "info", | ||
| 632 | payload: { summary: "Searching for API endpoints" }, | ||
| 633 | }), | ||
| 634 | ]; | ||
| 635 | |||
| 636 | const entries = deriveWorkLogEntries(activities, undefined); | ||
| 637 | expect(entries[0]?.label).toBe("Searching for API endpoints"); | ||
| 638 | }); | ||
| 639 | |||
| 640 | it("uses payload detail as label for task.completed and preserves error tone", () => { | ||
| 641 | const activities: OrchestrationThreadActivity[] = [ | ||
| 642 | makeActivity({ | ||
| 643 | id: "task-completed-failed", | ||
| 644 | createdAt: "2026-02-23T00:00:03.000Z", | ||
| 645 | kind: "task.completed", | ||
| 646 | summary: "Task failed", | ||
| 647 | tone: "error", | ||
| 648 | payload: { detail: "Failed to deploy changes" }, | ||
| 649 | }), | ||
| 650 | ]; | ||
| 651 | |||
| 652 | const entries = deriveWorkLogEntries(activities, undefined); | ||
| 653 | expect(entries[0]?.label).toBe("Failed to deploy changes"); | ||
| 654 | expect(entries[0]?.tone).toBe("error"); | ||
| 598 | }); | 655 | }); |
| 599 | 656 | ||
| 600 | it("filters by turn id when provided", () => { | 657 | it("filters by turn id when provided", () => { |
apps/web/src/session-logic.ts+36-14| ... | @@ -348,16 +348,15 @@ export function deriveActivePlanState( | ... | @@ -348,16 +348,15 @@ export function deriveActivePlanState( |
| 348 | latestTurnId: TurnId | undefined, | 348 | latestTurnId: TurnId | undefined, |
| 349 | ): ActivePlanState | null { | 349 | ): ActivePlanState | null { |
| 350 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | 350 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 351 | const candidates = ordered.filter((activity) => { | 351 | const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); |
| 352 | if (activity.kind !== "turn.plan.updated") { | 352 | // Prefer plan from the current turn; fall back to the most recent plan from any turn |
| 353 | return false; | 353 | // so that TodoWrite tasks persist across follow-up messages. |
| 354 | } | 354 | const latest = |
| 355 | if (!latestTurnId) { | 355 | (latestTurnId |
| 356 | return true; | 356 | ? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1) |
| 357 | } | 357 | : undefined) ?? |
| 358 | return activity.turnId === latestTurnId; | 358 | allPlanActivities.at(-1) ?? |
| 359 | }); | 359 | null; |
| 360 | const latest = candidates.at(-1); | ||
| 361 | if (!latest) { | 360 | if (!latest) { |
| 362 | return null; | 361 | return null; |
| 363 | } | 362 | } |
| ... | @@ -472,7 +471,7 @@ export function deriveWorkLogEntries( | ... | @@ -472,7 +471,7 @@ export function deriveWorkLogEntries( |
| 472 | const entries = ordered | 471 | const entries = ordered |
| 473 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) | 472 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) |
| 474 | .filter((activity) => activity.kind !== "tool.started") | 473 | .filter((activity) => activity.kind !== "tool.started") |
| 475 | .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") | 474 | .filter((activity) => activity.kind !== "task.started") |
| 476 | .filter((activity) => activity.kind !== "context-window.updated") | 475 | .filter((activity) => activity.kind !== "context-window.updated") |
| 477 | .filter((activity) => activity.summary !== "Checkpoint captured") | 476 | .filter((activity) => activity.summary !== "Checkpoint captured") |
| 478 | .filter((activity) => !isPlanBoundaryToolActivity(activity)) | 477 | .filter((activity) => !isPlanBoundaryToolActivity(activity)) |
| ... | @@ -502,16 +501,39 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -502,16 +501,39 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 502 | const commandPreview = extractToolCommand(payload); | 501 | const commandPreview = extractToolCommand(payload); |
| 503 | const changedFiles = extractChangedFiles(payload); | 502 | const changedFiles = extractChangedFiles(payload); |
| 504 | const title = extractToolTitle(payload); | 503 | const title = extractToolTitle(payload); |
| 504 | const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; | ||
| 505 | const taskSummary = | ||
| 506 | isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 | ||
| 507 | ? payload.summary | ||
| 508 | : null; | ||
| 509 | const taskDetailAsLabel = | ||
| 510 | isTaskActivity && | ||
| 511 | !taskSummary && | ||
| 512 | typeof payload?.detail === "string" && | ||
| 513 | payload.detail.length > 0 | ||
| 514 | ? payload.detail | ||
| 515 | : null; | ||
| 516 | const taskLabel = taskSummary || taskDetailAsLabel; | ||
| 505 | const entry: DerivedWorkLogEntry = { | 517 | const entry: DerivedWorkLogEntry = { |
| 506 | id: activity.id, | 518 | id: activity.id, |
| 507 | createdAt: activity.createdAt, | 519 | createdAt: activity.createdAt, |
| 508 | label: activity.summary, | 520 | label: taskLabel || activity.summary, |
| 509 | tone: activity.tone === "approval" ? "info" : activity.tone, | 521 | tone: |
| 522 | activity.kind === "task.progress" | ||
| 523 | ? "thinking" | ||
| 524 | : activity.tone === "approval" | ||
| 525 | ? "info" | ||
| 526 | : activity.tone, | ||
| 510 | activityKind: activity.kind, | 527 | activityKind: activity.kind, |
| 511 | }; | 528 | }; |
| 512 | const itemType = extractWorkLogItemType(payload); | 529 | const itemType = extractWorkLogItemType(payload); |
| 513 | const requestKind = extractWorkLogRequestKind(payload); | 530 | const requestKind = extractWorkLogRequestKind(payload); |
| 514 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { | 531 | if ( |
| 532 | !taskDetailAsLabel && | ||
| 533 | payload && | ||
| 534 | typeof payload.detail === "string" && | ||
| 535 | payload.detail.length > 0 | ||
| 536 | ) { | ||
| 515 | const detail = stripTrailingExitCode(payload.detail).output; | 537 | const detail = stripTrailingExitCode(payload.detail).output; |
| 516 | if (detail) { | 538 | if (detail) { |
| 517 | entry.detail = detail; | 539 | entry.detail = detail; |