diff --git a/src/features/chat/chat-message-renderer.ts b/src/features/chat/chat-message-renderer.ts index ab31dba2..ea6e015a 100644 --- a/src/features/chat/chat-message-renderer.ts +++ b/src/features/chat/chat-message-renderer.ts @@ -8,7 +8,7 @@ import { MessageScrollController, type MessageScrollIntent } from "./ui/scroll"; import type { ChatTurnDiffViewState } from "./ui/turn-diff"; import { MarkdownMessageRenderer } from "./markdown-message-renderer"; import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback"; -import { activeTurnId, chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state"; +import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state"; import { unmountReactRoot } from "../../shared/ui/react-root"; export interface ChatMessageRendererOptions { @@ -63,16 +63,14 @@ export class ChatMessageRenderer { this.messagesEl = messagesEl; const scrollPlan = this.scrollController.prepareRender(messagesEl, this.options.consumeScrollIntent()); const busy = chatTurnBusy(state); - const activeTurn = activeTurnId(state); const rollbackCandidate = busy ? null : rollbackCandidateFromItems(state.displayItems); const implementPlanCandidate = implementPlanCandidateFromState(state); const blocks = messageStreamBlocks({ activeThreadId: state.activeThreadId, - activeTurnId: activeTurn, + turnLifecycle: state.turnLifecycle, historyCursor: state.historyCursor, loadingHistory: state.loadingHistory, - busy, displayItems: state.displayItems, turnDiffs: state.turnDiffs, workspaceRoot: state.activeThreadCwd ?? this.options.vaultPath, diff --git a/src/features/chat/display/agent.ts b/src/features/chat/display/agent.ts index 0ffd27e6..d84c57e4 100644 --- a/src/features/chat/display/agent.ts +++ b/src/features/chat/display/agent.ts @@ -32,8 +32,8 @@ export function agentDisplayItem(item: CollabAgentToolCallItem, turnId?: string) }; } -export function activeAgentRunSummary(items: readonly DisplayItem[], activeTurnId: string | null, busy: boolean): AgentRunSummary | null { - if (!busy || !activeTurnId) return null; +export function activeAgentRunSummary(items: readonly DisplayItem[], activeTurnId: string | null): AgentRunSummary | null { + if (!activeTurnId) return null; const agentStatuses = new Map(); for (const item of items) { diff --git a/src/features/chat/ui/message-stream.tsx b/src/features/chat/ui/message-stream.tsx index 3bce78ea..42cfe4d6 100644 --- a/src/features/chat/ui/message-stream.tsx +++ b/src/features/chat/ui/message-stream.tsx @@ -4,6 +4,7 @@ import { displayBlocksForItems } from "../display/blocks"; import { executionState } from "../display/state"; import type { ToolResultDisplayItem } from "../display/tool-view"; import type { DisplayBlock, DisplayDetailSection, DisplayItem } from "../display/types"; +import { activeTurnId, type ChatTurnLifecycleState } from "../chat-state"; import { IconButton } from "../../../shared/ui/react-components"; import { toolResultNode } from "./tool-result"; import { activeAgentRunSummaryBlock, agentRunSummaryNode, workItemNode, type WorkItemDisplayItem } from "./work-items"; @@ -20,10 +21,9 @@ export interface MessageStreamBlock { export interface MessageStreamContext { activeThreadId: string | null; - activeTurnId: string | null; + turnLifecycle: ChatTurnLifecycleState; historyCursor: string | null; loadingHistory: boolean; - busy: boolean; displayItems: readonly DisplayItem[]; turnDiffs?: ReadonlyMap; workspaceRoot?: string | null; @@ -42,6 +42,10 @@ export interface MessageStreamContext { renderPendingRequests?: () => ReactNode; } +export function messageStreamActiveTurnId(context: Pick): string | null { + return activeTurnId(context); +} + type RenderableMessageItem = Extract; function isRenderableMessageItem(item: DisplayItem): item is RenderableMessageItem { @@ -71,6 +75,7 @@ function displayItemNode(item: DisplayItem, context: MessageStreamContext): Reac export function messageStreamBlocks(context: MessageStreamContext): MessageStreamBlock[] { const blocks: MessageStreamBlock[] = []; + const activeTurn = messageStreamActiveTurnId(context); if (context.activeThreadId && context.historyCursor) { blocks.push({ @@ -87,7 +92,7 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea return blocks; } - for (const block of displayBlocksForItems(context.displayItems, context.activeTurnId, context.workspaceRoot, context.turnDiffs)) { + for (const block of displayBlocksForItems(context.displayItems, activeTurn, context.workspaceRoot, context.turnDiffs)) { if (block.type === "item") { blocks.push({ key: `item:${block.item.id}`, @@ -104,7 +109,7 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea const agentSummary = activeAgentRunSummaryBlock(context); if (agentSummary) { blocks.push({ - key: `active-agents:${context.activeTurnId ?? "none"}`, + key: `active-agents:${activeTurn ?? "none"}`, node: agentRunSummaryNode(agentSummary), }); } @@ -587,9 +592,10 @@ function displayRoleLabel(item: DisplayItem): string { return "System"; } -function isMessageCopyActionVisible(item: DisplayItem, context: Pick): boolean { +function isMessageCopyActionVisible(item: DisplayItem, context: Pick): boolean { if (item.kind !== "message" || item.copyText === undefined) return false; - return !(context.busy && context.activeTurnId && item.role === "assistant" && item.turnId === context.activeTurnId); + const activeTurn = messageStreamActiveTurnId(context); + return !(activeTurn && item.role === "assistant" && item.turnId === activeTurn); } function messageClass(item: DisplayItem): string { diff --git a/src/features/chat/ui/work-items.tsx b/src/features/chat/ui/work-items.tsx index ebcdd74f..82705c48 100644 --- a/src/features/chat/ui/work-items.tsx +++ b/src/features/chat/ui/work-items.tsx @@ -4,7 +4,7 @@ import { activeAgentRunSummary } from "../display/agent"; import { executionState } from "../display/state"; import type { AgentDisplayItem, AgentRunSummary, AgentRunSummaryAgent, TaskProgressDisplayItem, ToolDisplayItem } from "../display/types"; import { agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel, taskStatusMarker } from "../display/labels"; -import type { MessageStreamContext } from "./message-stream"; +import { messageStreamActiveTurnId, type MessageStreamContext } from "./message-stream"; import { createWorkMessageClassName } from "./work-message"; import { shortThreadId } from "../../../utils"; @@ -14,7 +14,7 @@ type ReasoningDisplayItem = ToolDisplayItem & { kind: "reasoning" }; export type WorkItemDisplayItem = TaskProgressDisplayItem | AgentDisplayItem | ReasoningDisplayItem; export function activeAgentRunSummaryBlock(context: MessageStreamContext): AgentRunSummary | null { - return activeAgentRunSummary(context.displayItems, context.activeTurnId, context.busy); + return activeAgentRunSummary(context.displayItems, messageStreamActiveTurnId(context)); } export function agentRunSummaryNode(summary: AgentRunSummary): ReactNode { @@ -229,8 +229,9 @@ function isLongAgentMessage(message: string): boolean { } function isReasoningActive(item: ReasoningDisplayItem, context: MessageStreamContext): boolean { - if (!context.busy || !context.activeTurnId || item.turnId !== context.activeTurnId) return false; + const activeTurn = messageStreamActiveTurnId(context); + if (!activeTurn || item.turnId !== activeTurn) return false; if (executionState(item) === "completed") return false; - const latestActiveTurnItem = [...context.displayItems].reverse().find((candidate) => candidate.turnId === context.activeTurnId); + const latestActiveTurnItem = [...context.displayItems].reverse().find((candidate) => candidate.turnId === activeTurn); return latestActiveTurnItem?.id === item.id; } diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 3e002f27..7553a221 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -388,8 +388,7 @@ export class CodexChatView extends ItemView { return { viewId: this.viewId, threadId: this.closing ? null : this.state.activeThreadId, - busy: this.turnBusy, - activeTurnId: this.activeTurnId, + turnLifecycle: openPanelTurnLifecycle(this.state.turnLifecycle), pendingApprovals: this.state.approvals.length, pendingUserInputs: this.state.pendingUserInputs.length, hasComposerDraft: this.state.composerDraft.trim().length > 0, @@ -1722,6 +1721,12 @@ export class CodexChatView extends ItemView { } } +function openPanelTurnLifecycle(state: ChatState["turnLifecycle"]): OpenCodexPanelSnapshot["turnLifecycle"] { + if (state.kind === "running") return { kind: "running", turnId: state.turnId }; + if (state.kind === "starting") return { kind: "starting" }; + return { kind: "idle" }; +} + function latestProposedPlanItem(items: readonly DisplayItem[]): DisplayItem | null { return [...items].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ?? null; } diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index dbf877ee..61c4270a 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -86,7 +86,7 @@ function snapshotsForThreads(snapshots: OpenCodexPanelSnapshot[]): Map 0) return "needs-input"; if (snapshot.pendingApprovals > 0) return "approval"; - if (snapshot.busy) return "running"; + if (snapshot.turnLifecycle.kind !== "idle") return "running"; if (snapshot.hasComposerDraft) return "draft"; if (!snapshot.connected) return "offline"; return "open"; diff --git a/src/main.ts b/src/main.ts index 1e1ee87c..0e723e6e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -519,8 +519,7 @@ export default class CodexPanelPlugin extends Plugin { function isIdleEmptyPanelSnapshot(snapshot: OpenCodexPanelSnapshot): boolean { return ( snapshot.threadId === null && - !snapshot.busy && - snapshot.activeTurnId === null && + snapshot.turnLifecycle.kind === "idle" && snapshot.pendingApprovals === 0 && snapshot.pendingUserInputs === 0 && !snapshot.hasComposerDraft diff --git a/src/runtime/open-panel-snapshot.ts b/src/runtime/open-panel-snapshot.ts index 1cff1c6d..3fd88a2b 100644 --- a/src/runtime/open-panel-snapshot.ts +++ b/src/runtime/open-panel-snapshot.ts @@ -1,8 +1,9 @@ +export type OpenCodexPanelTurnLifecycle = { kind: "idle" } | { kind: "starting" } | { kind: "running"; turnId: string }; + export interface OpenCodexPanelSnapshot { viewId: string; threadId: string | null; - busy: boolean; - activeTurnId: string | null; + turnLifecycle: OpenCodexPanelTurnLifecycle; pendingApprovals: number; pendingUserInputs: number; hasComposerDraft: boolean; diff --git a/tests/features/chat/display/display-model.test.ts b/tests/features/chat/display/display-model.test.ts index e7fde720..344566aa 100644 --- a/tests/features/chat/display/display-model.test.ts +++ b/tests/features/chat/display/display-model.test.ts @@ -1064,7 +1064,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes }, ]; - expect(activeAgentRunSummary(items, "t1", true)).toEqual({ + expect(activeAgentRunSummary(items, "t1")).toEqual({ running: 1, completed: 1, failed: 1, @@ -1075,7 +1075,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes ], additionalAgents: 0, }); - expect(activeAgentRunSummary(items, "t1", false)).toBeNull(); + expect(activeAgentRunSummary(items, null)).toBeNull(); }); it("summarizes active subagent previews and fallback receiver states", () => { @@ -1116,7 +1116,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes }, ]; - expect(activeAgentRunSummary(items, "t1", true)).toMatchObject({ + expect(activeAgentRunSummary(items, "t1")).toMatchObject({ running: 3, completed: 0, failed: 1, @@ -1148,7 +1148,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes }, ]; - expect(activeAgentRunSummary(items, "t1", true)).toBeNull(); + expect(activeAgentRunSummary(items, "t1")).toBeNull(); }); it("adds edited files to the final assistant message", () => { diff --git a/tests/features/chat/ui/message-stream.test.tsx b/tests/features/chat/ui/message-stream.test.tsx index 37804e60..9a4bdf55 100644 --- a/tests/features/chat/ui/message-stream.test.tsx +++ b/tests/features/chat/ui/message-stream.test.tsx @@ -8,6 +8,7 @@ import type { PendingUserInput } from "../../../../src/features/chat/user-input/ import { pendingRequestMessageNode, type PendingRequestMessageActions } from "../../../../src/features/chat/ui/pending-request-message"; import type { DisplayItem } from "../../../../src/features/chat/display/types"; import { implementPlanCandidateFromState } from "../../../../src/features/chat/chat-message-renderer"; +import type { ChatTurnLifecycleState } from "../../../../src/features/chat/chat-state"; import { messageStreamBlocks as rawMessageStreamBlocks, renderMessageStreamBlocks } from "../../../../src/features/chat/ui/message-stream"; import { changeInputValue, installObsidianDomShims, topLevelDetailsSummaries } from "./dom-test-helpers"; import { renderReactRoot, unmountReactRoot } from "../../../../src/shared/ui/react-root"; @@ -69,6 +70,18 @@ function pendingRequestActions(overrides: Partial }; } +function idleTurnLifecycle(): ChatTurnLifecycleState { + return { kind: "idle" }; +} + +function runningTurnLifecycle(turnId = "turn"): ChatTurnLifecycleState { + return { kind: "running", turnId }; +} + +function startingTurnLifecycle(): ChatTurnLifecycleState { + return { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] } }; +} + function withMessageContentScrollHeight(scrollHeight: number, fn: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); Object.defineProperty(HTMLElement.prototype, "scrollHeight", { @@ -139,10 +152,9 @@ describe("message stream block identity and message actions", () => { const parent = document.createElement("div"); const baseContext = { activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, openDetails: new Set(), loadOlderTurns: vi.fn(), renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), @@ -199,10 +211,9 @@ describe("message stream block identity and message actions", () => { const loadOlderTurns = vi.fn(); const [historyBlock] = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: "cursor", loadingHistory: false, - busy: false, displayItems: [], openDetails: new Set(), loadOlderTurns, @@ -229,10 +240,9 @@ describe("message stream block identity and message actions", () => { it("renders the empty message stream state as a React block", () => { const [emptyBlock] = messageStreamBlocks({ activeThreadId: null, - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -254,10 +264,9 @@ describe("message stream block identity and message actions", () => { it("renders review result items as compact auto-review tool rows", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command.", markdown: false }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -277,10 +286,9 @@ describe("message stream block identity and message actions", () => { it("renders review result details inside one auto-review details block", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "review-1", @@ -334,10 +342,9 @@ describe("message stream block identity and message actions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "cmd-1", @@ -384,10 +391,9 @@ describe("message stream block identity and message actions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, workspaceRoot: "/vault", displayItems: [ { @@ -415,10 +421,9 @@ describe("message stream block identity and message actions", () => { it("renders structured system result details as visible selectable meta rows", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "system-help", @@ -461,10 +466,9 @@ describe("message stream block identity and message actions", () => { ] as const; const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [...items], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -488,10 +492,9 @@ describe("message stream block identity and message actions", () => { const copyText = vi.fn(); const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "u1", kind: "message", role: "user", text: "rendered user", copyText: "**user**", turnId: "turn-1", markdown: true }, { id: "a1", kind: "message", role: "assistant", text: "rendered answer", copyText: "# Answer", turnId: "turn-1", markdown: true }, @@ -524,10 +527,9 @@ describe("message stream block identity and message actions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "p1", @@ -569,10 +571,9 @@ describe("message stream block identity and message actions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -590,10 +591,9 @@ describe("message stream block identity and message actions", () => { const parent = document.createElement("div"); const baseContext = { activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, openDetails: new Set(), loadOlderTurns: vi.fn(), renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text: `markdown:${text}` }), @@ -627,10 +627,9 @@ describe("message stream block identity and message actions", () => { }); const baseContext = { activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, openDetails: new Set(), loadOlderTurns: vi.fn(), renderMarkdown, @@ -668,10 +667,9 @@ describe("message stream block identity and message actions", () => { }); const context = { activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }, ] satisfies DisplayItem[], @@ -701,10 +699,9 @@ describe("message stream block identity and message actions", () => { } as const; const context = { activeThreadId: "thread", - activeTurnId: "turn-1", + turnLifecycle: runningTurnLifecycle("turn-1"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [item], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -714,7 +711,7 @@ describe("message stream block identity and message actions", () => { }; const runningBlock = messageStreamBlocks(context)[0]; - const completedBlock = messageStreamBlocks({ ...context, busy: false, activeTurnId: null })[0]; + const completedBlock = messageStreamBlocks({ ...context, turnLifecycle: idleTurnLifecycle() })[0]; expect(renderMessageBlockElement(runningBlock).querySelector(".codex-panel__copy-message")).toBeNull(); expect(renderMessageBlockElement(completedBlock).querySelector(".codex-panel__copy-message")).not.toBeNull(); @@ -724,10 +721,9 @@ describe("message stream block identity and message actions", () => { const onImplementPlanItem = vi.fn(); const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "p1", @@ -791,10 +787,9 @@ describe("message stream block identity and message actions", () => { it("does not render copy actions for tool items", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "tool-1", @@ -820,10 +815,9 @@ describe("message stream block identity and message actions", () => { const onRollbackItem = vi.fn(); const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "u1", kind: "message", role: "user", text: "latest", copyText: "latest", turnId: "turn-1", markdown: true }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -855,10 +849,9 @@ describe("message stream block identity and message actions", () => { }); const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "u1", kind: "message", role: "user", text: "visible text", copyText: "full copied text", turnId: "turn-1", markdown: true }, ], @@ -898,10 +891,9 @@ describe("message stream block identity and message actions", () => { withMessageContentScrollHeight(120, () => { const shortUserBlock = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "u1", kind: "message", role: "user", text: "short", turnId: "turn-1", markdown: true }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -916,10 +908,9 @@ describe("message stream block identity and message actions", () => { withMessageContentScrollHeight(500, () => { const assistantBlock = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "long", turnId: "turn-1", markdown: true }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -935,10 +926,9 @@ describe("message stream block identity and message actions", () => { it("does not render rollback action when no item is eligible", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: startingTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [{ id: "u1", kind: "message", role: "user", text: "running", turnId: "turn-1", markdown: true }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -954,10 +944,9 @@ describe("message stream block identity and message actions", () => { it("renders command items as a compact summary with output behind details", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "cmd-1", @@ -993,10 +982,9 @@ describe("message stream block identity and message actions", () => { it("omits command exit and duration rows while they are unavailable", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "cmd-1", @@ -1029,10 +1017,9 @@ describe("message stream block identity and message actions", () => { it("uses read as the command header for parsed file reads", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "cmd-1", @@ -1062,10 +1049,9 @@ describe("message stream block identity and message actions", () => { it("renders file diffs inside a single file change details block", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, workspaceRoot: "/vault/project", displayItems: [ { @@ -1101,10 +1087,9 @@ describe("message stream block identity and message actions", () => { const openTurnDiff = vi.fn(); const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, workspaceRoot: "/vault/project", displayItems: [ { @@ -1145,10 +1130,9 @@ describe("message stream block identity and message actions", () => { it("renders referenced thread metadata without exposing hidden context", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "u1", @@ -1182,10 +1166,9 @@ describe("message stream block identity and message actions", () => { it("renders resolved file mentions as a collapsed user message attachment", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "u1", @@ -1214,10 +1197,9 @@ describe("message stream block identity and message actions", () => { it("does not render the open diff action without aggregated turn diff", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "patch-1", @@ -1248,10 +1230,9 @@ describe("work log renderer decisions", () => { it("renders generic tool details as visible sections inside one details block", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "tool-1", @@ -1288,10 +1269,9 @@ describe("work log renderer decisions", () => { it("keeps the tool summary as a separate row when details are open", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "cmd-1", @@ -1323,10 +1303,9 @@ describe("work log renderer decisions", () => { it("renders generic tools without details as two compact rows", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "tool-plain", @@ -1353,10 +1332,9 @@ describe("work log renderer decisions", () => { it("renders path summary tools relative to the workspace root", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, workspaceRoot: "/vault/project", displayItems: [ { @@ -1394,10 +1372,9 @@ describe("work log renderer decisions", () => { } as const; const baseContext = { activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [item] satisfies DisplayItem[], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -1416,10 +1393,9 @@ describe("work log renderer decisions", () => { it("keeps path summary tools absolute outside the workspace root", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, workspaceRoot: "/vault/project", displayItems: [ { @@ -1446,10 +1422,9 @@ describe("work log renderer decisions", () => { it("does not treat generic tool summaries as paths without an explicit marker", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, workspaceRoot: "/vault/project", displayItems: [ { @@ -1475,10 +1450,9 @@ describe("work log renderer decisions", () => { it("renders hook metadata as rows inside one details block", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "hook-1", @@ -1523,10 +1497,9 @@ describe("work log renderer decisions", () => { it("renders hook metadata when the hook is inside a completed-turn activity group", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true }, { @@ -1575,10 +1548,9 @@ describe("work log renderer decisions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true }, { @@ -1643,10 +1615,9 @@ describe("work log renderer decisions", () => { it("renders task progress items as a dedicated task list", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "plan-progress-turn", @@ -1684,10 +1655,9 @@ describe("work log renderer decisions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "plan-progress-turn", @@ -1720,10 +1690,9 @@ describe("work log renderer decisions", () => { it("renders agent activity with target state and prompt details", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "agent-1", @@ -1766,10 +1735,9 @@ describe("work log renderer decisions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "agent-1", @@ -1814,10 +1782,9 @@ describe("work log renderer decisions", () => { const onDetailsToggle = vi.fn(); const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "agent-1", @@ -1860,10 +1827,9 @@ describe("work log renderer decisions", () => { it("renders a compact live agent summary while subagents are running", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "agent-1", @@ -1907,10 +1873,9 @@ describe("work log renderer decisions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "agent-1", @@ -1951,10 +1916,9 @@ describe("work log renderer decisions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [{ id: "reasoning-1", kind: "reasoning", role: "tool", text: "", turnId: "turn", status: "running" }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -1975,10 +1939,9 @@ describe("work log renderer decisions", () => { it("hides the live agent summary once all subagents are complete", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "agent-1", @@ -2008,10 +1971,9 @@ describe("work log renderer decisions", () => { it("marks the live agent summary failed when any subagent fails", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: runningTurnLifecycle("turn"), historyCursor: null, loadingHistory: false, - busy: true, displayItems: [ { id: "agent-1", @@ -2357,10 +2319,9 @@ describe("pending request renderer decisions", () => { it("renders submitted user input separately from approvals", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "user-input-submitted-1", @@ -2390,10 +2351,9 @@ describe("pending request renderer decisions", () => { it("renders manual approval results with completion state and details", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: "turn", + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "approval-1", @@ -2436,10 +2396,9 @@ describe("pending request renderer decisions", () => { it("renders auto-review summaries under the final assistant message", () => { const block = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [ { id: "assistant-1", @@ -2466,10 +2425,9 @@ describe("pending request renderer decisions", () => { it("adds pending requests to the bottom of message stream blocks", () => { const blocks = messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -2492,10 +2450,9 @@ describe("pending request renderer decisions", () => { parent, messageStreamBlocks({ activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Waiting", markdown: true }], openDetails: new Set(), loadOlderTurns: vi.fn(), @@ -2527,10 +2484,9 @@ describe("pending request renderer decisions", () => { const parent = document.createElement("div"); const baseContext = { activeThreadId: "thread", - activeTurnId: null, + turnLifecycle: idleTurnLifecycle(), historyCursor: null, loadingHistory: false, - busy: false, displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }] satisfies DisplayItem[], openDetails: new Set(), loadOlderTurns: vi.fn(), diff --git a/tests/features/chat/ui/view-renderers.test.ts b/tests/features/chat/ui/view-renderers.test.ts index dfcebb11..35e5a315 100644 --- a/tests/features/chat/ui/view-renderers.test.ts +++ b/tests/features/chat/ui/view-renderers.test.ts @@ -16,6 +16,7 @@ import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomSh import { renderThreadsView } from "../../../../src/features/threads-view/renderer"; import { liveStateForSnapshots, threadRows, type ThreadsRowModel } from "../../../../src/features/threads-view/state"; import type { Thread } from "../../../../src/generated/app-server/v2/Thread"; +import type { OpenCodexPanelSnapshot } from "../../../../src/runtime/open-panel-snapshot"; installObsidianDomShims(); @@ -41,19 +42,17 @@ function openPanelSnapshot( overrides: Partial<{ viewId: string; threadId: string | null; - busy: boolean; - activeTurnId: string | null; + turnLifecycle: OpenCodexPanelSnapshot["turnLifecycle"]; pendingApprovals: number; pendingUserInputs: number; hasComposerDraft: boolean; connected: boolean; }> = {}, -) { +): OpenCodexPanelSnapshot { return { viewId: "view", threadId: "thread", - busy: false, - activeTurnId: null, + turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, hasComposerDraft: false, @@ -623,7 +622,7 @@ describe("threads view renderer decisions", () => { expect( liveStateForSnapshots([ openPanelSnapshot({ viewId: "open", threadId: "thread" }), - openPanelSnapshot({ viewId: "running", threadId: "thread", busy: true }), + openPanelSnapshot({ viewId: "running", threadId: "thread", turnLifecycle: { kind: "running", turnId: "turn" } }), openPanelSnapshot({ viewId: "approval", threadId: "thread", pendingApprovals: 1 }), openPanelSnapshot({ viewId: "input", threadId: "thread", pendingUserInputs: 1 }), ]), @@ -637,7 +636,9 @@ describe("threads view renderer decisions", () => { status: "offline", label: "Offline", }); - expect(liveStateForSnapshots([openPanelSnapshot({ viewId: "none", threadId: null, busy: true })])).toBeNull(); + expect( + liveStateForSnapshots([openPanelSnapshot({ viewId: "none", threadId: null, turnLifecycle: { kind: "running", turnId: "turn" } })]), + ).toBeNull(); }); it("renders thread rows with live state and routes open actions", () => { diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index d404b1bb..3a4a69e2 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -326,7 +326,7 @@ describe("CodexChatView connection lifecycle", () => { await submit; expect((view as unknown as { state: ChatState }).state.turnLifecycle).toEqual({ kind: "idle" }); - expect(view.openPanelSnapshot()).toMatchObject({ busy: false, activeTurnId: null }); + expect(view.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } }); }); it("requests a workspace layout save after resuming a thread", async () => { @@ -352,7 +352,7 @@ describe("CodexChatView connection lifecycle", () => { expect(client.startThread).not.toHaveBeenCalled(); expect(view.getState()).toEqual({ version: 1 }); - expect(view.openPanelSnapshot()).toMatchObject({ threadId: null, busy: false, activeTurnId: null, hasComposerDraft: false }); + expect(view.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false }); expect(requestSaveLayout).toHaveBeenCalledTimes(2); }); diff --git a/tests/main.test.ts b/tests/main.test.ts index e8dc0751..93cc7401 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -70,8 +70,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { vi.spyOn(openView, "openPanelSnapshot").mockReturnValue({ viewId: "open-view", threadId: "thread-1", - busy: false, - activeTurnId: null, + turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, hasComposerDraft: false, @@ -98,8 +97,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { vi.spyOn(busyView, "openPanelSnapshot").mockReturnValue({ viewId: "busy-view", threadId: "other-thread", - busy: false, - activeTurnId: null, + turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, hasComposerDraft: false, @@ -111,8 +109,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { vi.spyOn(emptyView, "openPanelSnapshot").mockReturnValue({ viewId: "empty-view", threadId: null, - busy: false, - activeTurnId: null, + turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, hasComposerDraft: false, @@ -451,8 +448,7 @@ function panelSnapshot( return { viewId: "view", threadId: "thread", - busy: false, - activeTurnId: null, + turnLifecycle: { kind: "idle" }, pendingApprovals: 0, pendingUserInputs: 0, hasComposerDraft: false,