Align UI context with chat turn lifecycle

This commit is contained in:
murashit 2026-05-28 17:34:45 +09:00
parent aa04e55c52
commit 10b8a8473b
13 changed files with 122 additions and 159 deletions

View file

@ -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,

View file

@ -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<string, AgentStateDisplay>();
for (const item of items) {

View file

@ -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<string, string>;
workspaceRoot?: string | null;
@ -42,6 +42,10 @@ export interface MessageStreamContext {
renderPendingRequests?: () => ReactNode;
}
export function messageStreamActiveTurnId(context: Pick<MessageStreamContext, "turnLifecycle">): string | null {
return activeTurnId(context);
}
type RenderableMessageItem = Extract<DisplayItem, { kind: "message" | "system" | "userInputResult" }>;
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<MessageStreamContext, "busy" | "activeTurnId">): boolean {
function isMessageCopyActionVisible(item: DisplayItem, context: Pick<MessageStreamContext, "turnLifecycle">): 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 {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -86,7 +86,7 @@ function snapshotsForThreads(snapshots: OpenCodexPanelSnapshot[]): Map<string, O
function snapshotStatus(snapshot: OpenCodexPanelSnapshot): ThreadsLiveStatus {
if (snapshot.pendingUserInputs > 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";

View file

@ -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

View file

@ -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;

View file

@ -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", () => {

View file

@ -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<PendingRequestMessageActions>
};
}
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<T>(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<string>(),
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<string>(),
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<string>(),
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<string>(),
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<string>(),
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<string>(),
loadOlderTurns: vi.fn(),

View file

@ -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", () => {

View file

@ -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);
});

View file

@ -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,