Normalize message stream render families and actions

This commit is contained in:
murashit 2026-06-17 19:24:08 +09:00
parent a10f18f0ed
commit 6445620d5e
44 changed files with 960 additions and 900 deletions

View file

@ -199,7 +199,7 @@ const chatExternalDomBridgeFiles = [
];
const chatPreactDomBridgeFiles = [
"src/features/chat/ui/message-stream/text-item.tsx",
"src/features/chat/ui/message-stream/tool-result.tsx",
"src/features/chat/ui/message-stream/detail.tsx",
"src/features/chat/ui/message-stream/viewport.tsx",
"src/features/chat/ui/composer-dom.ts",
"src/features/chat/panel/shell.tsx",

View file

@ -1,8 +1,8 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { CodexInput } from "../../../../domain/chat/input";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../../domain/message-stream/items";
import type { MessageStreamNoticeSection } from "../../domain/message-stream/items";
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
import { activeThreadId, canImplementPlan } from "../state/selectors";
import { activeThreadId, canImplementPlanItemId } from "../state/selectors";
import type { ChatStateStore } from "../state/store";
import type { ThreadManagementActions } from "../threads/thread-management-actions";
import type { GoalActions } from "../threads/goal-actions";
@ -69,8 +69,7 @@ export interface PlanImplementationHost {
}
interface PlanImplementation {
canImplement: (item: MessageStreamItem) => boolean;
implement: (item: MessageStreamItem) => Promise<void>;
implement: (itemId: string) => Promise<void>;
}
export interface ConversationTurnActions {
@ -151,8 +150,7 @@ export function createConversationTurnActions(
return {
planImplementation: {
canImplement: (item) => canImplementPlan(stateStore.getState(), item),
implement: (item) => implementPlan(planImplementationHost, item),
implement: (itemId) => implementPlan(planImplementationHost, itemId),
},
composerSubmit: {
submit: () => submitComposer(composerSubmitHost),
@ -165,8 +163,8 @@ async function startThreadForGoal(actions: ConversationThreadStarter, objective:
return response?.threadId ?? null;
}
export async function implementPlan(host: PlanImplementationHost, item: MessageStreamItem): Promise<void> {
if (!canImplementPlan(host.stateStore.getState(), item)) return;
export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise<void> {
if (!canImplementPlanItemId(host.stateStore.getState(), itemId)) return;
await host.ensureConnected();
if (!host.currentClient() || !activeThreadId(host.stateStore.getState())) return;

View file

@ -76,14 +76,7 @@ export interface ActiveThreadRestoredPlaceholderAction {
export interface DisclosureSetAction {
type: "ui/disclosure-set";
bucket:
| "toolResults"
| "activityGroups"
| "agentDetails"
| "textDetails"
| "userMessageExpanded"
| "goalObjectiveExpanded"
| "approvalDetails";
bucket: "details" | "activityGroups" | "textDetails" | "userMessageExpanded" | "goalObjectiveExpanded" | "approvalDetails";
id: string;
open: boolean;
}

View file

@ -48,8 +48,8 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
};
}
export function canImplementPlan(state: ChatState, item: MessageStreamItem): boolean {
return item.id === implementPlanCandidateFromState(state)?.id;
export function canImplementPlanItemId(state: ChatState, itemId: string): boolean {
return itemId === implementPlanCandidateFromState(state)?.id;
}
export function implementPlanCandidateFromState(state: {

View file

@ -29,9 +29,8 @@ interface ChatMessageActionsUiState {
}
const CHAT_DISCLOSURE_BUCKETS = [
"toolResults",
"details",
"activityGroups",
"agentDetails",
"textDetails",
"userMessageExpanded",
"goalObjectiveExpanded",

View file

@ -241,7 +241,7 @@ export interface ReasoningMessageStreamItem extends ToolMessageStreamBase {
readonly text: string;
}
export interface ContextCompactionMessageStreamItem extends MessageStreamBase {
interface ContextCompactionMessageStreamItem extends MessageStreamBase {
readonly kind: "contextCompaction";
readonly role: "tool";
}

View file

@ -102,6 +102,7 @@ function meaningForMessageStreamItem(item: MessageStreamItem): MessageStreamMean
case "system":
return { plane: "diagnostic", event: "notice" };
}
return { plane: "diagnostic", event: "notice" };
}
function reviewResultMeaning(item: Extract<MessageStreamItem, { kind: "reviewResult" }>): MessageStreamMeaning {

View file

@ -1,13 +1,9 @@
export { messageStreamSemanticClassifications } from "./classify";
export {
messageStreamIsAutoReviewDecision,
messageStreamIsCoordinationProgress,
messageStreamIsPermissionDecision,
messageStreamIsReasoningProgress,
messageStreamIsReviewResult,
messageStreamIsTaskProgress,
messageStreamIsTurnInitiator,
messageStreamIsTurnSteer,
messageStreamIsWorkspaceResult,
messageStreamRenderFamily,
} from "./predicates";
export type { MessageStreamSemanticClassification } from "./types";

View file

@ -1,9 +1,4 @@
import type {
MessageStreamMeaningEvent,
MessageStreamMeaningPlane,
MessageStreamRenderFamily,
MessageStreamSemanticClassification,
} from "./types";
import type { MessageStreamMeaningEvent, MessageStreamMeaningPlane, MessageStreamSemanticClassification } from "./types";
function messageStreamHasMeaning(
classification: MessageStreamSemanticClassification,
@ -35,41 +30,14 @@ export function messageStreamIsCoordinationProgress(classification: MessageStrea
return messageStreamHasMeaning(classification, "coordination", "progress");
}
export function messageStreamIsTaskProgress(classification: MessageStreamSemanticClassification): boolean {
return classification.item.kind === "taskProgress";
}
export function messageStreamIsReasoningProgress(classification: MessageStreamSemanticClassification): boolean {
return classification.item.kind === "reasoning";
}
export function messageStreamIsReviewResult(classification: MessageStreamSemanticClassification): boolean {
return classification.item.kind === "reviewResult";
}
export function messageStreamRenderFamily(classification: MessageStreamSemanticClassification): MessageStreamRenderFamily | null {
switch (classification.item.kind) {
case "message":
case "system":
case "userInputResult":
return "text";
case "command":
case "fileChange":
case "tool":
case "hook":
case "goal":
case "approvalResult":
case "reviewResult":
return "toolResult";
case "taskProgress":
case "agent":
case "reasoning":
case "contextCompaction":
return "work";
}
return null;
}
export function messageStreamIsPermissionDecision(classification: MessageStreamSemanticClassification): boolean {
function messageStreamIsPermissionDecision(classification: MessageStreamSemanticClassification): boolean {
return messageStreamHasMeaning(classification, "permission", "decision");
}
export function messageStreamIsAutoReviewDecision(classification: MessageStreamSemanticClassification): boolean {
const { provenance } = classification;
if (!messageStreamIsPermissionDecision(classification) || !provenance) return false;
if (provenance.source === "appServer" && provenance.channel === "notification") return provenance.event === "autoReview";
if (provenance.source === "panel" && provenance.channel === "notice") return provenance.reason === "parsedAutoReview";
return false;
}

View file

@ -63,8 +63,6 @@ export interface MessageStreamSemanticActions {
isTurnOutcome: boolean;
}
export type MessageStreamRenderFamily = "text" | "toolResult" | "work";
export interface MessageStreamSemanticClassification {
item: MessageStreamItem;
provenance?: MessageStreamItemProvenance;

View file

@ -5,7 +5,7 @@ import type { AppServerClientAccess } from "../../../app-server/connection/clien
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import type { MessageStreamNoticeSection } from "../domain/message-stream/items";
import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service";
import { PendingRequestController } from "../application/pending-requests/controller";
@ -959,7 +959,7 @@ function createSurfacesAndPresenter(
actions: {
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (item: MessageStreamItem) => void turnActions.planImplementation.implement(item),
implementPlan: (itemId) => void turnActions.planImplementation.implement(itemId),
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
},
requests: {

View file

@ -7,7 +7,6 @@ import type { ChatStateStore } from "../../application/state/store";
import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../../ui/message-stream/virtualizer";
import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-renderer";
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks";
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../shell-state";
import type { PendingRequestBlockActions, PendingRequestBlockState } from "../../application/pending-requests/block";
@ -33,7 +32,7 @@ export function ChatPanelMessageStream({ presenter }: { presenter: ChatPanelMess
interface ChatMessageStreamActions {
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (item: MessageStreamItem) => void;
implementPlan: (itemId: string) => void;
openTurnDiff: (state: ChatTurnDiffViewState) => void;
}

View file

@ -8,12 +8,7 @@ import {
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamViewBlocks, type MessageStreamViewBlock } from "../../presentation/message-stream/view-model";
import { implementPlanCandidateFromState } from "../../application/state/selectors";
import {
type ForkCandidate,
forkCandidatesFromItems,
isForkCandidateItem,
isRollbackCandidateItem,
} from "../../domain/message-stream/selectors";
import { type ForkCandidate, forkCandidatesFromItems } from "../../domain/message-stream/selectors";
import {
messageStreamActiveItems,
messageStreamItems,
@ -26,11 +21,12 @@ import type { ChatPanelMessageStreamShellState } from "../shell-state";
import { pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/snapshot";
import type { PendingRequestBlockActions, PendingRequestBlockState } from "../../application/pending-requests/block";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
import type { MessageStreamTextActions } from "../../presentation/message-stream/text-view";
interface ChatMessageStreamActions {
rollbackThread: (threadId: string) => void;
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
implementPlan: (item: MessageStreamItem) => void;
implementPlan: (itemId: string) => void;
openTurnDiff: (state: ChatTurnDiffViewState) => void;
}
@ -65,18 +61,9 @@ export interface MessageStreamSurfaceContextOptions {
interface MessageStreamStateProjection {
activeThreadId: string | null;
turnLifecycle: ChatPanelMessageStreamShellState["turn"]["lifecycle"];
historyCursor: string | null;
loadingHistory: boolean;
items: readonly MessageStreamItem[];
stableItems: readonly MessageStreamItem[];
activeItems: readonly MessageStreamItem[];
turnDiffs: ChatPanelMessageStreamShellState["messageStream"]["turnDiffs"];
workspaceRoot: string;
disclosures: ChatDisclosureUiState;
forkActionsItemId: string | null;
implementPlanCandidate: MessageStreamItem | null;
rollbackCandidate: MessageStreamRollbackCandidate | null;
forkCandidates: readonly ForkCandidate[];
viewBlocks: readonly MessageStreamViewBlock[];
}
@ -128,18 +115,15 @@ function messageStreamContextFromProjection(
loadOlderTurns: context.loadOlderTurns,
renderMarkdown: context.renderMarkdown,
copyText: context.copyMessageText,
canImplementPlanItem: (item: MessageStreamItem) => item.id === projection.implementPlanCandidate?.id,
onImplementPlanItem: (item) => {
context.actions.implementPlan(item);
onImplementPlan: (target) => {
context.actions.implementPlan(target.itemId);
},
canRollbackItem: (item: MessageStreamItem) => isRollbackCandidateItem(item, projection.rollbackCandidate),
onRollbackItem: () => {
onRollback: () => {
if (projection.activeThreadId) context.actions.rollbackThread(projection.activeThreadId);
},
canForkItem: (item: MessageStreamItem) => isForkCandidateItem(item, projection.forkCandidates),
onForkItem: (item, archiveSource) => {
if (projection.activeThreadId && item.turnId) {
context.actions.forkThreadFromTurn(projection.activeThreadId, item.turnId, archiveSource);
onFork: (target, archiveSource) => {
if (projection.activeThreadId) {
context.actions.forkThreadFromTurn(projection.activeThreadId, target.turnId, archiveSource);
}
},
openTurnDiff: (turnDiffState) => {
@ -166,23 +150,15 @@ function messageStreamStateProjection(
const rollbackCandidate = busy ? null : messageStreamRollbackCandidate(state.messageStream);
const forkCandidates = busy ? [] : forkCandidatesFromItems(items);
const implementPlanCandidate = implementPlanCandidateFromState(state);
const textActionsByItemId = textActionsForMessageStreamItems(rollbackCandidate, forkCandidates, implementPlanCandidate);
const activeTurn = activeTurnId({ lifecycle: state.turn.lifecycle });
return {
activeThreadId: state.activeThread.id,
turnLifecycle: state.turn.lifecycle,
historyCursor: state.messageStream.historyCursor,
loadingHistory: state.messageStream.loadingHistory,
items,
stableItems,
activeItems,
turnDiffs: state.messageStream.turnDiffs,
workspaceRoot,
disclosures: state.ui.disclosures,
forkActionsItemId: state.ui.messageActions.forkActionsItemId,
implementPlanCandidate,
rollbackCandidate,
forkCandidates,
viewBlocks: messageStreamViewBlocks({
activeThreadId: state.activeThread.id,
activeTurnId: activeTurn,
@ -193,11 +169,36 @@ function messageStreamStateProjection(
activeItems,
workspaceRoot,
turnDiffs: state.messageStream.turnDiffs,
textActionsByItemId,
pendingRequests: messageStreamBlockItemsEmpty(stableItems, activeItems) ? null : pendingRequestBlockFromContext(context),
}),
};
}
function textActionsForMessageStreamItems(
rollbackCandidate: MessageStreamRollbackCandidate | null,
forkCandidates: readonly ForkCandidate[],
implementPlanCandidate: MessageStreamItem | null,
): ReadonlyMap<string, MessageStreamTextActions> {
const byItemId = new Map<string, MessageStreamTextActions>();
for (const candidate of forkCandidates) {
patchTextActions(byItemId, candidate.itemId, { fork: { itemId: candidate.itemId, turnId: candidate.turnId } });
}
if (rollbackCandidate) {
patchTextActions(byItemId, rollbackCandidate.itemId, {
rollback: { itemId: rollbackCandidate.itemId, turnId: rollbackCandidate.turnId },
});
}
if (implementPlanCandidate) {
patchTextActions(byItemId, implementPlanCandidate.id, { implementPlan: { itemId: implementPlanCandidate.id } });
}
return byItemId;
}
function patchTextActions(byItemId: Map<string, MessageStreamTextActions>, itemId: string, patch: MessageStreamTextActions): void {
byItemId.set(itemId, { ...byItemId.get(itemId), ...patch });
}
function pendingRequestBlockFromContext(
context: ChatMessageStreamSurfaceContext,
): { signature: string; snapshot: ReturnType<typeof pendingRequestBlockSnapshotFromState> } | null {

View file

@ -1,53 +1,58 @@
import { jsonPreview, truncate } from "../../../../utils";
import { shortThreadId, truncate } from "../../../../utils";
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
import type {
AgentMessageStreamItem,
ApprovalResultMessageStreamItem,
CommandMessageStreamItem,
CommandMessageStreamTarget,
ExecutionState,
FileChangeMessageStreamItem,
GoalMessageStreamItem,
HookMessageStreamItem,
MessageStreamFileChange,
MessageStreamItem,
MessageStreamPrimaryTarget,
ReviewResultMessageStreamItem,
ToolCallMessageStreamItem,
} from "../../domain/message-stream/items";
import { agentActivityMetaLabel, agentMessagePreview } from "./agent-summary";
import {
compactSummary,
detailViewBase,
failedStatusLabel,
jsonOutputSection,
metaRow,
outputSection,
primaryTargetSummary,
statusQualifier,
type DetailSection,
type DetailView,
} from "./detail-types";
export type ToolResultMessageStreamItem =
| CommandMessageStreamItem
| FileChangeMessageStreamItem
| GoalMessageStreamItem
| ToolCallMessageStreamItem
| HookMessageStreamItem
| ApprovalResultMessageStreamItem
| ReviewResultMessageStreamItem;
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;
export type ToolResultDetailSection =
| { kind: "meta"; title?: string; rows: readonly { readonly key: string; readonly value: string }[] }
| { kind: "output"; title: string; body: string }
| { kind: "diff"; title: string; diff: string };
export interface ToolResultView {
className: string;
label: string;
summary: string;
detailsKey: string;
details: ToolResultDetailSection[];
state: ExecutionState;
export function codexDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView | null {
switch (item.kind) {
case "command":
return commandDetailView(item);
case "fileChange":
return fileChangeDetailView(item, workspaceRoot);
case "goal":
return goalDetailView(item);
case "approvalResult":
return approvalDetailView(item);
case "reviewResult":
return reviewDetailView(item);
case "agent":
return agentDetailView(item);
case "tool":
case "hook":
return genericToolDetailView(item, workspaceRoot);
default:
return null;
}
}
export function toolResultView(item: ToolResultMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
if (item.kind === "command") return commandToolView(item);
if (item.kind === "fileChange") return fileChangeToolView(item, workspaceRoot);
if (item.kind === "goal") return goalToolView(item);
if (item.kind === "approvalResult") return approvalToolView(item);
if (item.kind === "reviewResult") return reviewToolView(item);
return genericToolView(item, workspaceRoot);
}
function commandToolView(item: CommandMessageStreamItem): ToolResultView {
function commandDetailView(item: CommandMessageStreamItem): DetailView {
const rows = [
{ key: "command", value: item.command },
{ key: "cwd", value: item.cwd },
@ -55,31 +60,31 @@ function commandToolView(item: CommandMessageStreamItem): ToolResultView {
...(item.exitCode !== undefined ? [{ key: "exit", value: String(item.exitCode) }] : []),
...(item.durationMs !== undefined ? [{ key: "duration", value: `${String(item.durationMs)}ms` }] : []),
];
const details: ToolResultDetailSection[] = [
const sections: DetailSection[] = [
{
kind: "meta",
kind: "kv",
rows,
},
...outputSection("Output", item.output),
];
return toolView(
return detailViewBase(
item,
"codex-panel__tool-item",
"codex-panel__detail-item",
commandActionLabel(item.commandAction),
`${item.id}:command-details`,
details,
sections,
commandSummary(item),
);
}
function fileChangeToolView(item: FileChangeMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
function fileChangeDetailView(item: FileChangeMessageStreamItem, workspaceRoot?: string | null): DetailView {
const displayChanges = item.changes.map((change) => ({
...change,
displayPath: change.path && change.path !== "(unknown)" ? pathRelativeToRoot(change.path, workspaceRoot) : change.path,
}));
const details: ToolResultDetailSection[] = [
const sections: DetailSection[] = [
{
kind: "meta",
kind: "kv",
rows: [
{ key: "status", value: item.status },
{ key: "files", value: String(item.changes.length) },
@ -92,24 +97,41 @@ function fileChangeToolView(item: FileChangeMessageStreamItem, workspaceRoot?: s
})),
...outputSection("Patch output", item.output),
];
return toolView(
return detailViewBase(
item,
"codex-panel__file-change",
"codex-panel__detail-item codex-panel__detail-item--file-change",
"file change",
`${item.id}:file-change-details`,
details,
sections,
fileChangeSummary(item, displayChanges),
);
}
function goalToolView(item: GoalMessageStreamItem): ToolResultView {
return toolView(item, "codex-panel__tool-item codex-panel__tool-item--goal", "goal", `${item.id}:goal-details`, goalDetails(item));
function goalDetailView(item: GoalMessageStreamItem): DetailView {
return detailViewBase(
item,
"codex-panel__detail-item codex-panel__detail-item--goal",
"goal",
`${item.id}:goal-details`,
goalDetails(item),
);
}
function genericToolView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
return toolView(
function agentDetailView(item: AgentMessageStreamItem): DetailView {
return detailViewBase(
item,
`codex-panel__tool-item codex-panel__tool-item--${item.kind}`,
"codex-panel__detail-item codex-panel__agent-activity",
"agent",
`${item.id}:agent-details`,
agentDetailSections(item),
agentSummaryText(item),
);
}
function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): DetailView {
return detailViewBase(
item,
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
item.toolName ?? item.kind,
`${item.id}:details`,
[...genericToolDetails(item), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)],
@ -117,70 +139,77 @@ function genericToolView(item: ToolCallMessageStreamItem | HookMessageStreamItem
);
}
function reviewToolView(item: ReviewResultMessageStreamItem): ToolResultView {
return resultToolView(
function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"auto-review",
`${item.id}:review-details`,
"codex-panel__message--review-result codex-panel__tool-item--review",
"codex-panel__message--review-result codex-panel__detail-item--review",
);
}
function approvalToolView(item: ApprovalResultMessageStreamItem): ToolResultView {
return resultToolView(
function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"approval",
`${item.id}:approval-details`,
"codex-panel__message--approval-result codex-panel__tool-item--approval",
"codex-panel__message--approval-result codex-panel__detail-item--approval",
);
}
function resultToolView(
function resultDetailView(
item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem,
label: string,
detailsKey: string,
className: string,
): ToolResultView {
return toolView(item, `codex-panel__tool-item ${className}`, label, detailsKey, resultDetails(item));
): DetailView {
return detailViewBase(item, `codex-panel__detail-item ${className}`, label, detailsKey, resultDetails(item));
}
function toolView(
item: ToolResultMessageStreamItem,
className: string,
label: string,
detailsKey: string,
details: ToolResultDetailSection[],
summary = fallbackSummary(item),
): ToolResultView {
return {
className: `codex-panel__message codex-panel__message--tool ${className}`,
label,
summary,
detailsKey,
details,
state: item.executionState ?? null,
};
}
function outputSection(title: string, body: string | null | undefined): ToolResultDetailSection[] {
return body ? [{ kind: "output", title, body }] : [];
}
function goalDetails(item: GoalMessageStreamItem): ToolResultDetailSection[] {
function goalDetails(item: GoalMessageStreamItem): DetailSection[] {
return [
{
kind: "meta",
kind: "kv",
rows: [{ key: "action", value: item.action }],
},
...outputSection("Objective", item.objective),
];
}
function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem): ToolResultDetailSection[] {
function agentDetailSections(item: AgentMessageStreamItem): DetailSection[] {
const rows = [
{ key: "tool", value: agentActivityMetaLabel(item.tool) },
{ key: "status", value: item.status },
{ key: "sender", value: item.senderThreadId },
...(item.receiverThreadIds.length > 0 ? [{ key: "target", value: item.receiverThreadIds.join(", ") }] : []),
...(item.model ? [{ key: "model", value: item.model }] : []),
...(item.reasoningEffort ? [{ key: "effort", value: item.reasoningEffort }] : []),
];
return [
{ kind: "kv", rows },
...outputSection("Prompt", item.prompt),
...agentStateSection(item),
...item.agents.flatMap((agent) =>
agent.message && isLongAgentMessage(agent.message)
? outputSection(`Agent output ${shortThreadId(agent.threadId)}`, agent.message)
: [],
),
];
}
function agentStateSection(item: AgentMessageStreamItem): DetailSection[] {
const rows = item.agents.map((agent) => ({
key: shortThreadId(agent.threadId),
value: agentStatusLabel(agent.status, agent.message),
}));
return rows.length > 0 ? [{ kind: "kv", title: "agents", rows }] : [];
}
function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem): DetailSection[] {
if (item.kind === "approvalResult") {
return [
{
kind: "meta",
kind: "kv",
rows: [
{ key: "status", value: item.approval.status },
{ key: "scope", value: item.approval.scope },
@ -190,15 +219,15 @@ function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessa
},
];
}
return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "meta", rows: item.review.auditFacts }] : [];
return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "kv", rows: item.review.auditFacts }] : [];
}
function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): ToolResultDetailSection[] {
function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): DetailSection[] {
if (item.kind === "hook") return hookRunDetails(item);
return [...toolCallDetails(item), ...webSearchDetails(item), ...imageGenerationDetails(item)];
}
function toolCallDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
function toolCallDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.toolCall;
if (!details) return [];
return [
@ -208,7 +237,7 @@ function toolCallDetails(item: ToolCallMessageStreamItem): ToolResultDetailSecti
];
}
function webSearchDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
function webSearchDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.webSearch;
if (!details) return [];
const rows = [
@ -217,10 +246,10 @@ function webSearchDetails(item: ToolCallMessageStreamItem): ToolResultDetailSect
...metaRow("pattern", details.pattern),
...metaRow("url", details.url),
];
return rows.length > 0 ? [{ kind: "meta", title: "web search", rows }] : [];
return rows.length > 0 ? [{ kind: "kv", title: "web search", rows }] : [];
}
function imageGenerationDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
function imageGenerationDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.imageGeneration;
if (!details) return [];
return [
@ -230,7 +259,7 @@ function imageGenerationDetails(item: ToolCallMessageStreamItem): ToolResultDeta
];
}
function hookRunDetails(item: HookMessageStreamItem): ToolResultDetailSection[] {
function hookRunDetails(item: HookMessageStreamItem): DetailSection[] {
const details = item.hookRun;
if (!details) return [];
const rows = [
@ -240,15 +269,7 @@ function hookRunDetails(item: HookMessageStreamItem): ToolResultDetailSection[]
...metaRow("duration", details.durationMs),
];
const entries = details.entries.map((entry) => `${entry.kind}: ${entry.text}`).join("\n");
return [{ kind: "meta", rows }, ...outputSection("Hook output", entries)];
}
function jsonOutputSection(title: string, value: unknown): ToolResultDetailSection[] {
return value === null || value === undefined ? [] : outputSection(title, jsonPreview(value));
}
function metaRow(key: string, value: string | null | undefined): { key: string; value: string }[] {
return value ? [{ key, value }] : [];
return [{ kind: "kv", rows }, ...outputSection("Hook output", entries)];
}
function commandActionLabel(action: CommandMessageStreamItem["commandAction"]): string {
@ -287,14 +308,30 @@ function genericToolSummary(item: ToolCallMessageStreamItem | HookMessageStreamI
return compactSummary(toolOperationLabel(item.operation), target, statusQualifier(item.status, item.failureReason));
}
function fileChangeSummary(item: MessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
if (item.kind !== "fileChange") return "text" in item && typeof item.text === "string" ? item.text : "details";
function fileChangeSummary(item: FileChangeMessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
const target = fileChangeTargetSummary(changes);
return compactSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status)));
}
function fallbackSummary(item: ToolResultMessageStreamItem): string {
return "text" in item && typeof item.text === "string" ? item.text : "details";
function agentSummaryText(item: AgentMessageStreamItem): string {
const target = item.receiverThreadIds.length === 0 ? "" : ` ${item.receiverThreadIds.map(shortThreadId).join(", ")}`;
const promptPreview = agentPromptPreview(item.prompt);
return `${agentActivityMetaLabel(item.tool)}${target}${promptPreview ? `: ${promptPreview}` : ""} (${item.status})`;
}
function agentPromptPreview(prompt: string | null): string | null {
if (!prompt) return null;
const normalized = prompt.trim().replace(/\s+/g, " ");
return normalized ? truncate(normalized, AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT) : null;
}
function agentStatusLabel(status: string, message: string | null): string {
const preview = agentMessagePreview(message, AGENT_ROW_MESSAGE_PREVIEW_LIMIT);
return preview ? `${status}: ${preview}` : status;
}
function isLongAgentMessage(message: string): boolean {
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
}
function fileChangeTargetSummary(changes: (MessageStreamFileChange & { displayPath: string })[]): string {
@ -303,30 +340,6 @@ function fileChangeTargetSummary(changes: (MessageStreamFileChange & { displayPa
return `${String(changes.length)} files`;
}
function primaryTargetSummary(target: MessageStreamPrimaryTarget | undefined, workspaceRoot?: string | null): string | null {
if (!target) return null;
if (target.kind === "path") return pathRelativeToRoot(target.path, workspaceRoot);
return target.value;
}
function compactSummary(label: string | null, target?: string | null, qualifier?: string | null): string {
const targetText = target?.trim();
const base = label ? (targetText ? `${label}: ${targetText}` : label) : (targetText ?? "details");
return truncate(qualifier ? `${base} (${qualifier})` : base, 140);
}
function statusQualifier(status: unknown, failure?: string | null): string | null {
if (status === "declined") return "declined";
if (status === "failed") return failure && failure.length > 0 ? failure : "failed";
return null;
}
function failedStatusLabel(status: unknown): string | null {
if (status === "failed") return "failed";
if (status === "declined") return "declined";
return null;
}
function toolOperationLabel(operation: string | null | undefined): string | null {
if (!operation) return null;
if (operation === "openPage") return "open page";

View file

@ -0,0 +1,89 @@
import { jsonPreview, truncate } from "../../../../utils";
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
import type { ExecutionState, MessageStreamItem, MessageStreamPrimaryTarget } from "../../domain/message-stream/items";
export type DetailSection =
| { kind: "kv"; title?: string; rows: readonly { readonly key: string; readonly value: string }[] }
| { kind: "output"; title: string; body: string }
| { kind: "diff"; title: string; diff: string };
export interface DetailView {
className: string;
label: string;
summary: string;
detailsKey: string;
sections: DetailSection[];
state: ExecutionState;
}
export function detailViewBase(
item: MessageStreamItem,
className: string,
label: string,
detailsKey: string,
sections: DetailSection[],
summary = fallbackSummary(item),
): DetailView {
return {
className: `codex-panel__message codex-panel__message--tool ${className}`,
label,
summary,
detailsKey,
sections,
state: item.executionState ?? null,
};
}
export function outputSection(title: string, body: string | null | undefined): DetailSection[] {
return body ? [{ kind: "output", title, body }] : [];
}
export function jsonOutputSection(title: string, value: unknown): DetailSection[] {
return value === null || value === undefined ? [] : outputSection(title, jsonPreview(value));
}
export function metaRow(key: string, value: string | null | undefined): { key: string; value: string }[] {
return value ? [{ key, value }] : [];
}
export function primaryTargetSummary(target: MessageStreamPrimaryTarget | undefined, workspaceRoot?: string | null): string | null {
if (!target) return null;
if (target.kind === "path") return pathRelativeToRoot(target.path, workspaceRoot);
return target.value;
}
export function textField(item: MessageStreamItem): string | null {
return "text" in item && typeof item.text === "string" && item.text.trim().length > 0 ? item.text : null;
}
export function outputField(item: MessageStreamItem): string | null {
return "output" in item && typeof item.output === "string" && item.output.trim().length > 0 ? item.output : null;
}
export function stringField(item: MessageStreamItem, key: "failureReason" | "operation" | "status" | "toolName"): string | null {
if (!(key in item)) return null;
const value = (item as unknown as Record<string, unknown>)[key];
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function compactSummary(label: string | null, target?: string | null, qualifier?: string | null): string {
const targetText = target?.trim();
const base = label ? (targetText ? `${label}: ${targetText}` : label) : (targetText ?? "details");
return truncate(qualifier ? `${base} (${qualifier})` : base, 140);
}
export function statusQualifier(status: unknown, failure?: string | null): string | null {
if (status === "declined") return "declined";
if (status === "failed") return failure && failure.length > 0 ? failure : "failed";
return null;
}
export function failedStatusLabel(status: unknown): string | null {
if (status === "failed") return "failed";
if (status === "declined") return "declined";
return null;
}
function fallbackSummary(item: MessageStreamItem): string {
return textField(item) ?? "details";
}

View file

@ -0,0 +1,56 @@
import type { MessageStreamItem, MessageStreamPrimaryTarget } from "../../domain/message-stream/items";
import { codexDetailView } from "./codex-detail-view";
import {
compactSummary,
detailViewBase,
metaRow,
outputField,
outputSection,
primaryTargetSummary,
stringField,
textField,
type DetailSection,
type DetailView,
} from "./detail-types";
export { type DetailSection, type DetailView } from "./detail-types";
export function detailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView {
return codexDetailView(item, workspaceRoot) ?? genericDetailView(item, workspaceRoot);
}
function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView {
return detailViewBase(
item,
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
detailLabel(item),
`${item.id}:details`,
genericDetailSections(item, workspaceRoot),
genericDetailSummary(item, workspaceRoot),
);
}
function genericDetailSections(item: MessageStreamItem, workspaceRoot?: string | null): DetailSection[] {
const rows = [
...metaRow("kind", item.kind),
...metaRow("status", stringField(item, "status")),
...metaRow("operation", stringField(item, "operation")),
...metaRow("target", primaryTargetSummary(primaryTargetField(item), workspaceRoot)),
...metaRow("failure", stringField(item, "failureReason")),
];
return [...(rows.length > 0 ? [{ kind: "kv" as const, rows }] : []), ...outputSection("Output", outputField(item))];
}
function genericDetailSummary(item: MessageStreamItem, workspaceRoot?: string | null): string {
const target = primaryTargetSummary(primaryTargetField(item), workspaceRoot);
return compactSummary(null, target ?? textField(item) ?? outputField(item) ?? stringField(item, "status") ?? item.kind);
}
function detailLabel(item: MessageStreamItem): string {
return stringField(item, "toolName") ?? item.kind;
}
function primaryTargetField(item: MessageStreamItem): MessageStreamPrimaryTarget | undefined {
if (!("primaryTarget" in item)) return undefined;
return item.primaryTarget;
}

View file

@ -1,9 +1,7 @@
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
import {
messageStreamIsPermissionDecision,
messageStreamIsReasoningProgress,
messageStreamIsReviewResult,
messageStreamIsAutoReviewDecision,
messageStreamIsTurnInitiator,
messageStreamIsTurnSteer,
messageStreamIsWorkspaceResult,
@ -113,12 +111,11 @@ export function messageStreamLayoutBlocks(
type GroupedActivity = MessageStreamActivityGroupItem;
function shouldShowPresentationItem(classification: MessageStreamSemanticClassification): boolean {
const { item } = classification;
return (
!messageStreamIsReasoningProgress(classification) ||
item.executionState !== "completed" ||
textForMessageStreamItem(item).trim().length > 0
);
return !isEmptyCompletedReasoningItem(classification.item);
}
function isEmptyCompletedReasoningItem(item: MessageStreamItem): boolean {
return item.kind === "reasoning" && item.executionState === "completed" && textForMessageStreamItem(item).trim().length === 0;
}
function steeringActivityGroupItem(classification: MessageStreamSemanticClassification): MessageStreamActivityGroupItem {
@ -195,7 +192,7 @@ function autoReviewSummariesForTurns(items: readonly MessageStreamSemanticClassi
const byTurn = new Map<string, string[]>();
for (const classification of items) {
const { item } = classification;
if (!item.turnId || !messageStreamIsReviewResult(classification) || !messageStreamIsPermissionDecision(classification)) {
if (!item.turnId || !messageStreamIsAutoReviewDecision(classification)) {
continue;
}
const summary = textForMessageStreamItem(item).trim();

View file

@ -0,0 +1,26 @@
import type { MessageStreamSemanticClassification } from "../../domain/message-stream/semantics";
export type MessageStreamRenderFamily = "text" | "detail" | "status";
export function messageStreamRenderFamily(classification: MessageStreamSemanticClassification): MessageStreamRenderFamily {
switch (classification.item.kind) {
case "message":
case "system":
case "userInputResult":
return "text";
case "command":
case "fileChange":
case "tool":
case "hook":
case "goal":
case "approvalResult":
case "reviewResult":
case "agent":
return "detail";
case "taskProgress":
case "reasoning":
case "contextCompaction":
return "status";
}
return "status";
}

View file

@ -0,0 +1,143 @@
import { agentRunSummaryLabel } from "./agent-summary";
import { shortThreadId } from "../../../../utils";
import type {
AgentRunSummary,
AgentRunSummaryAgent,
ExecutionState,
MessageStreamItem,
ReasoningMessageStreamItem,
TaskProgressMessageStreamItem,
} from "../../domain/message-stream/items";
export type StatusChecklistItem = TaskProgressMessageStreamItem["steps"][number];
export type MessageStreamStatusView =
| {
kind: "taskProgress";
label: "tasks";
className: string;
state: ExecutionState;
summary: string | null;
checklist: readonly StatusChecklistItem[];
}
| {
kind: "contextCompaction";
label: "context";
className: string;
state: ExecutionState;
text: string;
}
| {
kind: "reasoning";
active: boolean;
label: string;
text: string;
}
| {
kind: "generic";
label: string;
className: string;
state: ExecutionState;
text: string;
};
export interface MessageStreamStatusViewContext {
activeTurnId: string | null;
items: readonly MessageStreamItem[];
activeItems?: readonly MessageStreamItem[] | undefined;
}
export interface AgentRunSummaryView {
label: "agents";
className: string;
state: ExecutionState;
summary: string;
rows: readonly { threadId: string; threadLabel: string; status: string }[];
additionalAgents: number;
}
export function messageStreamStatusView(item: MessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView {
if (item.kind === "taskProgress") {
return {
kind: "taskProgress",
label: "tasks",
className: "codex-panel__task-progress",
state: item.executionState ?? null,
summary: item.explanation,
checklist: item.steps,
};
}
if (item.kind === "contextCompaction") return contextCompactionStatusView(item, context);
if (item.kind === "reasoning") return reasoningStatusView(item, context);
return genericStatusView(item);
}
export function agentRunSummaryView(summary: AgentRunSummary): AgentRunSummaryView {
return {
label: "agents",
className: "codex-panel__agent-summary",
state: summary.failed > 0 ? "failed" : "running",
summary: agentRunSummaryLabel(summary),
rows: summary.agents.map(agentRunSummaryRow),
additionalAgents: summary.additionalAgents,
};
}
function contextCompactionStatusView(item: MessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView {
const active = context.activeTurnId === item.turnId;
return {
kind: "contextCompaction",
label: "context",
className: "codex-panel__context-compaction",
state: active ? "running" : "completed",
text: active ? "Compacting context..." : "Context compacted",
};
}
function reasoningStatusView(item: ReasoningMessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView {
const active = isReasoningActive(item, context);
return {
kind: "reasoning",
active,
label: active ? "reasoning" : "thought",
text: item.text || (active ? "Reasoning" : "Thought"),
};
}
function genericStatusView(item: MessageStreamItem): MessageStreamStatusView {
return {
kind: "generic",
label: item.kind,
className: "codex-panel__status-item",
state: item.executionState ?? null,
text:
stringField(item, "text") ??
stringField(item, "status") ??
stringField(item, "output") ??
stringField(item, "failureReason") ??
stringField(item, "operation") ??
item.kind,
};
}
function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; threadLabel: string; status: string } {
return {
threadId: agent.threadId,
threadLabel: shortThreadId(agent.threadId),
status: agent.messagePreview ? `${agent.status}: ${agent.messagePreview}` : agent.status,
};
}
function isReasoningActive(item: ReasoningMessageStreamItem, context: MessageStreamStatusViewContext): boolean {
const activeTurn = context.activeTurnId;
if (!activeTurn || item.turnId !== activeTurn) return false;
if (item.executionState === "completed") return false;
const latestActiveTurnItem = [...(context.activeItems ?? context.items)].reverse().find((candidate) => candidate.turnId === activeTurn);
return latestActiveTurnItem?.id === item.id;
}
function stringField(item: MessageStreamItem, key: "failureReason" | "operation" | "output" | "status" | "text"): string | null {
if (!(key in item)) return null;
const value = (item as unknown as Record<string, unknown>)[key];
return typeof value === "string" && value.trim().length > 0 ? value : null;
}

View file

@ -1,37 +1,87 @@
import type { ExecutionState, MessageStreamItem } from "../../domain/message-stream/items";
import type { MessageStreamItemAnnotations } from "./layout";
export type TextMessageStreamItem = Extract<MessageStreamItem, { kind: "message" | "system" | "userInputResult" }>;
export interface MessageStreamForkTarget {
itemId: string;
turnId: string;
}
export interface MessageStreamRollbackTarget {
itemId: string;
turnId: string;
}
export interface MessageStreamPlanImplementationTarget {
itemId: string;
}
export interface MessageStreamTextActions {
fork?: MessageStreamForkTarget;
rollback?: MessageStreamRollbackTarget;
implementPlan?: MessageStreamPlanImplementationTarget;
}
export interface MessageStreamTextView {
item: TextMessageStreamItem;
id: string;
item: MessageStreamItem;
roleLabel: string;
body: string;
className: string;
contentKey: string;
contentMode: "markdown" | "text";
collapsible: boolean;
copyText?: string;
actions: MessageStreamTextActions;
annotations?: MessageStreamItemAnnotations;
editedFiles: readonly string[];
autoReviewSummaries: readonly string[];
}
export function messageStreamTextView(item: TextMessageStreamItem, annotations?: MessageStreamItemAnnotations): MessageStreamTextView {
export function messageStreamTextView(
item: MessageStreamItem,
annotations?: MessageStreamItemAnnotations,
options: { activeTurnId?: string | null; actions?: MessageStreamTextActions } = {},
): MessageStreamTextView {
const contentMode = textContentMode(item);
const body = bodyForTextItem(item);
return {
id: item.id,
item,
roleLabel: roleLabelForTextItem(item),
body,
className: `${textItemClass(item)}${executionClassName(item.executionState ?? null)}`,
contentKey: `${item.id}\u001f${contentMode}`,
contentMode,
collapsible: item.kind === "message" && item.role === "user",
...definedProp("copyText", copyTextForTextItem(item, options.activeTurnId ?? null)),
actions: options.actions ?? {},
...definedProp("annotations", annotations),
editedFiles: annotations?.editedFiles ?? [],
autoReviewSummaries: annotations?.autoReviewSummaries ?? [],
};
}
function textContentMode(item: TextMessageStreamItem): "markdown" | "text" {
function textContentMode(item: MessageStreamItem): "markdown" | "text" {
return item.kind === "message" && (item.messageKind !== "proposedPlan" || item.messageState === "completed") ? "markdown" : "text";
}
function bodyForTextItem(item: MessageStreamItem): string {
return "text" in item && typeof item.text === "string" ? item.text : "";
}
function roleLabelForTextItem(item: MessageStreamItem): string {
if (item.kind === "userInputResult") return "Input";
if (item.role === "user") return "You";
if (item.role === "assistant") return "Codex";
return "System";
}
function copyTextForTextItem(item: MessageStreamItem, activeTurnId: string | null): string | undefined {
if (item.kind !== "message" || item.copyText === undefined) return undefined;
if (activeTurnId && item.role === "assistant" && item.turnId === activeTurnId) return undefined;
return item.copyText;
}
function executionClassName(state: ExecutionState): string {
return state ? ` codex-panel__execution codex-panel__execution--${state}` : "";
}

View file

@ -1,22 +1,15 @@
import { activeAgentRunSummary } from "./agent-summary";
import { messageStreamRenderFamily } from "../../domain/message-stream/semantics";
import {
messageStreamIsCoordinationProgress,
messageStreamIsTaskProgress,
messageStreamSemanticClassifications,
type MessageStreamSemanticClassification,
} from "../../domain/message-stream/semantics";
import type { AgentRunSummary, MessageStreamItem, TaskProgressMessageStreamItem } from "../../domain/message-stream/items";
import { messageStreamLayoutBlocks, type MessageStreamItemAnnotations, type MessageStreamLayoutBlock } from "./layout";
import { messageStreamTextView, type MessageStreamTextView, type TextMessageStreamItem } from "./text-view";
import { toolResultView, type ToolResultMessageStreamItem, type ToolResultView } from "./tool-result-view";
import {
agentRunSummaryView,
messageStreamWorkView,
type AgentRunSummaryView,
type MessageStreamWorkView,
type WorkMessageStreamItem,
} from "./work-view";
import { detailView, type DetailView } from "./detail-view";
import { messageStreamRenderFamily } from "./render-family";
import { messageStreamTextView, type MessageStreamTextActions, type MessageStreamTextView } from "./text-view";
import { agentRunSummaryView, messageStreamStatusView, type AgentRunSummaryView, type MessageStreamStatusView } from "./status-view";
import type { PendingRequestBlockSnapshot } from "../pending-requests/snapshot";
interface PendingRequestMessageStreamBlockInput {
@ -34,6 +27,7 @@ export interface MessageStreamPresentationBlockInput {
activeItems?: readonly MessageStreamItem[] | undefined;
workspaceRoot?: string | null | undefined;
turnDiffs?: ReadonlyMap<string, string> | undefined;
textActionsByItemId?: ReadonlyMap<string, MessageStreamTextActions> | undefined;
pendingRequests?: PendingRequestMessageStreamBlockInput | null | undefined;
}
@ -80,12 +74,12 @@ export type MessageStreamRenderedItemView =
view: MessageStreamTextView;
}
| {
kind: "toolResult";
view: ToolResultView;
kind: "detail";
view: DetailView;
}
| {
kind: "work";
view: MessageStreamWorkView;
kind: "status";
view: MessageStreamStatusView;
};
export type MessageStreamActivityItemView =
@ -203,7 +197,7 @@ function activeTurnLiveBlocks(
return semanticItems.flatMap((classification): MessageStreamPresentationBlock[] => {
const { item } = classification;
if (messageStreamIsTaskProgress(classification) && item.turnId === activeTurnId) {
if (isLiveTaskProgressItem(item, activeTurnId)) {
return [
{
kind: "liveTask",
@ -219,6 +213,10 @@ function activeTurnLiveBlocks(
});
}
function isLiveTaskProgressItem(item: MessageStreamItem, activeTurnId: string): boolean {
return item.kind === "taskProgress" && item.turnId === activeTurnId;
}
function activeAgentRunSummaryAnchorId(items: readonly MessageStreamSemanticClassification[], activeTurnId: string): string | null {
const firstActiveAgent = items.find(
(classification) => messageStreamIsCoordinationProgress(classification) && classification.item.turnId === activeTurnId,
@ -243,7 +241,7 @@ function messageStreamViewBlockFromPresentationBlock(
if (block.kind === "pendingRequests") return block;
if (block.kind === "liveAgentSummary") return { kind: "liveAgentSummary", key: block.key, view: agentRunSummaryView(block.summary) };
if (block.kind === "liveTask") {
return { kind: "work", key: block.key, view: messageStreamWorkView(block.item, workViewContext(input)) };
return { kind: "status", key: block.key, view: messageStreamStatusView(block.item, statusViewContext(input)) };
}
if (block.kind === "activityGroup") {
return {
@ -272,16 +270,23 @@ function messageStreamRenderedItemView(
annotations?: MessageStreamItemAnnotations,
): MessageStreamRenderedItemView {
const renderFamily = messageStreamRenderFamily(classification);
if (renderFamily === "text") return { kind: "text", view: messageStreamTextView(textItemFromSemantic(classification), annotations) };
if (renderFamily === "toolResult") {
return { kind: "toolResult", view: toolResultView(toolResultItemFromSemantic(classification), input.workspaceRoot) };
switch (renderFamily) {
case "text":
return {
kind: "text",
view: messageStreamTextView(classification.item, annotations, {
activeTurnId: input.activeTurnId,
...definedProp("actions", input.textActionsByItemId?.get(classification.item.id)),
}),
};
case "detail":
return { kind: "detail", view: detailView(classification.item, input.workspaceRoot) };
case "status":
return { kind: "status", view: messageStreamStatusView(classification.item, statusViewContext(input)) };
}
if (renderFamily === "work")
return { kind: "work", view: messageStreamWorkView(workItemFromSemantic(classification), workViewContext(input)) };
return unhandledClassification(classification);
}
function workViewContext(input: MessageStreamPresentationBlockInput): Parameters<typeof messageStreamWorkView>[1] {
function statusViewContext(input: MessageStreamPresentationBlockInput): Parameters<typeof messageStreamStatusView>[1] {
return {
activeTurnId: input.activeTurnId,
items: input.items,
@ -289,31 +294,6 @@ function workViewContext(input: MessageStreamPresentationBlockInput): Parameters
};
}
function textItemFromSemantic({ item }: MessageStreamSemanticClassification): TextMessageStreamItem {
if (item.kind === "message" || item.kind === "system" || item.kind === "userInputResult") return item;
throw new Error(`Message stream semantic expected text item: ${JSON.stringify(item)}`);
}
function toolResultItemFromSemantic({ item }: MessageStreamSemanticClassification): ToolResultMessageStreamItem {
if (
item.kind === "command" ||
item.kind === "fileChange" ||
item.kind === "goal" ||
item.kind === "tool" ||
item.kind === "hook" ||
item.kind === "approvalResult" ||
item.kind === "reviewResult"
) {
return item;
}
throw new Error(`Message stream semantic expected tool result item: ${JSON.stringify(item)}`);
}
function workItemFromSemantic({ item }: MessageStreamSemanticClassification): WorkMessageStreamItem {
if (item.kind === "taskProgress" || item.kind === "agent" || item.kind === "reasoning" || item.kind === "contextCompaction") return item;
throw new Error(`Message stream semantic expected work item: ${JSON.stringify(item)}`);
}
function unhandledClassification(classification: MessageStreamSemanticClassification): never {
throw new Error(`Unhandled message stream classification: ${JSON.stringify(classification)}`);
function definedProp<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
}

View file

@ -1,187 +0,0 @@
import { shortThreadId, truncate } from "../../../../utils";
import { agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel } from "./agent-summary";
import type {
AgentMessageStreamItem,
AgentRunSummary,
AgentRunSummaryAgent,
AgentStateSummary,
ContextCompactionMessageStreamItem,
ExecutionState,
MessageStreamItem,
ReasoningMessageStreamItem,
TaskProgressMessageStreamItem,
} from "../../domain/message-stream/items";
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;
export type WorkMessageStreamItem =
| TaskProgressMessageStreamItem
| AgentMessageStreamItem
| ReasoningMessageStreamItem
| ContextCompactionMessageStreamItem;
export type MessageStreamWorkView =
| {
kind: "taskProgress";
item: TaskProgressMessageStreamItem;
label: "tasks";
className: string;
state: ExecutionState;
}
| {
kind: "agent";
item: AgentMessageStreamItem;
label: "agent";
className: string;
state: ExecutionState;
summary: string;
metaRows: readonly { key: string; value: string }[];
prompt: string | null;
agentRows: readonly { threadId: string; threadLabel: string; status: string }[];
expandedMessages: readonly { threadId: string; threadLabel: string; message: string }[];
}
| {
kind: "contextCompaction";
item: ContextCompactionMessageStreamItem;
label: "context";
className: string;
state: ExecutionState;
summary: string;
}
| {
kind: "reasoning";
item: ReasoningMessageStreamItem;
active: boolean;
label: string;
text: string;
};
export interface MessageStreamWorkViewContext {
activeTurnId: string | null;
items: readonly MessageStreamItem[];
activeItems?: readonly MessageStreamItem[] | undefined;
}
export interface AgentRunSummaryView {
label: "agents";
className: string;
state: ExecutionState;
summary: string;
rows: readonly { threadId: string; threadLabel: string; status: string }[];
additionalAgents: number;
}
export function messageStreamWorkView(item: WorkMessageStreamItem, context: MessageStreamWorkViewContext): MessageStreamWorkView {
if (item.kind === "taskProgress") {
return { kind: "taskProgress", item, label: "tasks", className: "codex-panel__task-progress", state: item.executionState ?? null };
}
if (item.kind === "agent") return agentWorkView(item);
if (item.kind === "contextCompaction") return contextCompactionWorkView(item, context);
return reasoningWorkView(item, context);
}
export function agentRunSummaryView(summary: AgentRunSummary): AgentRunSummaryView {
return {
label: "agents",
className: "codex-panel__agent-summary",
state: summary.failed > 0 ? "failed" : "running",
summary: agentRunSummaryLabel(summary),
rows: summary.agents.map(agentRunSummaryRow),
additionalAgents: summary.additionalAgents,
};
}
function agentWorkView(item: AgentMessageStreamItem): MessageStreamWorkView {
return {
kind: "agent",
item,
label: "agent",
className: "codex-panel__agent-activity",
state: item.executionState ?? null,
summary: agentSummaryText(item),
metaRows: [
{ key: "tool", value: agentActivityMetaLabel(item.tool) },
{ key: "status", value: item.status },
{ key: "sender", value: item.senderThreadId },
...(item.receiverThreadIds.length > 0 ? [{ key: "target", value: item.receiverThreadIds.join(", ") }] : []),
...(item.model ? [{ key: "model", value: item.model }] : []),
...(item.reasoningEffort ? [{ key: "effort", value: item.reasoningEffort }] : []),
],
prompt: item.prompt,
agentRows: item.agents.map(agentStatusRow),
expandedMessages: item.agents.flatMap((agent) =>
agent.message && isLongAgentMessage(agent.message)
? [{ threadId: agent.threadId, threadLabel: shortThreadId(agent.threadId), message: agent.message }]
: [],
),
};
}
function contextCompactionWorkView(item: ContextCompactionMessageStreamItem, context: MessageStreamWorkViewContext): MessageStreamWorkView {
const active = context.activeTurnId === item.turnId;
return {
kind: "contextCompaction",
item,
label: "context",
className: "codex-panel__context-compaction",
state: active ? "running" : "completed",
summary: active ? "Compacting context..." : "Context compacted",
};
}
function reasoningWorkView(item: ReasoningMessageStreamItem, context: MessageStreamWorkViewContext): MessageStreamWorkView {
const active = isReasoningActive(item, context);
return {
kind: "reasoning",
item,
active,
label: active ? "reasoning" : "thought",
text: item.text || (active ? "Reasoning" : "Thought"),
};
}
function agentSummaryText(item: AgentMessageStreamItem): string {
const target = item.receiverThreadIds.length === 0 ? "" : ` ${item.receiverThreadIds.map(shortThreadId).join(", ")}`;
const promptPreview = agentPromptPreview(item.prompt);
return `${agentActivityMetaLabel(item.tool)}${target}${promptPreview ? `: ${promptPreview}` : ""} (${item.status})`;
}
function agentPromptPreview(prompt: string | null): string | null {
if (!prompt) return null;
const normalized = prompt.trim().replace(/\s+/g, " ");
return normalized ? truncate(normalized, AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT) : null;
}
function agentStatusRow(agent: AgentStateSummary): { threadId: string; threadLabel: string; status: string } {
return {
threadId: agent.threadId,
threadLabel: shortThreadId(agent.threadId),
status: agentStatusLabel(agent.status, agent.message),
};
}
function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; threadLabel: string; status: string } {
return {
threadId: agent.threadId,
threadLabel: shortThreadId(agent.threadId),
status: agent.messagePreview ? `${agent.status}: ${agent.messagePreview}` : agent.status,
};
}
function agentStatusLabel(status: string, message: string | null): string {
const preview = agentMessagePreview(message, AGENT_ROW_MESSAGE_PREVIEW_LIMIT);
return preview ? `${status}: ${preview}` : status;
}
function isLongAgentMessage(message: string): boolean {
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
}
function isReasoningActive(item: ReasoningMessageStreamItem, context: MessageStreamWorkViewContext): boolean {
const activeTurn = context.activeTurnId;
if (!activeTurn || item.turnId !== activeTurn) return false;
if (item.executionState === "completed") return false;
const latestActiveTurnItem = [...(context.activeItems ?? context.items)].reverse().find((candidate) => candidate.turnId === activeTurn);
return latestActiveTurnItem?.id === item.id;
}

View file

@ -2,8 +2,11 @@ import type { ComponentChild as UiNode } from "preact";
import type { ApprovalAction, PendingRequestId } from "../../domain/pending-requests/model";
import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/snapshot";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import type { TextMessageStreamItem } from "../../presentation/message-stream/text-view";
import type {
MessageStreamForkTarget,
MessageStreamPlanImplementationTarget,
MessageStreamRollbackTarget,
} from "../../presentation/message-stream/text-view";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
export interface MessageStreamBlock {
@ -11,21 +14,17 @@ export interface MessageStreamBlock {
node: UiNode;
}
export type { TextMessageStreamItem };
type MessageStreamDisclosureBucket =
| "toolResults"
| "details"
| "activityGroups"
| "agentDetails"
| "textDetails"
| "userMessageExpanded"
| "goalObjectiveExpanded"
| "approvalDetails";
export interface MessageStreamDisclosureState {
toolResults: ReadonlySet<string>;
details: ReadonlySet<string>;
activityGroups: ReadonlySet<string>;
agentDetails: ReadonlySet<string>;
textDetails: ReadonlySet<string>;
userMessageExpanded: ReadonlySet<string>;
goalObjectiveExpanded: ReadonlySet<string>;
@ -59,12 +58,9 @@ export interface TextItemActionContext extends TextItemDetailStateContext {
forkActionsItemId: string | null;
onForkActionsToggle?: (itemId: string | null) => void;
copyText?: (text: string) => void;
canImplementPlanItem?: (item: MessageStreamItem) => boolean;
onImplementPlanItem?: (item: MessageStreamItem) => void;
canRollbackItem?: (item: MessageStreamItem) => boolean;
onRollbackItem?: (item: MessageStreamItem) => void;
canForkItem?: (item: MessageStreamItem) => boolean;
onForkItem?: (item: MessageStreamItem, archiveSource: boolean) => void;
onImplementPlan?: (target: MessageStreamPlanImplementationTarget) => void;
onRollback?: (target: MessageStreamRollbackTarget) => void;
onFork?: (target: MessageStreamForkTarget, archiveSource: boolean) => void;
}
export interface TextItemMetadataContext extends TextItemDetailStateContext {

View file

@ -1,38 +1,38 @@
import type { ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { type ToolResultDetailSection, type ToolResultView } from "../../presentation/message-stream/tool-result-view";
import { renderRawDiffLines } from "../../../../shared/diff/render";
import { type DetailSection, type DetailView } from "../../presentation/message-stream/detail-view";
import type { MessageStreamDisclosureState } from "./context";
export interface ToolResultRenderContext {
export interface DetailRenderContext {
disclosures: MessageStreamDisclosureState;
onDisclosureToggle?: (bucket: "toolResults", id: string, open: boolean) => void;
onDisclosureToggle?: (bucket: "details", id: string, open: boolean) => void;
}
export function toolResultNode(view: ToolResultView, context: ToolResultRenderContext): UiNode {
return <ToolResult view={view} context={context} />;
export function detailNode(view: DetailView, context: DetailRenderContext): UiNode {
return <Detail view={view} context={context} />;
}
function ToolResult({ view, context }: { view: ToolResultView; context: ToolResultRenderContext }): UiNode {
const open = context.disclosures.toolResults.has(view.detailsKey);
function Detail({ view, context }: { view: DetailView; context: DetailRenderContext }): UiNode {
const open = context.disclosures.details.has(view.detailsKey);
const hasSummary = view.summary.trim().length > 0;
const className = [
view.className,
"codex-panel__tool-result",
view.details.length === 0 ? "codex-panel__tool-result--plain" : "",
"codex-panel__detail",
view.sections.length === 0 ? "codex-panel__detail--plain" : "",
view.state ? `codex-panel__execution codex-panel__execution--${view.state}` : "",
open ? "is-open" : "",
]
.filter(Boolean)
.join(" ");
if (view.details.length === 0) {
if (view.sections.length === 0) {
return (
<div className={className}>
<ToolResultHeader view={view} />
{hasSummary ? <ToolSummary text={view.summary} /> : null}
<DetailHeader view={view} />
{hasSummary ? <DetailSummary text={view.summary} /> : null}
</div>
);
}
@ -40,40 +40,40 @@ function ToolResult({ view, context }: { view: ToolResultView; context: ToolResu
return (
<div className={className}>
<details
className="codex-panel__tool-result-details"
className="codex-panel__detail-disclosure"
open={open}
onToggle={(event) => {
context.onDisclosureToggle?.("toolResults", view.detailsKey, event.currentTarget.open);
context.onDisclosureToggle?.("details", view.detailsKey, event.currentTarget.open);
}}
>
<ToolResultHeader view={view} />
{view.details.map((section, index) => (
<ToolResultDetailSection key={`${section.kind}:${section.title ?? ""}:${String(index)}`} section={section} />
<DetailHeader view={view} />
{view.sections.map((section, index) => (
<DetailSectionView key={`${section.kind}:${section.title ?? ""}:${String(index)}`} section={section} />
))}
</details>
{hasSummary ? <ToolSummary text={view.summary} /> : null}
{hasSummary ? <DetailSummary text={view.summary} /> : null}
</div>
);
}
function ToolResultHeader({ view }: { view: ToolResultView }): UiNode {
const content = <span className="codex-panel__message-role codex-panel__tool-result-label">{view.label}</span>;
return view.details.length > 0 ? (
<summary className="codex-panel__tool-result-header" tabIndex={-1}>
function DetailHeader({ view }: { view: DetailView }): UiNode {
const content = <span className="codex-panel__message-role codex-panel__detail-label">{view.label}</span>;
return view.sections.length > 0 ? (
<summary className="codex-panel__detail-header" tabIndex={-1}>
{content}
</summary>
) : (
<div className="codex-panel__tool-result-header">{content}</div>
<div className="codex-panel__detail-header">{content}</div>
);
}
function ToolSummary({ text }: { text: string }): UiNode {
return <div className="codex-panel__tool-summary">{text}</div>;
function DetailSummary({ text }: { text: string }): UiNode {
return <div className="codex-panel__stream-summary">{text}</div>;
}
function ToolResultDetailSection({ section }: { section: ToolResultDetailSection }): UiNode {
if (section.kind === "meta") {
return <MetaBlock title={section.title} rows={section.rows} />;
function DetailSectionView({ section }: { section: DetailSection }): UiNode {
if (section.kind === "kv") {
return <KeyValueBlock title={section.title} rows={section.rows} />;
}
if (section.kind === "diff") {
return (
@ -85,7 +85,7 @@ function ToolResultDetailSection({ section }: { section: ToolResultDetailSection
return <OutputBlock title={section.title} body={section.body} />;
}
function MetaBlock({
function KeyValueBlock({
title,
rows,
}: {
@ -95,7 +95,7 @@ function MetaBlock({
const body = (
<dl className="codex-panel__meta-grid">
{rows.map((row) => (
<FragmentPair key={`${row.key}\n${row.value}`} row={row} />
<KeyValuePair key={`${row.key}\n${row.value}`} row={row} />
))}
</dl>
);
@ -108,7 +108,7 @@ function MetaBlock({
);
}
function FragmentPair({ row }: { row: { key: string; value: string } }): UiNode {
function KeyValuePair({ row }: { row: { key: string; value: string } }): UiNode {
return (
<>
<dt>{row.key}</dt>

View file

@ -7,7 +7,7 @@ import {
type PendingUserInputViewModel,
} from "../../presentation/pending-requests/view-model";
import type { PendingRequestBlockActions } from "./context";
import { createWorkMessageClassName } from "./work-message";
import { createStatusMessageClassName } from "./status-message";
export function pendingRequestBlockNode(
approvals: readonly PendingApprovalViewModel[],
@ -61,7 +61,7 @@ function PendingRequestBlock({
}, [autoFocusRequested, consumeAutoFocus, autoFocusSignature]);
if (approvals.length === 0 && pendingUserInputs.length === 0) return null;
return (
<div ref={requestRef} className={createWorkMessageClassName("codex-panel__pending-request-block", "warning")}>
<div ref={requestRef} className={createStatusMessageClassName("codex-panel__pending-request-block", "warning")}>
<div className="codex-panel__message-role">Request</div>
{approvals.map((approval) => (
<ApprovalCard key={String(approval.requestId)} approval={approval} approvalDetails={approvalDetails} actions={actions} />

View file

@ -0,0 +1,127 @@
import type { ComponentChild as UiNode } from "preact";
import type { ExecutionState } from "../../domain/message-stream/items";
import type { AgentRunSummaryView, MessageStreamStatusView, StatusChecklistItem } from "../../presentation/message-stream/status-view";
import { createStatusMessageClassName } from "./status-message";
export function agentRunSummaryNode(view: AgentRunSummaryView): UiNode {
return <AgentRunSummaryItem view={view} />;
}
export function statusItemNode(view: MessageStreamStatusView): UiNode {
if (view.kind === "taskProgress") return <TaskProgressItem view={view} />;
if (view.kind === "contextCompaction") return <ContextCompactionItem view={view} />;
if (view.kind === "reasoning") return <ReasoningItem view={view} />;
return <GenericStatusItem view={view} />;
}
function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode {
return (
<StatusMessage label={view.label} className={view.className} state={view.state}>
<div className="codex-panel__stream-summary">{view.summary}</div>
<AgentSummaryRows view={view} />
</StatusMessage>
);
}
function TaskProgressItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "taskProgress" }> }): UiNode {
return (
<StatusMessage label={view.label} className={view.className} state={view.state}>
{view.summary ? <div className="codex-panel__stream-summary">{view.summary}</div> : null}
{view.checklist.length === 0 ? (
<div className="codex-panel__stream-summary">Plan updated</div>
) : (
<ul className="codex-panel__task-list">
{view.checklist.map((step) => (
<li key={`${step.status}\n${step.step}`} className={`codex-panel__task-step codex-panel__task-step--${step.status}`}>
<span className="codex-panel__task-marker">{taskStatusMarker(step.status)}</span>
<span className="codex-panel__task-text">{step.step}</span>
</li>
))}
</ul>
)}
</StatusMessage>
);
}
function taskStatusMarker(status: StatusChecklistItem["status"]): string {
if (status === "completed") return "[x]";
if (status === "inProgress") return "[>]";
return "[ ]";
}
function ContextCompactionItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "contextCompaction" }> }): UiNode {
return (
<StatusMessage label={view.label} className={view.className} state={view.state}>
<div className="codex-panel__stream-summary">{view.text}</div>
</StatusMessage>
);
}
function GenericStatusItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "generic" }> }): UiNode {
return (
<StatusMessage label={view.label} className={view.className} state={view.state}>
<div className="codex-panel__stream-summary">{view.text}</div>
</StatusMessage>
);
}
function ReasoningItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "reasoning" }> }): UiNode {
return (
<div className={`codex-panel__reasoning${view.active ? " is-active" : ""}`}>
<div className="codex-panel__reasoning-role">{view.label}</div>
<div className="codex-panel__reasoning-content">
<span>{view.text}</span>
{view.active ? (
<span className="codex-panel__reasoning-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
) : null}
</div>
</div>
);
}
function StatusMessage({
label,
className,
state,
children,
}: {
label: string;
className: string;
state: ExecutionState;
children: UiNode;
}): UiNode {
const classes = [createStatusMessageClassName(className), state ? `codex-panel__execution codex-panel__execution--${state}` : ""]
.filter(Boolean)
.join(" ");
return (
<div className={classes}>
<div className="codex-panel__message-role">{label}</div>
{children}
</div>
);
}
function AgentSummaryRows({ view }: { view: AgentRunSummaryView }): UiNode {
if (view.rows.length === 0 && view.additionalAgents === 0) return null;
return (
<ul className="codex-panel__agent-list codex-panel__agent-list--summary">
{view.rows.map((agent) => (
<li key={agent.threadId} className="codex-panel__agent-row">
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
</li>
))}
{view.additionalAgents > 0 ? (
<li className="codex-panel__agent-row codex-panel__agent-row--more">
<span className="codex-panel__agent-thread" />
<span className="codex-panel__agent-status">+{String(view.additionalAgents)} more</span>
</li>
) : null}
</ul>
);
}

View file

@ -0,0 +1,11 @@
export function createStatusMessageClassName(className: string, tone?: "warning"): string {
return [
"codex-panel__message",
"codex-panel__message--tool",
"codex-panel__status-message",
className,
tone ? `codex-panel__status-message--${tone}` : "",
]
.filter(Boolean)
.join(" ");
}

View file

@ -6,15 +6,15 @@ import {
type MessageStreamViewBlock,
} from "../../presentation/message-stream/view-model";
import { pendingRequestBlockNode } from "./pending-request-block";
import { toolResultNode } from "./tool-result";
import { agentRunSummaryNode, workItemNode } from "./work-items";
import { detailNode } from "./detail";
import { agentRunSummaryNode, statusItemNode } from "./status-items";
import type { MessageStreamBlock, MessageStreamContext, PendingRequestBlockContext } from "./context";
import { textItemNode } from "./text-item";
function streamItemNode(item: MessageStreamRenderedItemView, context: MessageStreamContext): UiNode {
if (item.kind === "text") return textItemNode(item.view, context);
if (item.kind === "toolResult") return toolResultNode(item.view, context);
return workItemNode(item.view, context);
if (item.kind === "detail") return detailNode(item.view, context);
return statusItemNode(item.view);
}
export function messageStreamBlocks(viewBlocks: readonly MessageStreamViewBlock[], context: MessageStreamContext): MessageStreamBlock[] {
@ -110,11 +110,11 @@ function ActivityGroup({
function SteeringActivity({ activity }: { activity: Extract<MessageStreamActivityItemView, { type: "steering" }> }): UiNode {
return (
<div className="codex-panel__message codex-panel__message--tool codex-panel__tool-item codex-panel__tool-result codex-panel__tool-result--plain">
<div className="codex-panel__tool-result-header">
<span className="codex-panel__message-role codex-panel__tool-result-label">{activity.label}</span>
<div className="codex-panel__message codex-panel__message--tool codex-panel__detail-item codex-panel__detail codex-panel__detail--plain">
<div className="codex-panel__detail-header">
<span className="codex-panel__message-role codex-panel__detail-label">{activity.label}</span>
</div>
<div className="codex-panel__tool-summary">{activity.text}</div>
<div className="codex-panel__stream-summary">{activity.text}</div>
</div>
);
}

View file

@ -1,14 +1,15 @@
import { type ComponentChild as UiNode } from "preact";
import { useEffect, useRef } from "preact/hooks";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import type { MessageStreamTextView } from "../../presentation/message-stream/text-view";
import { IconButton } from "../../../../shared/ui/components";
import { listenDomEvent } from "../../../../shared/ui/dom-events";
import type { TextItemActionContext, TextMessageStreamItem } from "./context";
import type { TextItemActionContext } from "./context";
export function TextItemHeader({ item, context }: { item: TextMessageStreamItem; context: TextItemActionContext }): UiNode {
const forkActionsOpen = context.forkActionsItemId === item.id;
export function TextItemHeader({ view, context }: { view: MessageStreamTextView; context: TextItemActionContext }): UiNode {
const forkActionsOpen = context.forkActionsItemId === view.id;
const roleRef = useRef<HTMLDivElement | null>(null);
const { fork, implementPlan, rollback } = view.actions;
useEffect(() => {
if (!forkActionsOpen) return;
@ -22,32 +23,32 @@ export function TextItemHeader({ item, context }: { item: TextMessageStreamItem;
}, [context, forkActionsOpen]);
const copyAction =
item.kind === "message" && context.copyText && isMessageCopyActionVisible(item, context) && !forkActionsOpen ? (
view.copyText !== undefined && context.copyText && !forkActionsOpen ? (
<TextItemAction
icon="copy"
label="Copy message"
className="codex-panel__copy-message"
onClick={() => context.copyText?.(item.copyText ?? item.text)}
onClick={() => context.copyText?.(view.copyText ?? "")}
/>
) : null;
return (
<div ref={roleRef} className={`codex-panel__message-role${forkActionsOpen ? " codex-panel__message-role--fork-open" : ""}`}>
<span>{displayRoleLabel(item)}</span>
{forkActionsOpen && context.canForkItem?.(item) ? (
<span>{view.roleLabel}</span>
{forkActionsOpen && fork ? (
<TextItemAction
icon="archive"
label="Fork and archive"
className="codex-panel__fork-and-archive-message"
onClick={() => {
context.onForkActionsToggle?.(null);
context.onForkItem?.(item, true);
context.onFork?.(fork, true);
}}
/>
) : (
copyAction
)}
{context.canForkItem?.(item) ? (
{fork ? (
<TextItemAction
icon={forkActionsOpen ? "file-plus-corner" : "lucide-split"}
label={forkActionsOpen ? "Fork" : "Fork from here"}
@ -55,27 +56,27 @@ export function TextItemHeader({ item, context }: { item: TextMessageStreamItem;
onClick={() => {
if (forkActionsOpen) {
context.onForkActionsToggle?.(null);
context.onForkItem?.(item, false);
context.onFork?.(fork, false);
} else {
context.onForkActionsToggle?.(item.id);
context.onForkActionsToggle?.(view.id);
}
}}
/>
) : null}
{context.canImplementPlanItem?.(item) ? (
{implementPlan ? (
<TextItemAction
icon="play"
label="Implement plan"
className="codex-panel__implement-plan"
onClick={() => context.onImplementPlanItem?.(item)}
onClick={() => context.onImplementPlan?.(implementPlan)}
/>
) : null}
{context.canRollbackItem?.(item) ? (
{rollback ? (
<TextItemAction
icon="undo-2"
label="Rollback last turn"
className="codex-panel__rollback-turn"
onClick={() => context.onRollbackItem?.(item)}
onClick={() => context.onRollback?.(rollback)}
/>
) : null}
</div>
@ -106,16 +107,3 @@ function TextItemAction({
/>
);
}
function displayRoleLabel(item: MessageStreamItem): string {
if (item.kind === "userInputResult") return "Input";
if (item.role === "user") return "You";
if (item.role === "assistant") return "Codex";
return "System";
}
function isMessageCopyActionVisible(item: MessageStreamItem, context: Pick<TextItemActionContext, "turnLifecycle">): boolean {
if (item.kind !== "message" || item.copyText === undefined) return false;
const activeTurn = context.turnLifecycle.kind === "running" ? context.turnLifecycle.turnId : null;
return !(activeTurn && item.role === "assistant" && item.turnId === activeTurn);
}

View file

@ -25,7 +25,7 @@ function TextItem({ view, context }: { view: MessageStreamTextView; context: Tex
const { item } = view;
return (
<div className={view.className}>
<TextItemHeader item={item} context={context} />
<TextItemHeader view={view} context={context} />
{view.collapsible ? (
<CollapsibleTextItemContent view={view} context={context} />
) : (
@ -69,7 +69,7 @@ function CollapsibleTextItemContent({ view, context }: { view: MessageStreamText
return () => {
content.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update);
};
}, [item.id, item.text, view.contentMode]);
}, [item.id, view.body, view.contentMode]);
useEffect(() => {
if (!overflows || !expanded) return;
@ -121,8 +121,7 @@ interface TextContentProps {
function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode {
const rendersMarkdown = view.contentMode === "markdown";
const { item } = view;
const text = item.text;
const text = view.body;
const localRef = useRef<HTMLDivElement | null>(null);
const contextRef = useRef(context);
useLayoutEffect(() => {

View file

@ -1,181 +0,0 @@
import type { ComponentChild as UiNode } from "preact";
import type { ExecutionState, TaskProgressMessageStreamItem } from "../../domain/message-stream/items";
import type { AgentRunSummaryView, MessageStreamWorkView } from "../../presentation/message-stream/work-view";
import type { MessageStreamDisclosureState } from "./context";
import { createWorkMessageClassName } from "./work-message";
export interface WorkItemContext {
disclosures: MessageStreamDisclosureState;
onDisclosureToggle?: (bucket: "agentDetails", id: string, open: boolean) => void;
}
export function agentRunSummaryNode(view: AgentRunSummaryView): UiNode {
return <AgentRunSummaryItem view={view} />;
}
export function workItemNode(view: MessageStreamWorkView, context: WorkItemContext): UiNode {
if (view.kind === "taskProgress") return <TaskProgressItem view={view} />;
if (view.kind === "agent") return <AgentItem view={view} context={context} />;
if (view.kind === "contextCompaction") return <ContextCompactionItem view={view} />;
return <ReasoningItem view={view} />;
}
function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode {
return (
<WorkMessage label={view.label} className={view.className} state={view.state}>
<div className="codex-panel__tool-summary">{view.summary}</div>
<AgentSummaryRows view={view} />
</WorkMessage>
);
}
function TaskProgressItem({ view }: { view: Extract<MessageStreamWorkView, { kind: "taskProgress" }> }): UiNode {
const { item } = view;
return (
<WorkMessage label={view.label} className={view.className} state={view.state}>
{item.explanation ? <div className="codex-panel__tool-summary">{item.explanation}</div> : null}
{item.steps.length === 0 ? (
<div className="codex-panel__tool-summary">Plan updated</div>
) : (
<ul className="codex-panel__task-list">
{item.steps.map((step) => (
<li key={`${step.status}\n${step.step}`} className={`codex-panel__task-step codex-panel__task-step--${step.status}`}>
<span className="codex-panel__task-marker">{taskStatusMarker(step.status)}</span>
<span className="codex-panel__task-text">{step.step}</span>
</li>
))}
</ul>
)}
</WorkMessage>
);
}
function taskStatusMarker(status: TaskProgressMessageStreamItem["steps"][number]["status"]): string {
if (status === "completed") return "[x]";
if (status === "inProgress") return "[>]";
return "[ ]";
}
function ContextCompactionItem({ view }: { view: Extract<MessageStreamWorkView, { kind: "contextCompaction" }> }): UiNode {
return (
<WorkMessage label={view.label} className={view.className} state={view.state}>
<div className="codex-panel__tool-summary">{view.summary}</div>
</WorkMessage>
);
}
function AgentItem({ view, context }: { view: Extract<MessageStreamWorkView, { kind: "agent" }>; context: WorkItemContext }): UiNode {
const { item } = view;
const detailsOpen = context.disclosures.agentDetails.has(item.id);
return (
<WorkMessage label={view.label} className={`${view.className}${detailsOpen ? " is-open" : ""}`} state={view.state}>
<div className="codex-panel__tool-summary codex-panel__agent-activity-summary">{view.summary}</div>
<details
className="codex-panel__output codex-panel__agent-details"
open={detailsOpen}
onToggle={(event) => {
context.onDisclosureToggle?.("agentDetails", item.id, event.currentTarget.open);
}}
>
<summary tabIndex={-1}>Details</summary>
<dl className="codex-panel__meta-grid">
{view.metaRows.map((row) => (
<MetaPair key={`${row.key}\n${row.value}`} name={row.key} value={row.value} />
))}
</dl>
{view.prompt ? (
<section className="codex-panel__agent-detail-section">
<div className="codex-panel__output-title">Prompt</div>
<pre>{view.prompt}</pre>
</section>
) : null}
{view.agentRows.length > 0 ? (
<ul className="codex-panel__agent-list">
{view.agentRows.map((agent) => (
<li key={agent.threadId} className="codex-panel__agent-row">
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
</li>
))}
</ul>
) : null}
{view.expandedMessages.map((agent) => (
<section key={agent.threadId} className="codex-panel__agent-detail-section">
<div className="codex-panel__output-title">Agent output {agent.threadLabel}</div>
<pre>{agent.message}</pre>
</section>
))}
</details>
</WorkMessage>
);
}
function ReasoningItem({ view }: { view: Extract<MessageStreamWorkView, { kind: "reasoning" }> }): UiNode {
return (
<div className={`codex-panel__reasoning${view.active ? " is-active" : ""}`}>
<div className="codex-panel__reasoning-role">{view.label}</div>
<div className="codex-panel__reasoning-content">
<span>{view.text}</span>
{view.active ? (
<span className="codex-panel__reasoning-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
) : null}
</div>
</div>
);
}
function WorkMessage({
label,
className,
state,
children,
}: {
label: string;
className: string;
state: ExecutionState;
children: UiNode;
}): UiNode {
const classes = [createWorkMessageClassName(className), state ? `codex-panel__execution codex-panel__execution--${state}` : ""]
.filter(Boolean)
.join(" ");
return (
<div className={classes}>
<div className="codex-panel__message-role">{label}</div>
{children}
</div>
);
}
function MetaPair({ name, value }: { name: string; value: string }): UiNode {
return (
<>
<dt>{name}</dt>
<dd>{value}</dd>
</>
);
}
function AgentSummaryRows({ view }: { view: AgentRunSummaryView }): UiNode {
if (view.rows.length === 0 && view.additionalAgents === 0) return null;
return (
<ul className="codex-panel__agent-list codex-panel__agent-list--summary">
{view.rows.map((agent) => (
<li key={agent.threadId} className="codex-panel__agent-row">
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
</li>
))}
{view.additionalAgents > 0 ? (
<li className="codex-panel__agent-row codex-panel__agent-row--more">
<span className="codex-panel__agent-thread" />
<span className="codex-panel__agent-status">+{String(view.additionalAgents)} more</span>
</li>
) : null}
</ul>
);
}

View file

@ -1,11 +0,0 @@
export function createWorkMessageClassName(className: string, tone?: "warning"): string {
return [
"codex-panel__message",
"codex-panel__message--tool",
"codex-panel__work-message",
className,
tone ? `codex-panel__work-message--${tone}` : "",
]
.filter(Boolean)
.join(" ");
}

View file

@ -157,7 +157,7 @@
}
.codex-panel__activity-group > .codex-panel__message,
.codex-panel__activity-group > .codex-panel__work-message,
.codex-panel__activity-group > .codex-panel__status-message,
.codex-panel__activity-group > .codex-panel__reasoning {
margin-top: var(--codex-panel-section-gap);
margin-bottom: var(--codex-panel-section-gap);

View file

@ -1,4 +1,4 @@
.codex-panel__work-message {
.codex-panel__status-message {
min-width: 0;
max-width: 100%;
margin-bottom: var(--codex-panel-section-gap);
@ -9,21 +9,20 @@
background: transparent;
}
.codex-panel__work-message .codex-panel__message-role {
.codex-panel__status-message .codex-panel__message-role {
font-size: var(--codex-panel-secondary-label-size);
font-weight: var(--codex-panel-secondary-label-weight);
}
.codex-panel__work-message--warning {
.codex-panel__status-message--warning {
border-left-color: var(--codex-panel-color-warning);
}
.codex-panel__work-message--warning > .codex-panel__message-role {
.codex-panel__status-message--warning > .codex-panel__message-role {
color: var(--codex-panel-color-warning);
}
.codex-panel__tool-item,
.codex-panel__file-change {
.codex-panel__detail-item {
min-width: 0;
max-width: 100%;
margin-bottom: var(--codex-panel-section-gap);
@ -34,8 +33,7 @@
background: transparent;
}
.codex-panel__tool-item .codex-panel__message-role,
.codex-panel__file-change .codex-panel__message-role {
.codex-panel__detail-item .codex-panel__message-role {
font-size: var(--codex-panel-secondary-label-size);
font-weight: var(--codex-panel-secondary-label-weight);
}
@ -52,11 +50,11 @@
border-left-color: var(--codex-panel-color-danger);
}
.codex-panel__tool-item--hook:not(.codex-panel__execution) {
.codex-panel__detail-item--hook:not(.codex-panel__execution) {
border-left-color: var(--text-accent);
}
.codex-panel__tool-result-header {
.codex-panel__detail-header {
display: flex;
align-items: center;
min-width: 0;
@ -64,34 +62,34 @@
cursor: default;
}
.codex-panel__tool-result--plain .codex-panel__tool-result-header {
.codex-panel__detail--plain .codex-panel__detail-header {
cursor: text;
}
.codex-panel__tool-result-details > .codex-panel__tool-result-header:hover,
.codex-panel__tool-result-details > .codex-panel__tool-result-header:hover .codex-panel__message-role {
.codex-panel__detail-disclosure > .codex-panel__detail-header:hover,
.codex-panel__detail-disclosure > .codex-panel__detail-header:hover .codex-panel__message-role {
color: var(--nav-item-color-hover, var(--text-normal));
}
.codex-panel__tool-result-header .codex-panel__message-role {
.codex-panel__detail-header .codex-panel__message-role {
margin-bottom: 0;
}
.codex-panel__tool-result > .codex-panel__output,
.codex-panel__tool-result > .codex-panel-diff-file,
.codex-panel__tool-result > .codex-panel__meta-grid,
.codex-panel__tool-result-details > .codex-panel__output,
.codex-panel__tool-result-details > .codex-panel-diff-file,
.codex-panel__tool-result-details > .codex-panel__meta-grid {
.codex-panel__detail > .codex-panel__output,
.codex-panel__detail > .codex-panel-diff-file,
.codex-panel__detail > .codex-panel__meta-grid,
.codex-panel__detail-disclosure > .codex-panel__output,
.codex-panel__detail-disclosure > .codex-panel-diff-file,
.codex-panel__detail-disclosure > .codex-panel__meta-grid {
margin-left: 0;
}
.codex-panel__tool-result-details {
.codex-panel__detail-disclosure {
cursor: text;
user-select: text;
}
.codex-panel__tool-summary {
.codex-panel__stream-summary {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--text-muted);
@ -100,7 +98,7 @@
user-select: text;
}
.codex-panel__tool-result > .codex-panel__tool-summary {
.codex-panel__detail > .codex-panel__stream-summary {
min-width: 0;
max-width: 100%;
margin-top: var(--codex-panel-panel-gap);
@ -110,7 +108,7 @@
white-space: nowrap;
}
.codex-panel__tool-result.is-open > .codex-panel__tool-summary {
.codex-panel__detail.is-open > .codex-panel__stream-summary {
display: none;
}
@ -155,27 +153,6 @@
overflow-wrap: anywhere;
}
.codex-panel__agent-activity > .codex-panel__agent-activity-summary {
min-width: 0;
max-width: 100%;
overflow: hidden;
line-height: var(--line-height-tight);
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-panel__agent-activity.is-open > .codex-panel__agent-activity-summary {
display: none;
}
.codex-panel__agent-detail-section {
margin-top: var(--codex-panel-section-gap);
}
.codex-panel__agent-detail-section > pre {
margin: var(--codex-panel-item-gap) 0 0;
}
.codex-panel__agent-list--summary {
margin-top: var(--codex-panel-panel-gap);
}

View file

@ -9,7 +9,7 @@
"23-chat-goal.css",
"30-chat-layout.css",
"31-chat-messages.css",
"32-chat-work-items.css",
"32-chat-stream-items.css",
"33-chat-pending-requests.css",
"34-chat-composer.css",
"35-chat-turn-diff-view.css",

View file

@ -93,7 +93,7 @@ describe("implementPlan", () => {
resumeThread(stateStore, [plan]);
stateStore.dispatch({ type: "ui/panel-set", panel: "status-panel" });
await implementPlan(host, plan);
await implementPlan(host, plan.id);
expect(ensureConnected).toHaveBeenCalledOnce();
expect(requestDefaultCollaborationModeForNextTurn).toHaveBeenCalledOnce();
@ -108,7 +108,7 @@ describe("implementPlan", () => {
const latest = planItem("latest");
resumeThread(stateStore, [first, latest]);
await implementPlan(host, first);
await implementPlan(host, first.id);
expect(ensureConnected).not.toHaveBeenCalled();
expect(sendTurnText).not.toHaveBeenCalled();

View file

@ -811,7 +811,7 @@ describe("turn item conversion preserves app-server semantics", () => {
});
});
it("preserves context compaction items as short work items", () => {
it("preserves context compaction items as short status items", () => {
const item: TurnItem = { type: "contextCompaction", id: "compact-1" };
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
@ -897,7 +897,7 @@ describe("permission detail rows", () => {
});
});
describe("display block grouping keeps work logs subordinate to conversation messages", () => {
describe("display block grouping keeps message stream details subordinate to conversation messages", () => {
it("groups completed turn activities before the final assistant message", () => {
const items: MessageStreamItem[] = [
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },

View file

@ -25,8 +25,8 @@ describe("message stream presentation blocks", () => {
items: [userMessage("u1", "turn"), taskProgressItem("task", "turn"), assistantMessage("a1", "turn")],
});
expect(blocks.map((block) => block.kind)).toEqual(["text", "text", "work"]);
expect(blocks.find((block) => block.key === "live-task:task")).toMatchObject({ kind: "work" });
expect(blocks.map((block) => block.kind)).toEqual(["text", "text", "status"]);
expect(blocks.find((block) => block.key === "live-task:task")).toMatchObject({ kind: "status" });
});
it("anchors active agent summaries at the first active agent item", () => {
@ -38,9 +38,40 @@ describe("message stream presentation blocks", () => {
items: [userMessage("u1", "turn"), agentItem("agent", "turn")],
});
expect(blocks.map((block) => block.kind)).toEqual(["text", "work", "liveAgentSummary"]);
expect(blocks.map((block) => block.kind)).toEqual(["text", "detail", "liveAgentSummary"]);
expect(blocks.find((block) => block.kind === "liveAgentSummary")).toMatchObject({ key: "live-agents:turn" });
});
it("renders unknown item kinds as generic status updates", () => {
const blocks = messageStreamViewBlocks({
activeThreadId: "thread",
activeTurnId: null,
historyCursor: null,
loadingHistory: false,
items: [
{
id: "unknown",
kind: "futureKind",
role: "tool",
status: "running",
output: "raw output",
operation: "future operation",
} as unknown as MessageStreamItem,
],
});
expect(blocks).toMatchObject([
{
kind: "status",
key: "item:unknown",
view: {
kind: "generic",
label: "futureKind",
text: "running",
},
},
]);
});
});
function userMessage(id: string, turnId: string): MessageStreamItem {

View file

@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { messageStreamItemFromTurnItem } from "../../../../src/features/chat/app-server/mappers/message-stream/turn-items";
import { messageStreamSemanticClassifications } from "../../../../src/features/chat/domain/message-stream/semantics";
import {
messageStreamIsAutoReviewDecision,
messageStreamSemanticClassifications,
} from "../../../../src/features/chat/domain/message-stream/semantics";
import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items";
import type { TurnItem } from "../../../../src/app-server/protocol/turn";
@ -62,7 +65,7 @@ describe("message stream semantic classification", () => {
expect(semantic.map(({ placement }) => ("turnRole" in placement ? placement.turnRole : null))).toEqual(["initiator", "initiator"]);
});
it("classifies work item meaning independently from payload shape", () => {
it("classifies message stream item meaning independently from payload shape", () => {
const semantic = messageStreamSemanticClassifications([
commandItem("cmd"),
{
@ -143,6 +146,7 @@ describe("message stream semantic classification", () => {
{ plane: "permission", event: "decision" },
{ plane: "permission", event: "decision" },
]);
expect(semantic.map(messageStreamIsAutoReviewDecision)).toEqual([true, true]);
});
it("marks completed proposed plans as implementable turn outcomes", () => {

View file

@ -803,9 +803,8 @@ function goal(threadId: string): ThreadGoal {
function uiDisclosureCount(state: ChatState): number {
const disclosures = state.ui.disclosures;
return (
disclosures.toolResults.size +
disclosures.details.size +
disclosures.activityGroups.size +
disclosures.agentDetails.size +
disclosures.textDetails.size +
disclosures.userMessageExpanded.size +
disclosures.goalObjectiveExpanded.size +

View file

@ -111,7 +111,7 @@ describe("message stream rendering and message actions", () => {
const element = renderMessageBlockElement(block);
expect(element.classList.contains("codex-panel__message--review-result")).toBe(true);
expect(element.classList.contains("codex-panel__tool-result--plain")).toBe(true);
expect(element.classList.contains("codex-panel__detail--plain")).toBe(true);
expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("auto-review");
expect(element.textContent).toContain("Auto-review denied this command.");
expect(element.querySelector("details")).toBeNull();
@ -135,7 +135,7 @@ describe("message stream rendering and message actions", () => {
auditFacts: [
{ key: "status", value: "approved" },
{ key: "action", value: "apply patch" },
{ key: "files", value: "src/ui/tool-result-view.ts\nsrc/ui/message-stream.ts" },
{ key: "files", value: "src/ui/detail-view.ts\nsrc/ui/message-stream.ts" },
],
},
},
@ -155,9 +155,7 @@ describe("message stream rendering and message actions", () => {
expect(element.textContent).not.toContain("▶Review");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain(
"filessrc/ui/tool-result-view.ts\nsrc/ui/message-stream.ts",
);
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("filessrc/ui/detail-view.ts\nsrc/ui/message-stream.ts");
expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]);
});
@ -227,8 +225,8 @@ describe("message stream rendering and message actions", () => {
const element = renderMessageBlockElement(block);
expect(element.classList.contains("codex-panel__message--tool")).toBe(true);
expect(element.querySelector(".codex-panel__tool-result-label")?.textContent).toBe("goal");
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("set: Ship the feature");
expect(element.querySelector(".codex-panel__detail-label")?.textContent).toBe("goal");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("set: Ship the feature");
expect(element.querySelector("details")?.open).toBe(false);
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionset");
expect(element.querySelector(".codex-panel__output-title")?.textContent).toBe("Objective");
@ -236,7 +234,7 @@ describe("message stream rendering and message actions", () => {
});
it("renders rollback action only for the eligible user message", () => {
const onRollbackItem = vi.fn();
const onRollback = vi.fn();
const items = [
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" },
{
@ -260,8 +258,8 @@ describe("message stream rendering and message actions", () => {
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
canRollbackItem: (item) => item.id === "u2",
onRollbackItem,
textActionsByItemId: new Map([["u2", { rollback: { itemId: "u2", turnId: "turn-2" } }]]),
onRollback,
});
const rendered = blocks.map((block) => renderMessageBlockElement(block));
@ -271,7 +269,7 @@ describe("message stream rendering and message actions", () => {
const button = expectPresent(rendered[2]).querySelector<HTMLButtonElement>(".codex-panel__rollback-turn");
expect(button?.getAttribute("aria-label")).toBe("Rollback last turn");
button?.click();
expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u2" }));
expect(onRollback).toHaveBeenCalledWith({ itemId: "u2", turnId: "turn-2" });
});
it("renders copy actions for copyable messages", () => {
@ -315,7 +313,7 @@ describe("message stream rendering and message actions", () => {
it("expands assistant fork actions in the copy action region and defaults repeat clicks to plain fork", () => {
const onForkActionsToggle = vi.fn();
const onForkItem = vi.fn();
const onFork = vi.fn();
const item: MessageStreamItem = {
id: "a1",
kind: "message",
@ -339,8 +337,8 @@ describe("message stream rendering and message actions", () => {
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
copyText: vi.fn(),
canForkItem: () => true,
onForkItem,
textActionsByItemId: new Map([["a1", { fork: { itemId: "a1", turnId: "turn-1" } }]]),
onFork,
})[0];
const closedElement = renderMessageBlockElement(closedBlock);
@ -363,8 +361,8 @@ describe("message stream rendering and message actions", () => {
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
copyText: vi.fn(),
canForkItem: () => true,
onForkItem,
textActionsByItemId: new Map([["a1", { fork: { itemId: "a1", turnId: "turn-1" } }]]),
onFork,
})[0];
const openElement = renderMessageBlockElement(openBlock);
@ -376,11 +374,11 @@ describe("message stream rendering and message actions", () => {
expect(openFork.getAttribute("aria-label")).toBe("Fork");
expect(openFork.getAttribute("data-icon")).toBe("file-plus-corner");
openFork.click();
expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), false);
expect(onFork).toHaveBeenCalledWith({ itemId: "a1", turnId: "turn-1" }, false);
});
it("runs fork and archive from the expanded assistant fork actions", () => {
const onForkItem = vi.fn();
const onFork = vi.fn();
const item: MessageStreamItem = {
id: "a1",
kind: "message",
@ -403,14 +401,14 @@ describe("message stream rendering and message actions", () => {
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
copyText: vi.fn(),
canForkItem: () => true,
onForkItem,
textActionsByItemId: new Map([["a1", { fork: { itemId: "a1", turnId: "turn-1" } }]]),
onFork,
})[0];
const element = renderMessageBlockElement(block);
expectPresent(element.querySelector<HTMLButtonElement>(".codex-panel__fork-and-archive-message")).click();
expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), true);
expect(onFork).toHaveBeenCalledWith({ itemId: "a1", turnId: "turn-1" }, true);
});
it("updates message content when a streaming plan delta completes", () => {
@ -761,7 +759,7 @@ describe("message stream rendering and message actions", () => {
});
it("renders implement plan action for eligible proposed plans", () => {
const onImplementPlanItem = vi.fn();
const onImplementPlan = vi.fn();
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
@ -784,8 +782,8 @@ describe("message stream rendering and message actions", () => {
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
copyText: vi.fn(),
canImplementPlanItem: () => true,
onImplementPlanItem,
textActionsByItemId: new Map([["p1", { implementPlan: { itemId: "p1" } }]]),
onImplementPlan,
})[0];
const element = renderMessageBlockElement(block);
@ -793,7 +791,7 @@ describe("message stream rendering and message actions", () => {
expect(button?.getAttribute("aria-label")).toBe("Implement plan");
button?.click();
expect(onImplementPlanItem).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" }));
expect(onImplementPlan).toHaveBeenCalledWith({ itemId: "p1" });
});
it("selects only the latest proposed plan as an implement candidate", () => {
@ -869,7 +867,7 @@ describe("message stream rendering and message actions", () => {
it("renders copy and rollback actions together when both apply", () => {
const copyText = vi.fn();
const onRollbackItem = vi.fn();
const onRollback = vi.fn();
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
@ -881,8 +879,8 @@ describe("message stream rendering and message actions", () => {
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
copyText,
canRollbackItem: () => true,
onRollbackItem,
textActionsByItemId: new Map([["u1", { rollback: { itemId: "u1", turnId: "turn-1" } }]]),
onRollback,
})[0];
const element = renderMessageBlockElement(block);
@ -890,7 +888,7 @@ describe("message stream rendering and message actions", () => {
element.querySelector<HTMLButtonElement>(".codex-panel__rollback-turn")?.click();
expect(copyText).toHaveBeenCalledWith("latest");
expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u1" }));
expect(onRollback).toHaveBeenCalledWith({ itemId: "u1", turnId: "turn-1" });
});
it("collapses tall user messages without changing the copy payload", () => {
@ -1029,8 +1027,6 @@ describe("message stream rendering and message actions", () => {
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
canRollbackItem: () => false,
onRollbackItem: vi.fn(),
})[0];
expect(renderMessageBlockElement(block).querySelector(".codex-panel__rollback-turn")).toBeNull();
@ -1065,8 +1061,8 @@ describe("message stream rendering and message actions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("npm run check (exit 1)");
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull();
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("npm run check (exit 1)");
expect(element.querySelector(".codex-panel__stream-summary")?.getAttribute("title")).toBeNull();
expect(topLevelDetailsSummaries(element)).toEqual(["command"]);
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["command"]);
expect(element.textContent).not.toContain("Details");
@ -1171,7 +1167,7 @@ describe("message stream rendering and message actions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe('"semantic target" in src');
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe('"semantic target" in src');
});
it("renders file diffs inside a single file change details block", () => {
@ -1200,7 +1196,7 @@ describe("message stream rendering and message actions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/main.ts");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("src/main.ts");
expect(topLevelDetailsSummaries(element)).toEqual(["file change"]);
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["file change"]);
expect(element.textContent).not.toContain("Details");
@ -1235,7 +1231,7 @@ describe("message stream rendering and message actions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/main.ts (failed)");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("src/main.ts (failed)");
});
it("renders the edited files footer with an open diff action when aggregated turn diff exists", () => {

View file

@ -49,8 +49,8 @@ describe("pending request renderer decisions", () => {
);
expect(parent.querySelectorAll(".codex-panel__pending-request-block")).toHaveLength(1);
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__work-message")).toBe(true);
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__work-message--warning")).toBe(
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__status-message")).toBe(true);
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__status-message--warning")).toBe(
true,
);
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")?.textContent).toBe("Submit");
@ -420,11 +420,11 @@ describe("pending request renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.classList.contains("codex-panel__message--approval-result")).toBe(true);
expect(element.classList.contains("codex-panel__tool-result")).toBe(true);
expect(element.classList.contains("codex-panel__detail")).toBe(true);
expect(element.classList.contains("codex-panel__execution--completed")).toBe(true);
expect(element.querySelector(".codex-panel__message-content")).toBeNull();
expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("approval");
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Allowed for this session: Need access");
expect(element.querySelector(".codex-panel__detail-header")?.textContent).toBe("approval");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("Allowed for this session: Need access");
expect(element.querySelector("details summary")?.textContent).toBe("approval");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusallowed for session");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("scopesession");

View file

@ -17,7 +17,7 @@ import {
unmountUiRootInAct,
} from "./test-helpers";
describe("work log renderer decisions", () => {
describe("message stream item renderer decisions", () => {
it("renders generic tool details as visible sections inside one details block", () => {
const block = messageStreamBlocks({
activeThreadId: "thread",
@ -47,7 +47,7 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("123");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("123");
expect(topLevelDetailsSummaries(element)).toEqual(["github.pull_request_read"]);
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["github.pull_request_read"]);
expect(element.querySelector<HTMLElement>("details summary")?.tabIndex).toBe(-1);
@ -92,8 +92,8 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(expectPresent(block));
expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("steer");
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("also check tests and keep the summary compact");
expect(element.querySelector(".codex-panel__detail-header")?.textContent).toBe("steer");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("also check tests and keep the summary compact");
});
it("renders path summary tools relative to the workspace root", () => {
@ -122,8 +122,8 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png");
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull();
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("assets/image.png");
expect(element.querySelector(".codex-panel__stream-summary")?.getAttribute("title")).toBeNull();
});
it("derives generic tool summaries from primary targets instead of item text", () => {
@ -153,7 +153,7 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("search: codex app-server");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("search: codex app-server");
});
it("updates path summary tools when the workspace root changes", () => {
@ -180,10 +180,10 @@ describe("work log renderer decisions", () => {
};
renderMessageStreamBlocksInAct(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault" }));
expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("project/assets/image.png");
expect(parent.querySelector(".codex-panel__stream-summary")?.textContent).toBe("project/assets/image.png");
renderMessageStreamBlocksInAct(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault/project" }));
expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png");
expect(parent.querySelector(".codex-panel__stream-summary")?.textContent).toBe("assets/image.png");
unmountUiRootInAct(parent);
});
@ -213,7 +213,7 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/tmp/image.png");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("/tmp/image.png");
});
it("does not treat generic tool summaries as paths without an explicit marker", () => {
@ -241,7 +241,7 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/vault/project");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("/vault/project");
});
it("renders hook metadata as rows inside one details block", () => {
@ -279,7 +279,7 @@ describe("work log renderer decisions", () => {
expect(topLevelDetailsSummaries(element)).toEqual(["hook"]);
expect(element.classList.contains("codex-panel__execution--completed")).toBe(true);
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["hook"]);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
expect(element.textContent).not.toContain("Details");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse");
@ -321,7 +321,7 @@ describe("work log renderer decisions", () => {
messageState: "completed",
},
],
disclosures: testDisclosures({ activityGroups: ["turn"], toolResults: ["hook-1"] }),
disclosures: testDisclosures({ activityGroups: ["turn"], details: ["hook-1:details"] }),
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
@ -331,8 +331,8 @@ describe("work log renderer decisions", () => {
expect(element).toBeDefined();
expect(element.querySelector(":scope > summary")?.textContent).toBe("Work details");
expect(element.querySelector(".codex-panel__tool-item--hook")?.classList.contains("codex-panel__execution--completed")).toBe(true);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
expect(element.querySelector(".codex-panel__detail-item--hook")?.classList.contains("codex-panel__execution--completed")).toBe(true);
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse");
expect(element.querySelector(".codex-panel__output-title")?.textContent).toBe("Hook output");
@ -368,7 +368,7 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.classList.contains("codex-panel__work-message")).toBe(true);
expect(element.classList.contains("codex-panel__status-message")).toBe(true);
expect(element.classList.contains("codex-panel__task-progress")).toBe(true);
expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("tasks");
expect(element.textContent).toContain("[x]Inspect code");
@ -588,13 +588,12 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.classList.contains("codex-panel__work-message")).toBe(true);
expect(element.classList.contains("codex-panel__detail")).toBe(true);
expect(element.classList.contains("codex-panel__agent-activity")).toBe(true);
expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("agent");
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__tool-summary"));
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__stream-summary"));
expect(summary.textContent).toBe("spawn child: Inspect the renderer. (completed)");
expect(summary.classList.contains("codex-panel__agent-activity-summary")).toBe(true);
expect([...element.querySelectorAll("details summary")].map((detailsSummary) => detailsSummary.textContent)).toEqual(["Details"]);
expect([...element.querySelectorAll("details summary")].map((detailsSummary) => detailsSummary.textContent)).toEqual(["agent"]);
expect(element.textContent).toContain("targetchild");
expect(element.textContent).toContain("PromptInspect the renderer.");
expect(element.textContent).toContain("childcompleted: Done");
@ -630,7 +629,7 @@ describe("work log renderer decisions", () => {
})[0];
const element = renderMessageBlockElement(block);
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__agent-activity-summary"));
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__stream-summary"));
expect(summary.textContent).toBe(`spawn child: Inspect the renderer. ${"a".repeat(73)}... (running)`);
});
@ -670,17 +669,20 @@ describe("work log renderer decisions", () => {
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__agent-thread")?.textContent).toBe("019e061e");
expect(element.querySelector(".codex-panel__agent-status")?.textContent).toBe("completed: Done");
expect(element.querySelector(".codex-panel__agent-status")?.textContent).not.toContain("a".repeat(180));
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]);
const agentRows = [...element.querySelectorAll(".codex-panel__meta-grid dt, .codex-panel__meta-grid dd")].map(
(node) => node.textContent,
);
expect(agentRows).toContain("019e061e");
expect(agentRows).toContain("completed: Done");
expect(agentRows.join("")).not.toContain("a".repeat(180));
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["agent"]);
expect(element.querySelector<HTMLElement>("details summary")?.tabIndex).toBe(-1);
expect(element.textContent).toContain("Agent output 019e061e");
expect(element.textContent).toContain(longMessage);
const details = element.querySelector("details");
expect(details?.hasAttribute("open")).toBe(false);
details?.dispatchEvent(new Event("toggle"));
expect(onDisclosureToggle).toHaveBeenCalledWith("agentDetails", "agent-1", false);
expect(onDisclosureToggle).toHaveBeenCalledWith("details", "agent-1:agent-details", false);
});
it("renders a compact live agent summary while subagents are running", () => {
@ -717,7 +719,7 @@ describe("work log renderer decisions", () => {
const summary = renderMessageBlockElement(expectPresent(blocks.at(-1)));
expect(summary.classList.contains("codex-panel__work-message")).toBe(true);
expect(summary.classList.contains("codex-panel__status-message")).toBe(true);
expect(summary.classList.contains("codex-panel__agent-summary")).toBe(true);
expect(summary.textContent).toContain("agents");
expect(summary.textContent).toContain("Agents 1 running, 1 done");
@ -725,7 +727,7 @@ describe("work log renderer decisions", () => {
expect(summary.textContent).not.toContain("donecompleted");
});
it("renders context compaction as a one-line work item while running and after completion", () => {
it("renders context compaction as a one-line status item while running and after completion", () => {
const runningParent = document.createElement("div");
const item: MessageStreamItem = {
id: "compact-1",
@ -753,7 +755,7 @@ describe("work log renderer decisions", () => {
runningParent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:compact-1"] .codex-panel__context-compaction'),
);
expect(running.querySelector(".codex-panel__message-role")?.textContent).toBe("context");
expect(running.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Compacting context...");
expect(running.querySelector(".codex-panel__stream-summary")?.textContent).toBe("Compacting context...");
expect(running.classList.contains("codex-panel__execution--running")).toBe(true);
unmountUiRootInAct(runningParent);
@ -776,7 +778,7 @@ describe("work log renderer decisions", () => {
const completed = expectPresent(
completedParent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:compact-1"] .codex-panel__context-compaction'),
);
expect(completed.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Context compacted");
expect(completed.querySelector(".codex-panel__stream-summary")?.textContent).toBe("Context compacted");
expect(completed.classList.contains("codex-panel__execution--completed")).toBe(true);
unmountUiRootInAct(completedParent);
});

View file

@ -16,6 +16,7 @@ import type {
} from "../../../../../src/features/chat/ui/message-stream/context";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { messageStreamViewBlocks } from "../../../../../src/features/chat/presentation/message-stream/view-model";
import type { MessageStreamTextActions } from "../../../../../src/features/chat/presentation/message-stream/text-view";
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
@ -33,6 +34,7 @@ export function messageStreamBlocks(
activeItems: context.activeItems,
workspaceRoot: normalized.workspaceRoot,
turnDiffs: context.turnDiffs,
textActionsByItemId: context.textActionsByItemId,
pendingRequests: pendingRequestBlockInput(context),
});
const blocks = rawMessageStreamBlocks(viewBlocks, normalized);
@ -66,6 +68,7 @@ type TestMessageStreamContext = Omit<MessageStreamContext, "disclosures" | "fork
stableItems?: readonly MessageStreamItem[];
activeItems?: readonly MessageStreamItem[];
turnDiffs?: ReadonlyMap<string, string>;
textActionsByItemId?: ReadonlyMap<string, MessageStreamTextActions>;
};
export function emptyDisclosures(): MessageStreamDisclosureState {
@ -76,9 +79,8 @@ export function testDisclosures(
overrides: Partial<Record<keyof MessageStreamDisclosureState, readonly string[]>> = {},
): MessageStreamDisclosureState {
return {
toolResults: new Set(overrides.toolResults),
details: new Set(overrides.details),
activityGroups: new Set(overrides.activityGroups),
agentDetails: new Set(overrides.agentDetails),
textDetails: new Set(overrides.textDetails),
userMessageExpanded: new Set(overrides.userMessageExpanded),
goalObjectiveExpanded: new Set(overrides.goalObjectiveExpanded),