Model chat message render state

This commit is contained in:
murashit 2026-06-03 21:09:29 +09:00
parent 35d78d08ca
commit 19c461f52b
29 changed files with 831 additions and 244 deletions

View file

@ -49,13 +49,13 @@ export function localUserMessageItem(params: LocalUserMessageParams): MessageDis
return {
id: params.id,
kind: "message",
messageKind: "user",
role: "user",
text: params.text,
copyText: params.copyText ?? params.text,
...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
...(mentionedFiles.length > 0 ? { mentionedFiles: [...mentionedFiles] } : {}),
markdown: true,
};
}

View file

@ -1,5 +1,5 @@
import type { DisplayBlock, DisplayItem, DisplayKind } from "./types";
import { isFinalAssistantMessage } from "./final-assistant";
import { isCompletedTurnOutcomeMessage } from "./turn-outcome-message";
import { pathRelativeToRoot } from "./paths";
import { executionState } from "./state";
@ -15,9 +15,9 @@ export function displayBlocksForItems(
const visibleItems = items.filter(shouldShowDisplayItem);
const editedFilesByTurn = editedFilesForTurns(visibleItems, workspaceRoot);
const autoReviewSummariesByTurn = autoReviewSummariesForTurns(visibleItems);
const finalAssistantIdByTurn = finalAssistantItemsByTurn(visibleItems);
const groupedTurnIds = new Set([...finalAssistantIdByTurn.keys()].filter((turnId) => turnId !== activeTurnId));
const summaryAssistantIdByTurn = new Map([...finalAssistantIdByTurn].filter(([turnId]) => groupedTurnIds.has(turnId)));
const turnOutcomeIdByTurn = turnOutcomeItemsByTurn(visibleItems);
const groupedTurnIds = new Set([...turnOutcomeIdByTurn.keys()].filter((turnId) => turnId !== activeTurnId));
const summaryOutcomeIdByTurn = new Map([...turnOutcomeIdByTurn].filter(([turnId]) => groupedTurnIds.has(turnId)));
const groupedActivities = new Map<string, DisplayItem[]>();
const seenUserMessagesByTurn = new Map<string, number>();
@ -30,7 +30,7 @@ export function displayBlocksForItems(
groupedActivities.set(turnId, group);
continue;
}
if (!isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) continue;
if (!isCompletedTurnDetailItem(item, turnOutcomeIdByTurn)) continue;
const group = groupedActivities.get(turnId) ?? [];
group.push(item);
groupedActivities.set(turnId, group);
@ -39,10 +39,10 @@ export function displayBlocksForItems(
const blocks: DisplayBlock[] = [];
for (const item of visibleItems) {
const turnId = item.turnId;
if (turnId && groupedActivities.has(turnId) && isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) {
if (turnId && groupedActivities.has(turnId) && isCompletedTurnDetailItem(item, turnOutcomeIdByTurn)) {
continue;
}
if (turnId && finalAssistantIdByTurn.get(turnId) === item.id && groupedActivities.has(turnId)) {
if (turnId && turnOutcomeIdByTurn.get(turnId) === item.id && groupedActivities.has(turnId)) {
const groupItems = groupedActivities.get(turnId) ?? [];
blocks.push({
type: "activityGroup",
@ -54,7 +54,7 @@ export function displayBlocksForItems(
}
blocks.push({
type: "item",
item: itemWithTurnSummaries(item, editedFilesByTurn, autoReviewSummariesByTurn, summaryAssistantIdByTurn, turnDiffs),
item: itemWithTurnSummaries(item, editedFilesByTurn, autoReviewSummariesByTurn, summaryOutcomeIdByTurn, turnDiffs),
});
}
@ -86,28 +86,28 @@ function steeringActivityItem(item: DisplayItem, turnId: string): DisplayItem {
};
}
function isCompletedTurnDetailItem(item: DisplayItem, finalAssistantIdByTurn: Map<string, string>): boolean {
function isCompletedTurnDetailItem(item: DisplayItem, turnOutcomeIdByTurn: Map<string, string>): boolean {
if (!item.turnId || item.role === "user") return false;
return finalAssistantIdByTurn.get(item.turnId) !== item.id;
return turnOutcomeIdByTurn.get(item.turnId) !== item.id;
}
function finalAssistantItemsByTurn(items: readonly DisplayItem[]): Map<string, string> {
const finalAssistantIdByTurn = new Map<string, string>();
function turnOutcomeItemsByTurn(items: readonly DisplayItem[]): Map<string, string> {
const turnOutcomeIdByTurn = new Map<string, string>();
for (const item of items) {
if (!item.turnId || !isFinalAssistantMessage(item)) continue;
finalAssistantIdByTurn.set(item.turnId, item.id);
if (!item.turnId || !isCompletedTurnOutcomeMessage(item)) continue;
turnOutcomeIdByTurn.set(item.turnId, item.id);
}
return finalAssistantIdByTurn;
return turnOutcomeIdByTurn;
}
function itemWithTurnSummaries(
item: DisplayItem,
editedFilesByTurn: Map<string, string[]>,
autoReviewSummariesByTurn: Map<string, string[]>,
finalAssistantIdByTurn: Map<string, string>,
turnOutcomeIdByTurn: Map<string, string>,
turnDiffs?: ReadonlyMap<string, string>,
): DisplayItem {
if (!item.turnId || finalAssistantIdByTurn.get(item.turnId) !== item.id) return item;
if (!item.turnId || turnOutcomeIdByTurn.get(item.turnId) !== item.id) return item;
if (item.kind !== "message") return item;
const editedFiles = editedFilesByTurn.get(item.turnId);
const autoReviewSummaries = autoReviewSummariesByTurn.get(item.turnId);

View file

@ -1,5 +0,0 @@
import type { DisplayItem } from "./types";
export function isFinalAssistantMessage(item: DisplayItem): boolean {
return item.kind === "message" && item.role === "assistant" && item.markdown !== false;
}

View file

@ -20,7 +20,6 @@ export function createReviewResultItem(id: string, text: string): DisplayItem {
kind: "reviewResult",
role: "tool",
text: parsed.summary,
markdown: false,
executionState: autoReviewExecutionState(parsed.status),
details: [{ title: "Review", rows: parsed.rows }],
};
@ -30,7 +29,6 @@ export function createReviewResultItem(id: string, text: string): DisplayItem {
kind: "reviewResult",
role: "tool",
text,
markdown: false,
};
}
@ -54,7 +52,6 @@ export function createAutoReviewResultItem(params: AutoReviewNotification): Disp
role: "tool",
text,
turnId: params.turnId,
markdown: false,
executionState: completed ? autoReviewExecutionState(status) : "running",
details: [{ title: "Review", rows }],
};

View file

@ -1,4 +1,5 @@
import type { DisplayFileChange, DisplayItem, DisplayKind } from "./types";
import type { AssistantAuthoredMessageDisplayItem, DisplayFileChange, DisplayItem, DisplayKind } from "./types";
import { isAssistantAuthoredMessage } from "./turn-outcome-message";
import { normalizeProposedPlanMarkdown } from "./plan";
export function upsertDisplayItem(items: readonly DisplayItem[], next: DisplayItem): DisplayItem[] {
@ -29,16 +30,16 @@ function mergeChanges(previous: DisplayItem, next: DisplayItem): DisplayFileChan
}
export function appendAssistantDelta(items: readonly DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.messageKind === "assistantResponse");
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && item.kind === "message"
itemIndex === index && item.kind === "message" && item.messageKind === "assistantResponse"
? {
...item,
text: `${item.text}${delta}`,
copyText: `${item.text}${delta}`,
turnId: item.turnId ?? turnId,
markdown: true,
messageState: "streaming",
}
: item,
);
@ -48,12 +49,13 @@ export function appendAssistantDelta(items: readonly DisplayItem[], itemId: stri
{
id: itemId,
kind: "message",
messageKind: "assistantResponse",
role: "assistant",
text: delta,
copyText: delta,
turnId,
itemId,
markdown: true,
messageState: "streaming",
},
];
}
@ -71,10 +73,10 @@ export function completeReasoningItems(items: readonly DisplayItem[], turnId: st
}
export function appendPlanDelta(items: readonly DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
const index = items.findIndex((item) => item.itemId === itemId && isAssistantAuthoredMessage(item));
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && item.kind === "message" ? appendPlanDeltaToMessage(item, turnId, delta) : item,
itemIndex === index && isAssistantAuthoredMessage(item) ? appendPlanDeltaToMessage(item, turnId, delta) : item,
);
}
const text = normalizeProposedPlanMarkdown(delta);
@ -83,26 +85,26 @@ export function appendPlanDelta(items: readonly DisplayItem[], itemId: string, t
{
id: itemId,
kind: "message",
messageKind: "proposedPlan",
role: "assistant",
text,
copyText: text,
turnId,
itemId,
markdown: false,
proposedPlan: true,
messageState: "streaming",
},
];
}
function appendPlanDeltaToMessage(item: Extract<DisplayItem, { kind: "message" }>, turnId: string, delta: string): DisplayItem {
function appendPlanDeltaToMessage(item: AssistantAuthoredMessageDisplayItem, turnId: string, delta: string): DisplayItem {
const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`);
return {
...item,
messageKind: "proposedPlan",
text,
copyText: text,
turnId: item.turnId ?? turnId,
markdown: false,
proposedPlan: true,
messageState: "streaming",
};
}

View file

@ -16,7 +16,6 @@ export function createStructuredSystemItem(id: string, text: string, details: Di
kind: "system",
role: "system",
text,
markdown: false,
details,
};
}

View file

@ -102,6 +102,7 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display
return {
id: item.id,
kind: "message",
messageKind: "user",
role: "user",
text: userMessageDisplayText(referencedThread.text, item.content),
copyText: referencedThread.text,
@ -109,20 +110,19 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display
...definedProp("turnId", turnId),
...definedProp("clientId", item.clientId),
itemId: item.id,
markdown: true,
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
};
}
return {
id: item.id,
kind: "message",
messageKind: "user",
role: "user",
text: displayText,
copyText: text,
...definedProp("turnId", turnId),
...definedProp("clientId", item.clientId),
itemId: item.id,
markdown: true,
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
};
}
@ -236,12 +236,13 @@ function agentMessageDisplayItem(item: AgentMessageItem, turnId?: string): Displ
return {
id: item.id,
kind: "message",
messageKind: "assistantResponse",
role: "assistant",
text: item.text,
copyText: item.text,
...definedProp("turnId", turnId),
itemId: item.id,
markdown: true,
messageState: "completed",
};
}
@ -250,13 +251,13 @@ function planDisplayItem(item: PlanItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "message",
messageKind: "proposedPlan",
role: "assistant",
text,
copyText: text,
...definedProp("turnId", turnId),
itemId: item.id,
markdown: true,
proposedPlan: true,
messageState: "completed",
};
}

View file

@ -0,0 +1,9 @@
import type { AssistantAuthoredMessageDisplayItem, DisplayItem } from "./types";
export function isAssistantAuthoredMessage(item: DisplayItem): item is AssistantAuthoredMessageDisplayItem {
return item.kind === "message" && (item.messageKind === "assistantResponse" || item.messageKind === "proposedPlan");
}
export function isCompletedTurnOutcomeMessage(item: DisplayItem): boolean {
return isAssistantAuthoredMessage(item) && item.messageState === "completed";
}

View file

@ -15,6 +15,8 @@ export type DisplayKind =
| "reviewResult";
export type DisplayRole = "user" | "assistant" | "system" | "tool";
export type ExecutionState = "running" | "completed" | "failed" | null;
export type MessageKind = "user" | "assistantResponse" | "proposedPlan";
export type MessageState = "streaming" | "completed";
export interface DisplayBase {
id: string;
@ -37,20 +39,40 @@ export interface DisplayDetailSection {
rows?: DisplayDetailMetaRow[];
}
export interface MessageDisplayItem extends DisplayBase {
interface MessageDisplayBase extends DisplayBase {
kind: "message";
role: "user" | "assistant";
clientId?: string;
copyText?: string;
referencedThread?: ReferencedThreadDisplay;
mentionedFiles?: DisplayFileMention[];
proposedPlan?: boolean;
editedFiles?: string[];
turnDiff?: DisplayTurnDiff;
autoReviewSummaries?: string[];
markdown?: boolean;
}
export interface UserMessageDisplayItem extends MessageDisplayBase {
messageKind: "user";
role: "user";
messageState?: never;
}
export interface AssistantResponseMessageDisplayItem extends MessageDisplayBase {
messageKind: "assistantResponse";
role: "assistant";
messageState: MessageState;
}
export interface ProposedPlanMessageDisplayItem extends MessageDisplayBase {
messageKind: "proposedPlan";
role: "assistant";
messageState: MessageState;
}
export type AssistantAuthoredMessageDisplayItem = AssistantResponseMessageDisplayItem | ProposedPlanMessageDisplayItem;
export type MessageDisplayItem = UserMessageDisplayItem | AssistantAuthoredMessageDisplayItem;
export interface DisplayFileMention {
name: string;
path: string;
@ -63,21 +85,18 @@ export interface DisplayTurnDiff {
export interface SystemDisplayItem extends DisplayBase {
kind: "system" | "userInputResult";
role: "system" | "tool";
markdown?: boolean;
details?: DisplayDetailSection[];
}
export interface ApprovalResultDisplayItem extends DisplayBase {
kind: "approvalResult";
role: "tool";
markdown?: boolean;
details?: DisplayDetailSection[];
}
export interface ReviewResultDisplayItem extends DisplayBase {
kind: "reviewResult";
role: "tool";
markdown?: boolean;
details?: DisplayDetailSection[];
}

View file

@ -1,5 +1,5 @@
import type { DisplayItem } from "./display/types";
import { isFinalAssistantMessage } from "./display/final-assistant";
import { isCompletedTurnOutcomeMessage } from "./display/turn-outcome-message";
export interface ForkCandidate {
itemId: string;
@ -7,12 +7,12 @@ export interface ForkCandidate {
}
export function forkCandidatesFromItems(items: readonly DisplayItem[]): readonly ForkCandidate[] {
const finalAssistantItemsByTurn = new Map<string, ForkCandidate>();
const turnOutcomeItemsByTurn = new Map<string, ForkCandidate>();
for (const item of items) {
if (!item.turnId || !isFinalAssistantMessage(item)) continue;
finalAssistantItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId });
if (!item.turnId || !isCompletedTurnOutcomeMessage(item)) continue;
turnOutcomeItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId });
}
return [...finalAssistantItemsByTurn.values()];
return [...turnOutcomeItemsByTurn.values()];
}
export function isForkCandidateItem(item: DisplayItem, candidates: readonly ForkCandidate[]): boolean {

View file

@ -7,8 +7,5 @@ export function implementPlanCandidateFromState(
if (!state.activeThreadId || chatTurnBusy(state) || state.composerDraft.trim().length > 0 || state.selectedCollaborationMode !== "plan") {
return null;
}
return (
[...state.displayItems].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ??
null
);
return [...state.displayItems].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null;
}

View file

@ -57,7 +57,6 @@ export function createApprovalResultItem(approval: PendingApproval, action: Appr
role: "tool",
text: approvalResultText(approval, action),
...definedProp("turnId", approvalTurnId(approval)),
markdown: false,
executionState: kind === "accept" || kind === "accept-session" ? "completed" : "failed",
details: [
{
@ -93,7 +92,6 @@ export function createUserInputResultItem(
role: "tool",
text: status === "submitted" ? `Input submitted for ${label}.` : `Input request cancelled for ${label}.`,
...definedProp("turnId", input.params.turnId),
markdown: false,
executionState: status === "submitted" ? "completed" : "failed",
details,
};

View file

@ -1,5 +1,6 @@
import type { ThreadNamingContext } from "../../domain/threads/naming";
import { truncate } from "../../utils";
import { isCompletedTurnOutcomeMessage } from "./display/turn-outcome-message";
import type { DisplayItem } from "./display/types";
const MAX_CONTEXT_CHARS = 4_000;
@ -7,11 +8,7 @@ const MAX_CONTEXT_CHARS = 4_000;
export function namingContextFromDisplayItems(turnId: string, items: readonly DisplayItem[]): ThreadNamingContext | null {
const turnItems = items.filter((item) => item.turnId === turnId);
const userRequest = turnItems.find((item) => item.kind === "message" && item.role === "user")?.text.trim() ?? "";
const assistantResponse =
[...turnItems]
.reverse()
.find((item) => item.kind === "message" && item.role === "assistant")
?.text.trim() ?? "";
const assistantResponse = [...turnItems].reverse().find(isCompletedTurnOutcomeMessage)?.text.trim() ?? "";
if (!userRequest || !assistantResponse) return null;
return {
userRequest: truncateForPrompt(userRequest),

View file

@ -48,9 +48,10 @@ export function messageStreamActiveTurnId(context: Pick<MessageStreamContext, "t
return activeTurnId(context);
}
type RenderableMessageItem = Extract<DisplayItem, { kind: "message" | "system" | "userInputResult" }>;
type RenderableTextItem = Extract<DisplayItem, { kind: "message" | "system" | "userInputResult" }>;
type TextRenderMode = "markdown" | "text";
function isRenderableMessageItem(item: DisplayItem): item is RenderableMessageItem {
function isRenderableTextItem(item: DisplayItem): item is RenderableTextItem {
return item.kind === "message" || item.kind === "system" || item.kind === "userInputResult";
}
@ -70,7 +71,7 @@ function isRenderableWorkItem(item: DisplayItem): item is WorkItemDisplayItem {
}
function displayItemNode(item: DisplayItem, context: MessageStreamContext): UiNode {
if (isRenderableMessageItem(item)) return <MessageItem item={item} context={context} />;
if (isRenderableTextItem(item)) return <MessageItem item={item} context={context} />;
if (isRenderableToolResultItem(item)) return toolResultNode(item, context);
if (isRenderableWorkItem(item)) return workItemNode(item, context);
}
@ -231,7 +232,7 @@ function ActivityGroup({
);
}
function MessageItem({ item, context }: { item: RenderableMessageItem; context: MessageStreamContext }): UiNode {
function MessageItem({ item, context }: { item: RenderableTextItem; context: MessageStreamContext }): UiNode {
const collapsible = isCollapsibleUserMessage(item);
const details = "details" in item ? item.details : undefined;
return (
@ -240,7 +241,7 @@ function MessageItem({ item, context }: { item: RenderableMessageItem; context:
{collapsible ? (
<CollapsibleMessageContent item={item} context={context} />
) : (
<MarkdownContent key={messageContentKey(item)} item={item} context={context} />
<TextContent key={messageContentKey(item)} item={item} context={context} />
)}
{item.kind === "message" && item.editedFiles && item.editedFiles.length > 0 ? <EditedFiles item={item} context={context} /> : null}
{item.kind === "message" && item.referencedThread ? <ReferencedThread item={item} /> : null}
@ -259,7 +260,7 @@ function MessageItem({ item, context }: { item: RenderableMessageItem; context:
);
}
function MessageRole({ item, context }: { item: RenderableMessageItem; context: MessageStreamContext }): UiNode {
function MessageRole({ item, context }: { item: RenderableTextItem; context: MessageStreamContext }): UiNode {
const forkActionsKey = `message:fork-actions:${item.id}`;
const forkActionsOpen = context.openDetails.has(forkActionsKey);
const roleRef = useRef<HTMLDivElement | null>(null);
@ -364,8 +365,9 @@ function MessageAction({
);
}
function CollapsibleMessageContent({ item, context }: { item: RenderableMessageItem; context: MessageStreamContext }): UiNode {
function CollapsibleMessageContent({ item, context }: { item: RenderableTextItem; context: MessageStreamContext }): UiNode {
const key = `message:${item.id}:expanded`;
const renderModeKey = contentRenderMode(item);
const contentRef = useRef<HTMLDivElement | null>(null);
const [overflows, setOverflows] = useState(false);
const [expanded, setExpanded] = useState(context.openDetails.has(key));
@ -386,7 +388,7 @@ function CollapsibleMessageContent({ item, context }: { item: RenderableMessageI
return () => {
content.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update);
};
}, [item.id, item.text, item.markdown]);
}, [item.id, item.text, renderModeKey]);
return (
<div
@ -398,13 +400,7 @@ function CollapsibleMessageContent({ item, context }: { item: RenderableMessageI
.filter(Boolean)
.join(" ")}
>
<MarkdownContent
key={messageContentKey(item)}
item={item}
context={context}
contentRef={contentRef}
collapsed={overflows && !expanded}
/>
<TextContent key={messageContentKey(item)} item={item} context={context} contentRef={contentRef} collapsed={overflows && !expanded} />
<details
className="codex-panel__message-collapse-details"
hidden={!overflows || expanded}
@ -421,14 +417,17 @@ function CollapsibleMessageContent({ item, context }: { item: RenderableMessageI
);
}
interface MarkdownContentProps {
item: RenderableMessageItem;
interface TextContentProps {
item: RenderableTextItem;
context: MessageStreamContext;
contentRef?: Ref<HTMLDivElement>;
collapsed?: boolean;
}
function MarkdownContent({ item, context, contentRef, collapsed = false }: MarkdownContentProps): UiNode {
function TextContent({ item, context, contentRef, collapsed = false }: TextContentProps): UiNode {
const renderModeKey = contentRenderMode(item);
const rendersMarkdown = renderModeKey === "markdown";
const text = item.text;
const localRef = useRef<HTMLDivElement | null>(null);
const contextRef = useRef(context);
useLayoutEffect(() => {
@ -439,12 +438,12 @@ function MarkdownContent({ item, context, contentRef, collapsed = false }: Markd
if (!content) return;
const currentContext = contextRef.current;
content.replaceChildren();
if (item.markdown === false) {
content.textContent = item.text;
if (rendersMarkdown) {
currentContext.renderMarkdown(content, text);
} else {
currentContext.renderMarkdown(content, item.text);
content.textContent = text;
}
}, [item.markdown, item.text]);
}, [renderModeKey, rendersMarkdown, text]);
return (
<div
ref={(element) => {
@ -457,7 +456,7 @@ function MarkdownContent({ item, context, contentRef, collapsed = false }: Markd
}}
className={[
"codex-panel__message-content",
item.markdown === false ? "" : "markdown-rendered",
rendersMarkdown ? "markdown-rendered" : "",
collapsed ? "codex-panel__message-content--collapsed" : "",
]
.filter(Boolean)
@ -652,8 +651,13 @@ function RememberedDetails({
return wrapperClassName ? <div className={wrapperClassName}>{details}</div> : details;
}
function messageContentKey(item: RenderableMessageItem): string {
return `${item.id}\u001f${item.markdown === false ? "text" : "markdown"}\u001f${item.text}`;
function messageContentKey(item: RenderableTextItem): string {
return `${item.id}\u001f${contentRenderMode(item)}\u001f${item.text}`;
}
function contentRenderMode(item: RenderableTextItem): TextRenderMode {
if (item.kind !== "message") return "text";
return item.messageKind === "proposedPlan" && item.messageState === "streaming" ? "text" : "markdown";
}
function executionClassName(state: ReturnType<typeof executionState>): string {

View file

@ -17,7 +17,7 @@ export function openPanelTurnLifecycle(state: ChatState["turnLifecycle"]): OpenC
}
export function latestProposedPlanItem(items: readonly DisplayItem[]): DisplayItem | null {
return [...items].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ?? null;
return [...items].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null;
}
export function toolbarSlotSnapshot(state: ChatState, connected: boolean): ChatPanelSlotSnapshot {

View file

@ -66,7 +66,7 @@ describe("ChatController", () => {
params: { threadId: "thread-active", turnId: "turn-active", itemId: "a1", delta: "hello" },
} satisfies Extract<ServerNotification, { method: "item/agentMessage/delta" }>);
expect(state.displayItems).toMatchObject([{ id: "a1", text: "hello", markdown: true }]);
expect(state.displayItems).toMatchObject([{ id: "a1", text: "hello" }]);
});
it("marks active reasoning completed when assistant text starts", () => {
@ -100,7 +100,49 @@ describe("ChatController", () => {
params: { threadId: "thread-active", turnId: "turn-active", itemId: "p1", delta: "<proposed_plan>\n# Plan" },
} satisfies Extract<ServerNotification, { method: "item/plan/delta" }>);
expect(state.displayItems).toMatchObject([{ id: "p1", kind: "message", role: "assistant", text: "# Plan", markdown: false }]);
expect(state.displayItems).toMatchObject([
{ id: "p1", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "# Plan", messageState: "streaming" },
]);
});
it("marks streamed plan deltas completed when the completed turn reconciles", () => {
const state = createChatState();
state.activeThreadId = "thread-active";
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
const controller = controllerForState(state);
controller.handleNotification({
method: "item/plan/delta",
params: { threadId: "thread-active", turnId: "turn-active", itemId: "p1", delta: "<proposed_plan>\n# Plan" },
} satisfies Extract<ServerNotification, { method: "item/plan/delta" }>);
controller.handleNotification({
method: "turn/completed",
params: {
threadId: "thread-active",
turn: {
id: "turn-active",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
itemsView: "full",
items: [{ type: "plan", id: "p1", text: "<proposed_plan>\n# Plan\n</proposed_plan>" }],
},
},
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
expect(state.displayItems).toEqual([
expect.objectContaining({
id: "p1",
kind: "message",
role: "assistant",
text: "# Plan",
messageKind: "proposedPlan",
messageState: "completed",
}),
]);
});
it("updates structured turn plan progress", () => {
@ -385,7 +427,7 @@ describe("ChatController", () => {
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
};
state.displayItems = [
{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true },
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
{
id: "hook-hook-1-1",
kind: "hook",
@ -438,7 +480,7 @@ describe("ChatController", () => {
toolLabel: "hook",
status: "completed",
},
{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true },
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
],
"turn-active",
["hook-hook-1-1"],
@ -489,7 +531,7 @@ describe("ChatController", () => {
const state = createChatState();
state.activeThreadId = "thread-active";
state.turnLifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } };
state.displayItems = [{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true }];
state.displayItems = [{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" }];
const controller = controllerForState(state);
controller.handleNotification({
@ -562,7 +604,7 @@ describe("ChatController", () => {
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
};
state.displayItems = [
{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true },
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
{
id: "hook-hook-1-1",
kind: "hook",
@ -920,7 +962,9 @@ describe("ChatController", () => {
};
state.historyCursor = "cursor";
state.loadingHistory = true;
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "stale" }];
state.displayItems = [
{ id: "message", kind: "message", role: "assistant", text: "stale", messageKind: "assistantResponse", messageState: "completed" },
];
state.turnDiffs = new Map([["turn-active", "@@\n-stale\n+stale"]]);
state.composerDraft = "thread draft";
state.approvals = [
@ -1004,15 +1048,16 @@ describe("ChatController", () => {
state.activeThreadId = "thread-active";
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
state.displayItems = [
{ id: "local-user-1", kind: "message", role: "user", text: "hello", turnId: "turn-active", markdown: true },
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello", turnId: "turn-active" },
{
id: "a1",
itemId: "a1",
kind: "message",
role: "assistant",
messageKind: "assistantResponse",
messageState: "completed",
text: "partial",
turnId: "turn-active",
markdown: false,
},
];
const controller = controllerForState(state);
@ -1040,7 +1085,7 @@ describe("ChatController", () => {
expect(state.displayItems.filter((item) => item.kind === "message" && item.role === "user")).toEqual([
expect.objectContaining({ id: "u1", text: "hello" }),
]);
expect(state.displayItems).toEqual(expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done", markdown: true })]));
expect(state.displayItems).toEqual(expect.arrayContaining([expect.objectContaining({ id: "a1", text: "done" })]));
expect(state.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
});
@ -1049,9 +1094,9 @@ describe("ChatController", () => {
state.activeThreadId = "thread-active";
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
state.displayItems = [
{ id: "local-user-1", kind: "message", role: "user", text: "same text", turnId: "turn-active", markdown: true },
{ id: "local-steer-2", kind: "message", role: "user", text: "same text", turnId: "turn-active", markdown: true },
{ id: "local-user-2", kind: "message", role: "user", text: "same text", turnId: "turn-other", markdown: true },
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" },
{ id: "local-steer-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-active" },
{ id: "local-user-2", kind: "message", messageKind: "user", role: "user", text: "same text", turnId: "turn-other" },
];
const controller = controllerForState(state);
@ -1093,7 +1138,7 @@ describe("ChatController", () => {
legacyState.activeThreadId = "thread-active";
legacyState.turnLifecycle = { kind: "running", turnId: "turn-active" };
legacyState.displayItems = [
{ id: "local-user-legacy", kind: "message", role: "user", text: "legacy text", turnId: "turn-active", markdown: true },
{ id: "local-user-legacy", kind: "message", messageKind: "user", role: "user", text: "legacy text", turnId: "turn-active" },
];
const legacyController = controllerForState(legacyState);
@ -1125,17 +1170,18 @@ describe("ChatController", () => {
state.activeThreadId = "thread-active";
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
state.displayItems = [
{ id: "local-user-1", kind: "message", role: "user", text: "start", turnId: "turn-active", markdown: true },
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "start", turnId: "turn-active" },
{
id: "a1",
itemId: "a1",
kind: "message",
role: "assistant",
messageKind: "assistantResponse",
messageState: "completed",
text: "first partial",
turnId: "turn-active",
markdown: true,
},
{ id: "local-steer-1", kind: "message", role: "user", text: "steer", turnId: "turn-active", markdown: true },
{ id: "local-steer-1", kind: "message", messageKind: "user", role: "user", text: "steer", turnId: "turn-active" },
];
const controller = controllerForState(state);
@ -1389,7 +1435,6 @@ describe("ChatController", () => {
kind: "reviewResult",
role: "tool",
text: "Auto-review denied this command.",
markdown: false,
},
]);
});

View file

@ -116,7 +116,17 @@ describe("ChatMessageRenderer scroll pinning", () => {
it("pins to the scroll container bottom without aligning the last message element", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Streaming message", turnId: "turn" }];
state.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Streaming message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
@ -138,7 +148,17 @@ describe("ChatMessageRenderer scroll pinning", () => {
it("can repin the current scroll container after composer growth shrinks the viewport", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Streaming message", turnId: "turn" }];
state.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Streaming message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
@ -161,7 +181,17 @@ describe("ChatMessageRenderer scroll pinning", () => {
it("repins after composer growth has changed the scroll viewport height", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Streaming message", turnId: "turn" }];
state.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Streaming message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
@ -200,7 +230,17 @@ describe("ChatMessageRenderer scroll pinning", () => {
it("does not force the bottom into view when the user is reading older messages", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Initial message", turnId: "turn" }];
state.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Initial message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
@ -216,7 +256,17 @@ describe("ChatMessageRenderer scroll pinning", () => {
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
scrollIntoView.mockClear();
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Updated streaming message", turnId: "turn" }];
state.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Updated streaming message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
renderer.render(messages);
await settleMessageRender(messages);
@ -227,7 +277,17 @@ describe("ChatMessageRenderer scroll pinning", () => {
it("does not run a pending bottom pin after the user scrolls away", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Streaming message", turnId: "turn" }];
state.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Streaming message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
@ -250,7 +310,17 @@ describe("ChatMessageRenderer scroll pinning", () => {
it("unmounts the Preact message stream root on dispose", () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Rendered message", turnId: "turn" }];
state.displayItems = [
{
id: "message",
kind: "message",
role: "assistant",
text: "Rendered message",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);

View file

@ -153,7 +153,7 @@ describe("chatReducer", () => {
it("keeps turn lifecycle fields synchronized through start and completion", () => {
const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: [] };
const optimisticItem = { id: "local-user", kind: "message", role: "user", text: "hello" } satisfies DisplayItem;
const optimisticItem = { id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello" } satisfies DisplayItem;
const acknowledgedItem = { ...optimisticItem, turnId: "turn" } satisfies DisplayItem;
const optimistic = chatReducer(createChatState(), {
@ -217,7 +217,7 @@ describe("chatReducer", () => {
const next = chatReducer(state, {
type: "turn/start-acknowledged",
turnId: "completed-turn",
displayItems: [{ id: "local-user", kind: "message", role: "user", text: "hello", markdown: true, turnId: "completed-turn" }],
displayItems: [{ id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello", turnId: "completed-turn" }],
});
expect(next).toBe(state);
@ -229,7 +229,7 @@ describe("chatReducer", () => {
const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] };
const state = createChatState();
state.turnLifecycle = { kind: "starting", pendingTurnStart: pending };
state.displayItems = [{ id: "local-user", kind: "message", role: "user", text: "hello", markdown: true }];
state.displayItems = [{ id: "local-user", kind: "message", messageKind: "user", role: "user", text: "hello" }];
const next = chatReducer(state, {
type: "turn/completed",
@ -396,7 +396,7 @@ describe("chatReducer", () => {
});
function message(id: string): DisplayItem {
return { id, kind: "message", role: "assistant", text: id };
return { id, kind: "message", role: "assistant", text: id, messageKind: "assistantResponse", messageState: "completed" };
}
function suggestion(display: string): ChatState["composerSuggestions"][number] {

View file

@ -15,7 +15,8 @@ const planItem = (id: string): DisplayItem => ({
kind: "message",
role: "assistant",
text: "Plan",
proposedPlan: true,
messageKind: "proposedPlan",
messageState: "completed",
});
function resumeThread(stateStore: ChatStateStore, displayItems: readonly DisplayItem[]): void {

View file

@ -17,7 +17,7 @@ describe("chat turn submission helpers", () => {
mentionedFiles.push({ name: "Other", path: "Other.md" });
expect(item).toMatchObject({ id: "local", kind: "message", role: "user", text: "hello", copyText: "hello", markdown: true });
expect(item).toMatchObject({ id: "local", kind: "message", messageKind: "user", role: "user", text: "hello", copyText: "hello" });
expect(item.mentionedFiles).toEqual([{ name: "Note", path: "Note.md" }]);
});
@ -33,6 +33,7 @@ describe("chat turn submission helpers", () => {
expect(start.item).toMatchObject({
id: "local-user",
kind: "message",
messageKind: "user",
role: "user",
text: "hello [[Note]]",
mentionedFiles: [{ name: "Note", path: "Note.md" }],
@ -93,7 +94,7 @@ describe("chat turn submission helpers", () => {
const items: DisplayItem[] = [
localUserMessageItem({ id: "local-user", text: "hello" }),
hookItem("hook-1"),
{ id: "assistant", kind: "message", role: "assistant", text: "working" },
{ id: "assistant", kind: "message", role: "assistant", text: "working", messageKind: "assistantResponse", messageState: "completed" },
];
const next = acknowledgeOptimisticTurnStart({

View file

@ -77,8 +77,8 @@ describe("thread item conversion preserves app-server semantics", () => {
];
expect(displayItemsFromTurns(turns).map((item) => item.text)).toEqual(["hello", "world"]);
expect(displayItemFromThreadItem(userMessage)).toMatchObject({ role: "user", copyText: "hello", markdown: true });
expect(displayItemFromThreadItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world", markdown: true });
expect(displayItemFromThreadItem(userMessage)).toMatchObject({ role: "user", copyText: "hello" });
expect(displayItemFromThreadItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world" });
});
it("keeps resolved file mentions visible as user message metadata", () => {
@ -96,6 +96,7 @@ describe("thread item conversion preserves app-server semantics", () => {
expect(displayItemFromThreadItem(userMessage)).toMatchObject({
kind: "message",
messageKind: "user",
role: "user",
text: "Read [[Alpha]] and [[Beta]].",
mentionedFiles: [
@ -190,8 +191,8 @@ describe("thread item conversion preserves app-server semantics", () => {
role: "assistant",
text: "# Plan",
copyText: "# Plan",
markdown: true,
proposedPlan: true,
messageKind: "proposedPlan",
messageState: "completed",
turnId: "t1",
});
});
@ -204,7 +205,15 @@ describe("thread item conversion preserves app-server semantics", () => {
const items = appendAssistantDelta([], "a1", "t1", "**Hello**");
const updated = appendAssistantDelta(items, "a1", "t1", "\n\n- world");
expect(updated).toMatchObject([
{ id: "a1", kind: "message", role: "assistant", text: "**Hello**\n\n- world", copyText: "**Hello**\n\n- world", markdown: true },
{
id: "a1",
kind: "message",
role: "assistant",
text: "**Hello**\n\n- world",
copyText: "**Hello**\n\n- world",
messageKind: "assistantResponse",
messageState: "streaming",
},
]);
});
@ -212,7 +221,15 @@ describe("thread item conversion preserves app-server semantics", () => {
const items = appendPlanDelta([], "p1", "t1", "<proposed_plan>\n# Plan");
const updated = appendPlanDelta(items, "p1", "t1", "\n</proposed_plan>");
expect(updated).toMatchObject([
{ id: "p1", kind: "message", role: "assistant", text: "# Plan", copyText: "# Plan", markdown: false, proposedPlan: true },
{
id: "p1",
kind: "message",
role: "assistant",
text: "# Plan",
copyText: "# Plan",
messageKind: "proposedPlan",
messageState: "streaming",
},
]);
});
@ -865,7 +882,15 @@ describe("thread item conversion preserves app-server semantics", () => {
describe("streaming updates target item identity without mutating history", () => {
it("upserts assistant deltas by item id, not by last position", () => {
const items: DisplayItem[] = [
{ id: "a1", itemId: "a1", kind: "message", role: "assistant", text: "hello" },
{
id: "a1",
itemId: "a1",
kind: "message",
role: "assistant",
text: "hello",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "tool1", itemId: "tool1", kind: "tool", role: "tool", text: "tool" },
];
@ -906,10 +931,18 @@ describe("streaming updates target item identity without mutating history", () =
describe("display block grouping keeps work logs subordinate to conversation messages", () => {
it("groups completed turn activities before the final assistant message", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" },
commandItem("c1", "npm test", "t1"),
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -919,7 +952,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("groups completed hook and review logs before the final assistant message", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{
id: "hook-1",
kind: "hook",
@ -947,7 +980,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
turnId: "t1",
executionState: "completed",
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -964,9 +1005,17 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("hides empty completed reasoning items", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{ id: "r1", kind: "reasoning", role: "tool", text: "", turnId: "t1", status: "completed", executionState: "completed" },
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -975,12 +1024,36 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("collapses earlier assistant responses with their turn details", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "a1", kind: "message", role: "assistant", text: "I'll inspect it.", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "I'll inspect it.",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
commandItem("c1", "rg issue", "t1"),
{ id: "a2", kind: "message", role: "assistant", text: "I found the cause.", turnId: "t1" },
{
id: "a2",
kind: "message",
role: "assistant",
text: "I found the cause.",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
fileChangeItem("f1", "t1"),
{ id: "a3", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a3",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -997,7 +1070,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
{ id: "s1", kind: "system", role: "system", text: "debug before hook" },
commandItem("c1", "npm test", "t1"),
{ id: "s2", kind: "system", role: "system", text: "debug after hook" },
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -1007,11 +1088,19 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("adds steering markers to completed turn work details without hiding the steering message", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
commandItem("c1", "rg issue", "t1"),
{ id: "u2", kind: "message", role: "user", text: "also check tests", turnId: "t1", clientId: "local-steer-1" },
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "also check tests", turnId: "t1", clientId: "local-steer-1" },
commandItem("c2", "npm test", "t1"),
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -1025,10 +1114,18 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("pluralizes multiple steering markers as steers in completed turn work details", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "u2", kind: "message", role: "user", text: "also check tests", turnId: "t1" },
{ id: "u3", kind: "message", role: "user", text: "and update docs", turnId: "t1" },
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "also check tests", turnId: "t1" },
{ id: "u3", kind: "message", messageKind: "user", role: "user", text: "and update docs", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -1043,7 +1140,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("keeps active turn activities expanded", () => {
const items: DisplayItem[] = [
{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1" },
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
expect(displayBlocksForItems(items, "t1").map((block) => block.type)).toEqual(["item", "item"]);
@ -1051,7 +1156,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("keeps active task progress chronological in the display model", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{
id: "plan-progress-t1",
kind: "taskProgress",
@ -1062,7 +1167,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
steps: [{ step: "Patch UI", status: "inProgress" }],
status: "inProgress",
},
{ id: "a1", kind: "message", role: "assistant", text: "working", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "working",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, "t1");
@ -1072,7 +1185,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("summarizes task progress and agent activity separately from tools", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{
id: "plan-progress-t1",
kind: "taskProgress",
@ -1098,7 +1211,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
reasoningEffort: null,
agents: [{ threadId: "child", status: "completed", message: null }],
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const blocks = displayBlocksForItems(items, null);
@ -1219,8 +1340,24 @@ describe("display block grouping keeps work logs subordinate to conversation mes
const items: DisplayItem[] = [
fileChangeItem("f1", "t1", "src/main.ts"),
fileChangeItem("f2", "t1", "styles.css"),
{ id: "a1", kind: "message", role: "assistant", text: "intermediate", turnId: "t1" },
{ id: "a2", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "intermediate",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
{
id: "a2",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const assistantBlock = displayBlocksForItems(items, null).find((block) => block.type === "item" && block.item.role === "assistant");
@ -1230,7 +1367,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("adds turn diff metadata to the final assistant message only when aggregated diff exists", () => {
const items: DisplayItem[] = [
fileChangeItem("f1", "t1", "src/main.ts"),
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const withoutDiff = displayBlocksForItems(items, null).find((block) => block.type === "item" && block.item.role === "assistant");
@ -1261,7 +1406,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
turnId: "t1",
executionState: "completed",
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const assistantBlock = displayBlocksForItems(items, null).find((block) => block.type === "item" && block.item.role === "assistant");
@ -1281,7 +1434,15 @@ describe("display block grouping keeps work logs subordinate to conversation mes
turnId: "t1",
executionState: "completed",
},
{ id: "a1", kind: "message", role: "assistant", text: "still working", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "still working",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const assistantBlock = displayBlocksForItems(items, "t1").find((block) => block.type === "item" && block.item.id === "a1");
@ -1296,7 +1457,15 @@ describe("workspace path summaries stay readable without hiding audit paths", ()
fileChangeItem("f1", "t1", "/vault/project/src/main.ts"),
fileChangeItem("f2", "t1", "/vault/project/styles.css"),
fileChangeItem("f3", "t1", "/tmp/outside.txt"),
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const assistantBlock = displayBlocksForItems(items, null, "/vault/project").find(

View file

@ -6,13 +6,45 @@ import type { DisplayItem } from "../../../src/features/chat/display/types";
describe("fork candidates", () => {
it("selects final assistant messages and counts later turns", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "first", turnId: "turn-1", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "first answer", turnId: "turn-1", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "first", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "first answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" },
{ id: "a2-delta", kind: "message", role: "assistant", text: "draft", turnId: "turn-2", markdown: false },
{ id: "a2", kind: "message", role: "assistant", text: "second answer", turnId: "turn-2", markdown: true },
{ id: "u3", kind: "message", role: "user", text: "third", turnId: "turn-3", markdown: true },
{ id: "a3", kind: "message", role: "assistant", text: "third answer", turnId: "turn-3", markdown: true },
{
id: "a2-delta",
kind: "message",
role: "assistant",
text: "draft",
turnId: "turn-2",
messageKind: "proposedPlan",
messageState: "streaming",
},
{
id: "a2",
kind: "message",
role: "assistant",
text: "second answer",
turnId: "turn-2",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u3", kind: "message", messageKind: "user", role: "user", text: "third", turnId: "turn-3" },
{
id: "a3",
kind: "message",
role: "assistant",
text: "third answer",
turnId: "turn-3",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const candidates = forkCandidatesFromItems(items);

View file

@ -11,10 +11,26 @@ function expectPresent<T>(value: T | null | undefined): T {
describe("rollback candidate", () => {
it("selects the first user message from the latest turn", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "older", turnId: "turn-1", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "older answer", turnId: "turn-1", markdown: true },
{ id: "u2", kind: "message", role: "user", text: "latest", turnId: "turn-2", markdown: true },
{ id: "a2", kind: "message", role: "assistant", text: "latest answer", turnId: "turn-2", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "older answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "latest", turnId: "turn-2" },
{
id: "a2",
kind: "message",
role: "assistant",
text: "latest answer",
turnId: "turn-2",
messageKind: "assistantResponse",
messageState: "completed",
},
];
const candidate = rollbackCandidateFromItems(items);
@ -29,7 +45,17 @@ describe("rollback candidate", () => {
expect(rollbackCandidateFromItems([])).toBeNull();
expect(rollbackCandidateFromItems([{ id: "system", kind: "system", role: "system", text: "Idle" }])).toBeNull();
expect(
rollbackCandidateFromItems([{ id: "a1", kind: "message", role: "assistant", text: "answer", turnId: "turn-1", markdown: true }]),
rollbackCandidateFromItems([
{
id: "a1",
kind: "message",
role: "assistant",
text: "answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
]),
).toBeNull();
});
});

View file

@ -81,12 +81,36 @@ describe("ChatThreadActionController", () => {
function turnItems(): DisplayItem[] {
return [
{ id: "u1", kind: "message", role: "user", text: "one", turnId: "turn-1", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "one answer", turnId: "turn-1", markdown: true },
{ id: "u2", kind: "message", role: "user", text: "two", turnId: "turn-2", markdown: true },
{ id: "a2", kind: "message", role: "assistant", text: "two answer", turnId: "turn-2", markdown: true },
{ id: "u3", kind: "message", role: "user", text: "three", turnId: "turn-3", markdown: true },
{ id: "a3", kind: "message", role: "assistant", text: "three answer", turnId: "turn-3", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "one", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "one answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "two", turnId: "turn-2" },
{
id: "a2",
kind: "message",
role: "assistant",
text: "two answer",
turnId: "turn-2",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u3", kind: "message", messageKind: "user", role: "user", text: "three", turnId: "turn-3" },
{
id: "a3",
kind: "message",
role: "assistant",
text: "three answer",
turnId: "turn-3",
messageKind: "assistantResponse",
messageState: "completed",
},
];
}

View file

@ -64,8 +64,16 @@ describe("thread naming", () => {
it("extracts naming context from streamed display items when completed turn items are not loaded", () => {
expect(
namingContextFromDisplayItems("turn", [
{ id: "u1", kind: "message", role: "user", text: "自動命名を直したい", turnId: "turn", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "原因を直しました。", turnId: "turn", markdown: false },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "自動命名を直したい", turnId: "turn" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "原因を直しました。",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
]),
).toEqual({
userRequest: "自動命名を直したい",
@ -76,11 +84,27 @@ describe("thread naming", () => {
it("uses the first usable displayed turn as a resumed-history fallback", () => {
expect(
firstNamingContextFromDisplayItems([
{ id: "u1", kind: "message", role: "user", text: "本文だけのturn", turnId: "turn-1", markdown: true },
{ id: "u2", kind: "message", role: "user", text: "履歴から命名したい", turnId: "turn-2", markdown: true },
{ id: "a2", kind: "message", role: "assistant", text: "表示済み履歴から候補を作ります。", turnId: "turn-2", markdown: false },
{ id: "u3", kind: "message", role: "user", text: "後続turn", turnId: "turn-3", markdown: true },
{ id: "a3", kind: "message", role: "assistant", text: "後続応答", turnId: "turn-3", markdown: false },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "本文だけのturn", turnId: "turn-1" },
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "履歴から命名したい", turnId: "turn-2" },
{
id: "a2",
kind: "message",
role: "assistant",
text: "表示済み履歴から候補を作ります。",
turnId: "turn-2",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u3", kind: "message", messageKind: "user", role: "user", text: "後続turn", turnId: "turn-3" },
{
id: "a3",
kind: "message",
role: "assistant",
text: "後続応答",
turnId: "turn-3",
messageKind: "assistantResponse",
messageState: "completed",
},
]),
).toEqual({
userRequest: "履歴から命名したい",
@ -139,8 +163,16 @@ describe("thread naming", () => {
it("finds the first visible display item naming context", () => {
expect(
firstNamingContextFromDisplayItems([
{ id: "u1", kind: "message", role: "user", text: "表示済み履歴から命名したい", turnId: "visible", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "表示済み履歴を使います。", turnId: "visible", markdown: false },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "表示済み履歴から命名したい", turnId: "visible" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "表示済み履歴を使います。",
turnId: "visible",
messageKind: "assistantResponse",
messageState: "completed",
},
]),
).toEqual({
userRequest: "表示済み履歴から命名したい",

View file

@ -38,8 +38,16 @@ describe("message stream rendering and message actions", () => {
messageStreamBlocks({
...baseContext,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
}),
);
@ -48,7 +56,7 @@ describe("message stream rendering and message actions", () => {
messageStreamBlocks({
...baseContext,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
{
id: "hook-1",
kind: "hook",
@ -58,7 +66,15 @@ describe("message stream rendering and message actions", () => {
turnId: "t1",
status: "completed",
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "t1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
}),
);
@ -132,7 +148,7 @@ describe("message stream rendering and message actions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command.", markdown: false }],
displayItems: [{ id: "review-1", kind: "reviewResult", role: "tool", text: "Auto-review denied this command." }],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
@ -160,7 +176,6 @@ describe("message stream rendering and message actions", () => {
role: "tool",
text: "Auto-review approved: npm test",
turnId: "turn",
markdown: false,
executionState: "completed",
details: [
{
@ -278,6 +293,7 @@ describe("message stream rendering and message actions", () => {
});
it("renders structured system result details as visible selectable meta rows", () => {
const renderMarkdown = vi.fn((parent: HTMLElement, text: string) => parent.createDiv({ text: `markdown:${text}` }));
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
@ -289,7 +305,6 @@ describe("message stream rendering and message actions", () => {
kind: "system",
role: "system",
text: "Available slash commands",
markdown: false,
details: [
{
rows: [
@ -302,13 +317,15 @@ describe("message stream rendering and message actions", () => {
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
renderMarkdown,
})[0];
const element = renderMessageBlockElement(block);
expect(element.classList.contains("codex-panel__message--system")).toBe(true);
expect(element.querySelector(".codex-panel__message-content")?.textContent).toBe("Available slash commands");
expect(element.querySelector(".codex-panel__message-content")?.classList.contains("markdown-rendered")).toBe(false);
expect(renderMarkdown).not.toHaveBeenCalled();
expect(element.querySelector("details")).toBeNull();
expect(element.querySelector(".codex-panel__output-title")).toBeNull();
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("/helpShow available Codex slash commands.");
@ -318,9 +335,17 @@ describe("message stream rendering and message actions", () => {
it("renders rollback action only for the eligible user message", () => {
const onRollbackItem = vi.fn();
const items = [
{ id: "u1", kind: "message", role: "user", text: "older", turnId: "turn-1", markdown: true },
{ id: "a1", kind: "message", role: "assistant", text: "older answer", turnId: "turn-1", markdown: true },
{ id: "u2", kind: "message", role: "user", text: "latest", turnId: "turn-2", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "older answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
{ id: "u2", kind: "message", messageKind: "user", role: "user", text: "latest", turnId: "turn-2" },
] as const;
const blocks = messageStreamBlocks({
activeThreadId: "thread",
@ -353,8 +378,17 @@ describe("message stream rendering and message actions", () => {
historyCursor: null,
loadingHistory: 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 },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "rendered user", copyText: "**user**", turnId: "turn-1" },
{
id: "a1",
kind: "message",
role: "assistant",
text: "rendered answer",
copyText: "# Answer",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
@ -381,10 +415,11 @@ describe("message stream rendering and message actions", () => {
id: "a1",
kind: "message",
role: "assistant",
messageKind: "assistantResponse",
messageState: "completed",
text: "answer",
copyText: "answer",
turnId: "turn-1",
markdown: true,
};
const closedBlock = messageStreamBlocks({
@ -441,10 +476,11 @@ describe("message stream rendering and message actions", () => {
id: "a1",
kind: "message",
role: "assistant",
messageKind: "assistantResponse",
messageState: "completed",
text: "answer",
copyText: "answer",
turnId: "turn-1",
markdown: true,
};
const block = messageStreamBlocks({
activeThreadId: "thread",
@ -487,8 +523,8 @@ describe("message stream rendering and message actions", () => {
text: "Plan",
copyText: "Plan",
turnId: "turn-1",
markdown: false,
proposedPlan: true,
messageKind: "proposedPlan",
messageState: "streaming",
},
],
openDetails: new Set(),
@ -522,7 +558,17 @@ describe("message stream rendering and message actions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }],
displayItems: [
{
id: "a1",
kind: "message",
role: "assistant",
text: "**answer**",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown,
@ -534,7 +580,7 @@ describe("message stream rendering and message actions", () => {
unmountUiRootInAct(parent);
});
it("updates message content when the markdown mode changes", () => {
it("updates message content when a streaming plan delta completes", () => {
const parent = document.createElement("div");
const baseContext = {
activeThreadId: "thread",
@ -550,7 +596,17 @@ describe("message stream rendering and message actions", () => {
parent,
messageStreamBlocks({
...baseContext,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer** [[Note]]", turnId: "turn-1", markdown: false }],
displayItems: [
{
id: "a1",
kind: "message",
role: "assistant",
text: "**answer** [[Note]]",
turnId: "turn-1",
messageKind: "proposedPlan",
messageState: "streaming",
},
],
}),
);
expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("**answer** [[Note]]");
@ -560,7 +616,17 @@ describe("message stream rendering and message actions", () => {
parent,
messageStreamBlocks({
...baseContext,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "**answer**", turnId: "turn-1", markdown: true }],
displayItems: [
{
id: "a1",
kind: "message",
role: "assistant",
text: "**answer**",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
}),
);
expect(parent.querySelector(".codex-panel__message-content")?.textContent).toBe("markdown:**answer**");
@ -586,7 +652,17 @@ describe("message stream rendering and message actions", () => {
parent,
messageStreamBlocks({
...baseContext,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "first", turnId: "turn-1", markdown: true }],
displayItems: [
{
id: "a1",
kind: "message",
role: "assistant",
text: "first",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
}),
);
expect(parent.querySelector('[data-codex-panel-block-key="item:a1"] .codex-panel__message-content')?.textContent).toBe(
@ -597,7 +673,17 @@ describe("message stream rendering and message actions", () => {
parent,
messageStreamBlocks({
...baseContext,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "second", turnId: "turn-1", markdown: true }],
displayItems: [
{
id: "a1",
kind: "message",
role: "assistant",
text: "second",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
}),
);
@ -613,10 +699,11 @@ describe("message stream rendering and message actions", () => {
itemId: "a-running",
kind: "message",
role: "assistant",
messageKind: "assistantResponse",
messageState: "completed",
text: "partial",
copyText: "partial",
turnId: "turn-1",
markdown: false,
} as const;
const context = {
activeThreadId: "thread",
@ -652,8 +739,8 @@ describe("message stream rendering and message actions", () => {
text: "# Plan",
copyText: "# Plan",
turnId: "turn-1",
markdown: true,
proposedPlan: true,
messageKind: "proposedPlan",
messageState: "completed",
},
],
openDetails: new Set(),
@ -686,8 +773,8 @@ describe("message stream rendering and message actions", () => {
text: "# Plan",
copyText: "# Plan",
turnId: "turn-1",
markdown: true,
proposedPlan: true,
messageKind: "proposedPlan",
messageState: "completed",
},
],
openDetails: new Set(),
@ -713,7 +800,8 @@ describe("message stream rendering and message actions", () => {
role: "assistant",
text: "# First plan",
turnId: "turn-1",
proposedPlan: true,
messageKind: "proposedPlan",
messageState: "completed",
} as const;
const secondPlan = {
id: "p2",
@ -721,14 +809,26 @@ describe("message stream rendering and message actions", () => {
role: "assistant",
text: "# Second plan",
turnId: "turn-2",
proposedPlan: true,
messageKind: "proposedPlan",
messageState: "completed",
} as const;
const baseState = {
activeThreadId: "thread",
turnLifecycle: { kind: "idle" as const },
composerDraft: "",
selectedCollaborationMode: "plan" as const,
displayItems: [firstPlan, { id: "a1", kind: "message", role: "assistant", text: "answer" } as const, secondPlan],
displayItems: [
firstPlan,
{
id: "a1",
kind: "message",
role: "assistant",
text: "answer",
messageKind: "assistantResponse",
messageState: "completed",
} as const,
secondPlan,
],
};
expect(implementPlanCandidateFromState(baseState)).toBe(secondPlan);
@ -770,7 +870,9 @@ describe("message stream rendering and message actions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "u1", kind: "message", role: "user", text: "latest", copyText: "latest", turnId: "turn-1", markdown: true }],
displayItems: [
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "latest", copyText: "latest", turnId: "turn-1" },
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
@ -804,7 +906,15 @@ describe("message stream rendering and message actions", () => {
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "visible text", copyText: "full copied text", turnId: "turn-1", markdown: true },
{
id: "u1",
kind: "message",
messageKind: "user",
role: "user",
text: "visible text",
copyText: "full copied text",
turnId: "turn-1",
},
],
openDetails,
onDetailsToggle,
@ -844,7 +954,7 @@ describe("message stream rendering and message actions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "u1", kind: "message", role: "user", text: "short", turnId: "turn-1", markdown: true }],
displayItems: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "short", turnId: "turn-1" }],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
@ -860,7 +970,17 @@ describe("message stream rendering and message actions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "long", turnId: "turn-1", markdown: true }],
displayItems: [
{
id: "a1",
kind: "message",
role: "assistant",
text: "long",
turnId: "turn-1",
messageKind: "assistantResponse",
messageState: "completed",
},
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
@ -877,7 +997,7 @@ describe("message stream rendering and message actions", () => {
turnLifecycle: startingTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "u1", kind: "message", role: "user", text: "running", turnId: "turn-1", markdown: true }],
displayItems: [{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "running", turnId: "turn-1" }],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
@ -1044,7 +1164,15 @@ describe("message stream rendering and message actions", () => {
status: "completed",
changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }],
},
{ id: "a1", kind: "message", role: "assistant", text: "Done", turnId: "turn", markdown: true },
{
id: "a1",
kind: "message",
role: "assistant",
text: "Done",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
],
turnDiffs: new Map([["turn", "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new"]]),
openDetails: new Set(),
@ -1079,10 +1207,10 @@ describe("message stream rendering and message actions", () => {
{
id: "u1",
kind: "message",
messageKind: "user",
role: "user",
text: "この続きです",
copyText: "この続きです",
markdown: true,
referencedThread: {
threadId: "thread-reference",
title: "参照元",
@ -1114,10 +1242,10 @@ describe("message stream rendering and message actions", () => {
{
id: "u1",
kind: "message",
messageKind: "user",
role: "user",
text: "Read [[Alpha]].",
copyText: "Read [[Alpha]].",
markdown: true,
mentionedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }],
},
],
@ -1150,7 +1278,15 @@ describe("message stream rendering and message actions", () => {
status: "completed",
changes: [{ kind: "update", path: "src/main.ts", diff: "@@\n-old\n+new" }],
},
{ id: "a1", kind: "message", role: "assistant", text: "Done", turnId: "turn", markdown: true },
{
id: "a1",
kind: "message",
role: "assistant",
text: "Done",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),

View file

@ -349,6 +349,7 @@ describe("pending request renderer decisions", () => {
});
it("renders submitted user input separately from approvals", () => {
const renderMarkdown = vi.fn((parent: HTMLElement, text: string) => parent.createDiv({ text: `markdown:${text}` }));
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
@ -361,19 +362,21 @@ describe("pending request renderer decisions", () => {
role: "tool",
text: "Input submitted for 1 question.",
turnId: "turn",
markdown: false,
executionState: "completed",
details: [{ title: "Question: Scope", rows: [{ key: "Answer", value: "Narrow" }] }],
},
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
renderMarkdown,
})[0];
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("Input");
expect(element.querySelector(".codex-panel__message-content")?.textContent).toBe("Input submitted for 1 question.");
expect(element.querySelector(".codex-panel__message-content")?.classList.contains("markdown-rendered")).toBe(false);
expect(renderMarkdown).not.toHaveBeenCalled();
expect(element.textContent).not.toContain("Approval");
expect(element.querySelector("details summary")?.textContent).toBe("Question: Scope");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("AnswerNarrow");
@ -392,7 +395,6 @@ describe("pending request renderer decisions", () => {
role: "tool",
text: "Allowed for this session: Need access",
turnId: "turn",
markdown: false,
executionState: "completed",
details: [
{
@ -434,9 +436,10 @@ describe("pending request renderer decisions", () => {
id: "assistant-1",
kind: "message",
role: "assistant",
messageKind: "assistantResponse",
messageState: "completed",
text: "Done",
turnId: "turn",
markdown: true,
autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"],
},
],
@ -457,7 +460,9 @@ describe("pending request renderer decisions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }],
displayItems: [
{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" },
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
@ -481,7 +486,9 @@ describe("pending request renderer decisions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Waiting", markdown: true }],
displayItems: [
{ id: "a1", kind: "message", role: "assistant", text: "Waiting", messageKind: "assistantResponse", messageState: "completed" },
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (element, text) => element.createDiv({ text }),
@ -516,7 +523,9 @@ describe("pending request renderer decisions", () => {
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
displayItems: [{ id: "a1", kind: "message", role: "assistant", text: "Done", markdown: true }] satisfies DisplayItem[],
displayItems: [
{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" },
] satisfies DisplayItem[],
openDetails: new Set<string>(),
loadOlderTurns: vi.fn(),
renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }),

View file

@ -312,7 +312,7 @@ describe("work log renderer decisions", () => {
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "turn" },
{
id: "hook-1",
kind: "hook",
@ -332,7 +332,15 @@ describe("work log renderer decisions", () => {
{ title: "Hook output", body: "feedback: ok" },
],
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "turn", markdown: true },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
],
openDetails: new Set(["turn:turn:activity", "hook-1"]),
loadOlderTurns: vi.fn(),
@ -362,7 +370,7 @@ describe("work log renderer decisions", () => {
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "turn" },
{
id: "hook-1",
kind: "hook",
@ -396,7 +404,15 @@ describe("work log renderer decisions", () => {
reasoningEffort: null,
agents: [{ threadId: "child", status: "completed", message: "Done" }],
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "turn", markdown: true },
{
id: "a1",
kind: "message",
role: "assistant",
text: "done",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
],
openDetails: new Set(["turn:turn:activity", "hook-1:details"]),
onDetailsToggle,
@ -503,7 +519,7 @@ describe("work log renderer decisions", () => {
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "Do it", turnId: "turn" },
{
id: "plan-progress-turn",
kind: "taskProgress",
@ -514,7 +530,15 @@ describe("work log renderer decisions", () => {
steps: [{ step: "Patch UI", status: "inProgress" }],
status: "inProgress",
},
{ id: "a1", kind: "message", role: "assistant", text: "Working", turnId: "turn", markdown: true },
{
id: "a1",
kind: "message",
role: "assistant",
text: "Working",
turnId: "turn",
messageKind: "assistantResponse",
messageState: "completed",
},
{
id: "agent-1",
kind: "agent",
@ -555,7 +579,7 @@ describe("work log renderer decisions", () => {
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "Do it", turnId: "turn" },
{
id: "agent-1",
kind: "agent",
@ -597,7 +621,7 @@ describe("work log renderer decisions", () => {
historyCursor: null,
loadingHistory: false,
displayItems: [
{ id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true },
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "Do it", turnId: "turn" },
{
id: "agent-spawn",
kind: "agent",

View file

@ -14,9 +14,9 @@ describe("chat view snapshots", () => {
it("finds the latest proposed plan item", () => {
expect(
latestProposedPlanItem([
{ id: "first", kind: "message", role: "assistant", text: "plan", proposedPlan: true },
{ id: "user", kind: "message", role: "user", text: "ok" },
{ id: "latest", kind: "message", role: "assistant", text: "plan", proposedPlan: true },
{ id: "first", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "plan", messageState: "completed" },
{ id: "user", kind: "message", messageKind: "user", role: "user", text: "ok" },
{ id: "latest", kind: "message", messageKind: "proposedPlan", role: "assistant", text: "plan", messageState: "completed" },
])?.id,
).toBe("latest");
});