mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Model chat message render state
This commit is contained in:
parent
35d78d08ca
commit
19c461f52b
29 changed files with 831 additions and 244 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export function createStructuredSystemItem(id: string, text: string, details: Di
|
|||
kind: "system",
|
||||
role: "system",
|
||||
text,
|
||||
markdown: false,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
9
src/features/chat/display/turn-outcome-message.ts
Normal file
9
src/features/chat/display/turn-outcome-message.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "表示済み履歴から命名したい",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue