Project text message metadata views

This commit is contained in:
murashit 2026-06-17 21:02:13 +09:00
parent 6445620d5e
commit d2f8755eb6
25 changed files with 1020 additions and 1068 deletions

View file

@ -198,7 +198,7 @@ const chatExternalDomBridgeFiles = [
"src/features/chat/ui/message-stream/virtualizer.ts",
];
const chatPreactDomBridgeFiles = [
"src/features/chat/ui/message-stream/text-item.tsx",
"src/features/chat/ui/message-stream/text-content.tsx",
"src/features/chat/ui/message-stream/detail.tsx",
"src/features/chat/ui/message-stream/viewport.tsx",
"src/features/chat/ui/composer-dom.ts",

View file

@ -9,7 +9,7 @@ import type {
} from "./root-reducer";
import type { Thread } from "../../../../domain/threads/model";
import type { MessageStreamItem } from "../../domain/message-stream/items";
import { latestImplementablePlanFromItems } from "../../domain/message-stream/selectors";
import { latestImplementablePlanTargetFromItems, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
import { messageStreamItems, messageStreamIsEmpty } from "./message-stream";
export interface SubmissionStateSnapshot {
@ -49,17 +49,17 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
}
export function canImplementPlanItemId(state: ChatState, itemId: string): boolean {
return itemId === implementPlanCandidateFromState(state)?.id;
return itemId === implementPlanTargetFromState(state)?.itemId;
}
export function implementPlanCandidateFromState(state: {
export function implementPlanTargetFromState(state: {
activeThread: Pick<ChatActiveThreadState, "id">;
turn: ChatTurnState;
runtime: Pick<ChatRuntimeState, "selectedCollaborationMode">;
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">;
}): MessageStreamItem | null {
}): PlanImplementationTarget | null {
if (!state.activeThread.id || chatTurnBusy(state) || state.runtime.selectedCollaborationMode !== "plan") {
return null;
}
return latestImplementablePlanFromItems(messageStreamItems(state.messageStream));
return latestImplementablePlanTargetFromItems(messageStreamItems(state.messageStream));
}

View file

@ -6,8 +6,7 @@ export interface ForkCandidate {
turnId: string;
}
interface RollbackCandidateItem {
turnId: string;
export interface PlanImplementationTarget {
itemId: string;
}
@ -20,20 +19,11 @@ export function forkCandidatesFromItems(items: readonly MessageStreamItem[]): re
return [...turnOutcomeItemsByTurn.values()];
}
export function latestImplementablePlanFromItems(items: readonly MessageStreamItem[]): MessageStreamItem | null {
return [...messageStreamSemanticClassifications(items)].reverse().find((item) => item.actions.canImplementPlan)?.item ?? null;
export function latestImplementablePlanTargetFromItems(items: readonly MessageStreamItem[]): PlanImplementationTarget | null {
const classification = [...messageStreamSemanticClassifications(items)].reverse().find((item) => item.actions.canImplementPlan);
return classification ? { itemId: classification.item.id } : null;
}
export function isCompletedTurnOutcomeMessage(item: MessageStreamItem): boolean {
return messageStreamSemanticClassifications([item])[0]?.actions.isTurnOutcome ?? false;
}
export function isForkCandidateItem(item: MessageStreamItem, candidates: readonly ForkCandidate[]): boolean {
return candidates.some((candidate) => item.id === candidate.itemId && item.turnId === candidate.turnId);
}
export function isRollbackCandidateItem(item: MessageStreamItem, candidate: RollbackCandidateItem | null): boolean {
return Boolean(
candidate && item.kind === "message" && item.role === "user" && item.id === candidate.itemId && item.turnId === candidate.turnId,
);
}

View file

@ -7,8 +7,8 @@ import {
} from "../../application/state/root-reducer";
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 } from "../../domain/message-stream/selectors";
import { implementPlanTargetFromState } from "../../application/state/selectors";
import { type ForkCandidate, forkCandidatesFromItems, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
import {
messageStreamActiveItems,
messageStreamItems,
@ -60,7 +60,6 @@ export interface MessageStreamSurfaceContextOptions {
interface MessageStreamStateProjection {
activeThreadId: string | null;
turnLifecycle: ChatPanelMessageStreamShellState["turn"]["lifecycle"];
workspaceRoot: string;
disclosures: ChatDisclosureUiState;
forkActionsItemId: string | null;
@ -106,7 +105,6 @@ function messageStreamContextFromProjection(
): MessageStreamContext {
return {
activeThreadId: projection.activeThreadId,
turnLifecycle: projection.turnLifecycle,
workspaceRoot: projection.workspaceRoot,
disclosures: projection.disclosures,
onDisclosureToggle: context.setDisclosureOpen,
@ -149,13 +147,12 @@ function messageStreamStateProjection(
const workspaceRoot = state.activeThread.cwd ?? context.vaultPath;
const rollbackCandidate = busy ? null : messageStreamRollbackCandidate(state.messageStream);
const forkCandidates = busy ? [] : forkCandidatesFromItems(items);
const implementPlanCandidate = implementPlanCandidateFromState(state);
const textActionsByItemId = textActionsForMessageStreamItems(rollbackCandidate, forkCandidates, implementPlanCandidate);
const implementPlanTarget = implementPlanTargetFromState(state);
const textActionsByItemId = textActionsForMessageStreamItems(rollbackCandidate, forkCandidates, implementPlanTarget);
const activeTurn = activeTurnId({ lifecycle: state.turn.lifecycle });
return {
activeThreadId: state.activeThread.id,
turnLifecycle: state.turn.lifecycle,
workspaceRoot,
disclosures: state.ui.disclosures,
forkActionsItemId: state.ui.messageActions.forkActionsItemId,
@ -178,19 +175,17 @@ function messageStreamStateProjection(
function textActionsForMessageStreamItems(
rollbackCandidate: MessageStreamRollbackCandidate | null,
forkCandidates: readonly ForkCandidate[],
implementPlanCandidate: MessageStreamItem | null,
implementPlanTarget: PlanImplementationTarget | 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 },
});
patchTextActions(byItemId, rollbackCandidate.itemId, { rollback: true });
}
if (implementPlanCandidate) {
patchTextActions(byItemId, implementPlanCandidate.id, { implementPlan: { itemId: implementPlanCandidate.id } });
if (implementPlanTarget) {
patchTextActions(byItemId, implementPlanTarget.itemId, { implementPlan: implementPlanTarget });
}
return byItemId;
}

View file

@ -1,77 +0,0 @@
import { truncate } from "../../../../utils";
import { collabAgentStateExecutionState } from "../../domain/message-stream/agent-state";
import type { AgentRunSummary, AgentRunSummaryAgent, AgentStateSummary, MessageStreamItem } from "../../domain/message-stream/items";
const ACTIVE_AGENT_PREVIEW_LIMIT = 96;
type AgentRunState = "running" | "completed" | "failed";
export function agentActivityMetaLabel(tool: string): string {
if (tool === "spawnAgent") return "spawn";
if (tool === "sendInput") return "send input";
if (tool === "resumeAgent") return "resume";
if (tool === "wait") return "wait";
if (tool === "closeAgent") return "close";
return tool;
}
export function activeAgentRunSummary(items: readonly MessageStreamItem[], activeTurnId: string | null): AgentRunSummary | null {
if (!activeTurnId) return null;
const agentStatuses = new Map<string, AgentStateSummary>();
for (const item of items) {
if (item.kind !== "agent" || item.turnId !== activeTurnId) continue;
if (item.agents.length > 0) {
for (const agent of item.agents) {
agentStatuses.set(agent.threadId, agent);
}
} else {
for (const threadId of item.receiverThreadIds) {
agentStatuses.set(threadId, { threadId, status: item.status, message: null });
}
}
}
if (agentStatuses.size === 0) return null;
const summary = { running: 0, completed: 0, failed: 0, agents: [] as AgentRunSummaryAgent[], additionalAgents: 0 };
const agents = [...agentStatuses.values()];
for (const agent of agents) {
const state = agentRunState(agent.status);
summary[state] += 1;
}
if (summary.running === 0 && summary.failed === 0) return null;
summary.agents = agents
.filter((agent) => agentRunState(agent.status) === "running")
.sort((a, b) => a.threadId.localeCompare(b.threadId))
.map((agent) => ({
threadId: agent.threadId,
status: agent.status,
messagePreview: agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT),
}));
return summary;
}
export function agentRunSummaryLabel(summary: AgentRunSummary): string {
const parts: string[] = [];
if (summary.failed > 0) parts.push(`${String(summary.failed)} failed`);
if (summary.running > 0) parts.push(`${String(summary.running)} running`);
if (summary.completed > 0) parts.push(`${String(summary.completed)} done`);
return `Agents ${parts.join(", ")}`;
}
export function agentMessagePreview(message: string | null, maxLength: number): string | null {
if (!message) return null;
const firstLine = message
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) return null;
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
}
function agentRunState(status: string): AgentRunState {
return collabAgentStateExecutionState(status) ?? "running";
}

View file

@ -1,355 +0,0 @@
import { shortThreadId, truncate } from "../../../../utils";
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
import type {
AgentMessageStreamItem,
ApprovalResultMessageStreamItem,
CommandMessageStreamItem,
CommandMessageStreamTarget,
FileChangeMessageStreamItem,
GoalMessageStreamItem,
HookMessageStreamItem,
MessageStreamFileChange,
MessageStreamItem,
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";
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;
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;
}
}
function commandDetailView(item: CommandMessageStreamItem): DetailView {
const rows = [
{ key: "command", value: item.command },
{ key: "cwd", value: item.cwd },
{ key: "status", value: item.status },
...(item.exitCode !== undefined ? [{ key: "exit", value: String(item.exitCode) }] : []),
...(item.durationMs !== undefined ? [{ key: "duration", value: `${String(item.durationMs)}ms` }] : []),
];
const sections: DetailSection[] = [
{
kind: "kv",
rows,
},
...outputSection("Output", item.output),
];
return detailViewBase(
item,
"codex-panel__detail-item",
commandActionLabel(item.commandAction),
`${item.id}:command-details`,
sections,
commandSummary(item),
);
}
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 sections: DetailSection[] = [
{
kind: "kv",
rows: [
{ key: "status", value: item.status },
{ key: "files", value: String(item.changes.length) },
],
},
...displayChanges.map((change) => ({
kind: "diff" as const,
title: `${change.kind} ${change.displayPath}`,
diff: change.diff,
})),
...outputSection("Patch output", item.output),
];
return detailViewBase(
item,
"codex-panel__detail-item codex-panel__detail-item--file-change",
"file change",
`${item.id}:file-change-details`,
sections,
fileChangeSummary(item, displayChanges),
);
}
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 agentDetailView(item: AgentMessageStreamItem): DetailView {
return detailViewBase(
item,
"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)],
genericToolSummary(item, workspaceRoot),
);
}
function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"auto-review",
`${item.id}:review-details`,
"codex-panel__message--review-result codex-panel__detail-item--review",
);
}
function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"approval",
`${item.id}:approval-details`,
"codex-panel__message--approval-result codex-panel__detail-item--approval",
);
}
function resultDetailView(
item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem,
label: string,
detailsKey: string,
className: string,
): DetailView {
return detailViewBase(item, `codex-panel__detail-item ${className}`, label, detailsKey, resultDetails(item));
}
function goalDetails(item: GoalMessageStreamItem): DetailSection[] {
return [
{
kind: "kv",
rows: [{ key: "action", value: item.action }],
},
...outputSection("Objective", item.objective),
];
}
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: "kv",
rows: [
{ key: "status", value: item.approval.status },
{ key: "scope", value: item.approval.scope },
{ key: "request", value: item.approval.request },
...item.approval.auditFacts,
],
},
];
}
return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "kv", rows: item.review.auditFacts }] : [];
}
function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): DetailSection[] {
if (item.kind === "hook") return hookRunDetails(item);
return [...toolCallDetails(item), ...webSearchDetails(item), ...imageGenerationDetails(item)];
}
function toolCallDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.toolCall;
if (!details) return [];
return [
...jsonOutputSection("Arguments JSON", details.arguments),
...jsonOutputSection("Result JSON", details.result),
...jsonOutputSection("Error JSON", details.error),
];
}
function webSearchDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.webSearch;
if (!details) return [];
const rows = [
...metaRow("action", details.action),
...metaRow("query", details.query),
...metaRow("pattern", details.pattern),
...metaRow("url", details.url),
];
return rows.length > 0 ? [{ kind: "kv", title: "web search", rows }] : [];
}
function imageGenerationDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.imageGeneration;
if (!details) return [];
return [
...outputSection("Saved path", details.savedPath),
...outputSection("Revised prompt", details.revisedPrompt),
...outputSection("Result", details.result),
];
}
function hookRunDetails(item: HookMessageStreamItem): DetailSection[] {
const details = item.hookRun;
if (!details) return [];
const rows = [
...metaRow("status", item.status),
{ key: "event", value: details.eventName },
...metaRow("message", details.statusMessage),
...metaRow("duration", details.durationMs),
];
const entries = details.entries.map((entry) => `${entry.kind}: ${entry.text}`).join("\n");
return [{ kind: "kv", rows }, ...outputSection("Hook output", entries)];
}
function commandActionLabel(action: CommandMessageStreamItem["commandAction"]): string {
if (action === "read") return "read";
if (action === "search") return "search";
if (action === "listFiles") return "list files";
return "command";
}
function commandSummary(item: CommandMessageStreamItem): string {
return compactSummary(null, commandTargetSummary(item.commandTarget, item.cwd), commandQualifier(item));
}
function commandTargetSummary(target: CommandMessageStreamTarget, cwd: string): string {
if (target.kind === "read") return target.path ? pathRelativeToRoot(target.path, cwd) : target.name;
if (target.kind === "search") {
const query = target.query ? quoteInline(target.query) : null;
const path = target.path ? pathRelativeToRoot(target.path, cwd) : null;
if (query && path) return `${query} in ${path}`;
if (query) return query;
if (path) return path;
return "search";
}
if (target.kind === "listFiles") return target.path ? pathRelativeToRoot(target.path, cwd) : "workspace";
return target.commandLine;
}
function commandQualifier(item: CommandMessageStreamItem): string | null {
if (typeof item.exitCode === "number" && item.exitCode !== 0) return `exit ${String(item.exitCode)}`;
return statusQualifier(item.status, failedStatusLabel(item.status));
}
function genericToolSummary(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): string {
const target = primaryTargetSummary(item.primaryTarget, workspaceRoot);
if (!target) return item.text ?? "details";
return compactSummary(toolOperationLabel(item.operation), target, statusQualifier(item.status, item.failureReason));
}
function fileChangeSummary(item: FileChangeMessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
const target = fileChangeTargetSummary(changes);
return compactSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status)));
}
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 {
if (changes.length === 0) return "no files";
if (changes.length === 1) return changes[0]?.displayPath ?? "1 file";
return `${String(changes.length)} files`;
}
function toolOperationLabel(operation: string | null | undefined): string | null {
if (!operation) return null;
if (operation === "openPage") return "open page";
if (operation === "findInPage") return "find in page";
if (operation === "search") return "search";
if (operation === "webSearch") return "web search";
if (operation === "other") return "other";
return operation;
}
function quoteInline(value: string): string {
return value.includes(" ") ? JSON.stringify(value) : value;
}

View file

@ -1,89 +0,0 @@
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

@ -1,24 +1,187 @@
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";
import { jsonPreview, 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";
export { type DetailSection, type DetailView } from "./detail-types";
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;
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 detailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView {
return codexDetailView(item, workspaceRoot) ?? genericDetailView(item, workspaceRoot);
}
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;
}
}
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,
};
}
function commandDetailView(item: CommandMessageStreamItem): DetailView {
const rows = [
{ key: "command", value: item.command },
{ key: "cwd", value: item.cwd },
{ key: "status", value: item.status },
...(item.exitCode !== undefined ? [{ key: "exit", value: String(item.exitCode) }] : []),
...(item.durationMs !== undefined ? [{ key: "duration", value: `${String(item.durationMs)}ms` }] : []),
];
const sections: DetailSection[] = [
{
kind: "kv",
rows,
},
...outputSection("Output", item.output),
];
return detailViewBase(
item,
"codex-panel__detail-item",
commandActionLabel(item.commandAction),
`${item.id}:command-details`,
sections,
commandSummary(item),
);
}
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 sections: DetailSection[] = [
{
kind: "kv",
rows: [
{ key: "status", value: item.status },
{ key: "files", value: String(item.changes.length) },
],
},
...displayChanges.map((change) => ({
kind: "diff" as const,
title: `${change.kind} ${change.displayPath}`,
diff: change.diff,
})),
...outputSection("Patch output", item.output),
];
return detailViewBase(
item,
"codex-panel__detail-item codex-panel__detail-item--file-change",
"file change",
`${item.id}:file-change-details`,
sections,
fileChangeSummary(item, displayChanges),
);
}
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 agentDetailView(item: AgentMessageStreamItem): DetailView {
return detailViewBase(
item,
"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)],
genericToolSummary(item, workspaceRoot),
);
}
function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"auto-review",
`${item.id}:review-details`,
"codex-panel__message--review-result codex-panel__detail-item--review",
);
}
function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView {
return resultDetailView(
item,
"approval",
`${item.id}:approval-details`,
"codex-panel__message--approval-result codex-panel__detail-item--approval",
);
}
function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView {
return detailViewBase(
item,
@ -30,6 +193,76 @@ function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | nul
);
}
function resultDetailView(
item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem,
label: string,
detailsKey: string,
className: string,
): DetailView {
return detailViewBase(item, `codex-panel__detail-item ${className}`, label, detailsKey, resultDetails(item));
}
function goalDetails(item: GoalMessageStreamItem): DetailSection[] {
return [
{
kind: "kv",
rows: [{ key: "action", value: item.action }],
},
...outputSection("Objective", item.objective),
];
}
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: "kv",
rows: [
{ key: "status", value: item.approval.status },
{ key: "scope", value: item.approval.scope },
{ key: "request", value: item.approval.request },
...item.approval.auditFacts,
],
},
];
}
return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "kv", rows: item.review.auditFacts }] : [];
}
function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): DetailSection[] {
if (item.kind === "hook") return hookRunDetails(item);
return [...toolCallDetails(item), ...webSearchDetails(item), ...imageGenerationDetails(item)];
}
function genericDetailSections(item: MessageStreamItem, workspaceRoot?: string | null): DetailSection[] {
const rows = [
...metaRow("kind", item.kind),
@ -41,6 +274,135 @@ function genericDetailSections(item: MessageStreamItem, workspaceRoot?: string |
return [...(rows.length > 0 ? [{ kind: "kv" as const, rows }] : []), ...outputSection("Output", outputField(item))];
}
function toolCallDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.toolCall;
if (!details) return [];
return [
...jsonOutputSection("Arguments JSON", details.arguments),
...jsonOutputSection("Result JSON", details.result),
...jsonOutputSection("Error JSON", details.error),
];
}
function webSearchDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.webSearch;
if (!details) return [];
const rows = [
...metaRow("action", details.action),
...metaRow("query", details.query),
...metaRow("pattern", details.pattern),
...metaRow("url", details.url),
];
return rows.length > 0 ? [{ kind: "kv", title: "web search", rows }] : [];
}
function imageGenerationDetails(item: ToolCallMessageStreamItem): DetailSection[] {
const details = item.imageGeneration;
if (!details) return [];
return [
...outputSection("Saved path", details.savedPath),
...outputSection("Revised prompt", details.revisedPrompt),
...outputSection("Result", details.result),
];
}
function hookRunDetails(item: HookMessageStreamItem): DetailSection[] {
const details = item.hookRun;
if (!details) return [];
const rows = [
...metaRow("status", item.status),
{ key: "event", value: details.eventName },
...metaRow("message", details.statusMessage),
...metaRow("duration", details.durationMs),
];
const entries = details.entries.map((entry) => `${entry.kind}: ${entry.text}`).join("\n");
return [{ kind: "kv", rows }, ...outputSection("Hook output", entries)];
}
function outputSection(title: string, body: string | null | undefined): DetailSection[] {
return body ? [{ kind: "output", title, body }] : [];
}
function jsonOutputSection(title: string, value: unknown): DetailSection[] {
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 }] : [];
}
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 textField(item: MessageStreamItem): string | null {
return "text" in item && typeof item.text === "string" && item.text.trim().length > 0 ? item.text : null;
}
function outputField(item: MessageStreamItem): string | null {
return "output" in item && typeof item.output === "string" && item.output.trim().length > 0 ? item.output : null;
}
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;
}
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 fallbackSummary(item: MessageStreamItem): string {
return textField(item) ?? "details";
}
function commandActionLabel(action: CommandMessageStreamItem["commandAction"]): string {
if (action === "read") return "read";
if (action === "search") return "search";
if (action === "listFiles") return "list files";
return "command";
}
function commandSummary(item: CommandMessageStreamItem): string {
return compactSummary(null, commandTargetSummary(item.commandTarget, item.cwd), commandQualifier(item));
}
function commandTargetSummary(target: CommandMessageStreamTarget, cwd: string): string {
if (target.kind === "read") return target.path ? pathRelativeToRoot(target.path, cwd) : target.name;
if (target.kind === "search") {
const query = target.query ? quoteInline(target.query) : null;
const path = target.path ? pathRelativeToRoot(target.path, cwd) : null;
if (query && path) return `${query} in ${path}`;
if (query) return query;
if (path) return path;
return "search";
}
if (target.kind === "listFiles") return target.path ? pathRelativeToRoot(target.path, cwd) : "workspace";
return target.commandLine;
}
function commandQualifier(item: CommandMessageStreamItem): string | null {
if (typeof item.exitCode === "number" && item.exitCode !== 0) return `exit ${String(item.exitCode)}`;
return statusQualifier(item.status, failedStatusLabel(item.status));
}
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);
@ -54,3 +416,74 @@ function primaryTargetField(item: MessageStreamItem): MessageStreamPrimaryTarget
if (!("primaryTarget" in item)) return undefined;
return item.primaryTarget;
}
function genericToolSummary(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): string {
const target = primaryTargetSummary(item.primaryTarget, workspaceRoot);
if (!target) return item.text ?? "details";
return compactSummary(toolOperationLabel(item.operation), target, statusQualifier(item.status, item.failureReason));
}
function fileChangeSummary(item: FileChangeMessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
const target = fileChangeTargetSummary(changes);
return compactSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status)));
}
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 agentActivityMetaLabel(tool: string): string {
if (tool === "spawnAgent") return "spawn";
if (tool === "sendInput") return "send input";
if (tool === "resumeAgent") return "resume";
if (tool === "wait") return "wait";
if (tool === "closeAgent") return "close";
return tool;
}
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 agentMessagePreview(message: string | null, maxLength: number): string | null {
if (!message) return null;
const firstLine = message
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) return null;
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
}
function isLongAgentMessage(message: string): boolean {
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
}
function fileChangeTargetSummary(changes: (MessageStreamFileChange & { displayPath: string })[]): string {
if (changes.length === 0) return "no files";
if (changes.length === 1) return changes[0]?.displayPath ?? "1 file";
return `${String(changes.length)} files`;
}
function toolOperationLabel(operation: string | null | undefined): string | null {
if (!operation) return null;
if (operation === "openPage") return "open page";
if (operation === "findInPage") return "find in page";
if (operation === "search") return "search";
if (operation === "webSearch") return "web search";
if (operation === "other") return "other";
return operation;
}
function quoteInline(value: string): string {
return value.includes(" ") ? JSON.stringify(value) : value;
}

View file

@ -1,14 +1,18 @@
import { agentRunSummaryLabel } from "./agent-summary";
import { shortThreadId } from "../../../../utils";
import { shortThreadId, truncate } from "../../../../utils";
import { collabAgentStateExecutionState } from "../../domain/message-stream/agent-state";
import type {
AgentRunSummary,
AgentRunSummaryAgent,
AgentStateSummary,
ExecutionState,
MessageStreamItem,
ReasoningMessageStreamItem,
TaskProgressMessageStreamItem,
} from "../../domain/message-stream/items";
const ACTIVE_AGENT_PREVIEW_LIMIT = 96;
type AgentRunState = "running" | "completed" | "failed";
export type StatusChecklistItem = TaskProgressMessageStreamItem["steps"][number];
export type MessageStreamStatusView =
@ -72,6 +76,46 @@ export function messageStreamStatusView(item: MessageStreamItem, context: Messag
return genericStatusView(item);
}
export function activeAgentRunSummary(items: readonly MessageStreamItem[], activeTurnId: string | null): AgentRunSummary | null {
if (!activeTurnId) return null;
const agentStatuses = new Map<string, AgentStateSummary>();
for (const item of items) {
if (item.kind !== "agent" || item.turnId !== activeTurnId) continue;
if (item.agents.length > 0) {
for (const agent of item.agents) {
agentStatuses.set(agent.threadId, agent);
}
} else {
for (const threadId of item.receiverThreadIds) {
agentStatuses.set(threadId, { threadId, status: item.status, message: null });
}
}
}
if (agentStatuses.size === 0) return null;
const summary = { running: 0, completed: 0, failed: 0, agents: [] as AgentRunSummaryAgent[], additionalAgents: 0 };
const agents = [...agentStatuses.values()];
for (const agent of agents) {
const state = agentRunState(agent.status);
summary[state] += 1;
}
if (summary.running === 0 && summary.failed === 0) return null;
summary.agents = agents
.filter((agent) => agentRunState(agent.status) === "running")
.sort((a, b) => a.threadId.localeCompare(b.threadId))
.map((agent) => ({
threadId: agent.threadId,
status: agent.status,
messagePreview: agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT),
}));
return summary;
}
export function agentRunSummaryView(summary: AgentRunSummary): AgentRunSummaryView {
return {
label: "agents",
@ -120,6 +164,14 @@ function genericStatusView(item: MessageStreamItem): MessageStreamStatusView {
};
}
function agentRunSummaryLabel(summary: AgentRunSummary): string {
const parts: string[] = [];
if (summary.failed > 0) parts.push(`${String(summary.failed)} failed`);
if (summary.running > 0) parts.push(`${String(summary.running)} running`);
if (summary.completed > 0) parts.push(`${String(summary.completed)} done`);
return `Agents ${parts.join(", ")}`;
}
function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; threadLabel: string; status: string } {
return {
threadId: agent.threadId,
@ -128,6 +180,20 @@ function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; th
};
}
function agentMessagePreview(message: string | null, maxLength: number): string | null {
if (!message) return null;
const firstLine = message
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) return null;
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
}
function agentRunState(status: string): AgentRunState {
return collabAgentStateExecutionState(status) ?? "running";
}
function isReasoningActive(item: ReasoningMessageStreamItem, context: MessageStreamStatusViewContext): boolean {
const activeTurn = context.activeTurnId;
if (!activeTurn || item.turnId !== activeTurn) return false;

View file

@ -1,4 +1,10 @@
import type { ExecutionState, MessageStreamItem } from "../../domain/message-stream/items";
import type {
ExecutionState,
MessageStreamItem,
MessageStreamNoticeSection,
MessageStreamUserInputQuestionResult,
} from "../../domain/message-stream/items";
import type { PlanImplementationTarget } from "../../domain/message-stream/selectors";
import type { MessageStreamItemAnnotations } from "./layout";
export interface MessageStreamForkTarget {
@ -6,24 +12,53 @@ export interface MessageStreamForkTarget {
turnId: string;
}
export interface MessageStreamRollbackTarget {
itemId: string;
turnId: string;
}
export interface MessageStreamPlanImplementationTarget {
itemId: string;
}
export type MessageStreamPlanImplementationTarget = PlanImplementationTarget;
export interface MessageStreamTextActions {
fork?: MessageStreamForkTarget;
rollback?: MessageStreamRollbackTarget;
rollback?: true;
implementPlan?: MessageStreamPlanImplementationTarget;
}
export interface ReferencedThreadTextView {
title: string;
includedTurns: number;
turnLimit: number;
}
export interface MentionedFileTextView {
name: string;
path: string;
}
export interface EditedFilesTextView {
files: readonly string[];
turnDiff?: {
turnId: string;
diff: string;
};
}
export interface TextItemDetailSectionView {
title?: string;
facts?: readonly { readonly key: string; readonly value: string }[];
body?: string;
}
interface MessageStreamTextMetadataView {
editedFiles?: EditedFilesTextView;
referencedThread?: ReferencedThreadTextView;
mentionedFiles?: {
itemId: string;
files: readonly MentionedFileTextView[];
};
autoReviewSummaries: readonly string[];
systemDetails: readonly TextItemDetailSectionView[];
userInputDetails: readonly TextItemDetailSectionView[];
}
export interface MessageStreamTextView {
id: string;
item: MessageStreamItem;
roleLabel: string;
body: string;
className: string;
@ -32,9 +67,7 @@ export interface MessageStreamTextView {
collapsible: boolean;
copyText?: string;
actions: MessageStreamTextActions;
annotations?: MessageStreamItemAnnotations;
editedFiles: readonly string[];
autoReviewSummaries: readonly string[];
metadata: MessageStreamTextMetadataView;
}
export function messageStreamTextView(
@ -46,7 +79,6 @@ export function messageStreamTextView(
const body = bodyForTextItem(item);
return {
id: item.id,
item,
roleLabel: roleLabelForTextItem(item),
body,
className: `${textItemClass(item)}${executionClassName(item.executionState ?? null)}`,
@ -55,9 +87,7 @@ export function messageStreamTextView(
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 ?? [],
metadata: textMetadataView(item, annotations),
};
}
@ -82,6 +112,53 @@ function copyTextForTextItem(item: MessageStreamItem, activeTurnId: string | nul
return item.copyText;
}
function textMetadataView(item: MessageStreamItem, annotations?: MessageStreamItemAnnotations): MessageStreamTextMetadataView {
return {
...definedProp("editedFiles", editedFilesView(item, annotations)),
...definedProp("referencedThread", referencedThreadView(item)),
...definedProp("mentionedFiles", mentionedFilesView(item)),
autoReviewSummaries: item.kind === "message" ? (annotations?.autoReviewSummaries ?? []) : [],
systemDetails: item.kind === "system" ? systemDetailViews(item.noticeSections ?? []) : [],
userInputDetails: item.kind === "userInputResult" ? userInputQuestionDetailViews(item.questions) : [],
};
}
function editedFilesView(item: MessageStreamItem, annotations?: MessageStreamItemAnnotations): EditedFilesTextView | undefined {
if (item.kind !== "message" || !annotations?.editedFiles || annotations.editedFiles.length === 0) return undefined;
return {
files: annotations.editedFiles,
...definedProp("turnDiff", item.turnId && annotations.turnDiff ? { turnId: item.turnId, diff: annotations.turnDiff.diff } : undefined),
};
}
function referencedThreadView(item: MessageStreamItem): ReferencedThreadTextView | undefined {
if (item.kind !== "message" || !item.referencedThread) return undefined;
return item.referencedThread;
}
function mentionedFilesView(item: MessageStreamItem): MessageStreamTextMetadataView["mentionedFiles"] | undefined {
if (item.kind !== "message" || !item.mentionedFiles || item.mentionedFiles.length === 0) return undefined;
return { itemId: item.id, files: item.mentionedFiles };
}
function systemDetailViews(sections: readonly MessageStreamNoticeSection[]): readonly TextItemDetailSectionView[] {
return sections.map((section) => ({
...(section.title !== undefined ? { title: section.title } : {}),
...(section.auditFacts !== undefined ? { facts: section.auditFacts } : {}),
...(section.body !== undefined ? { body: section.body } : {}),
}));
}
function userInputQuestionDetailViews(questions: readonly MessageStreamUserInputQuestionResult[]): readonly TextItemDetailSectionView[] {
return questions.map((question) => ({
title: `Question: ${question.header}`,
facts: [
{ key: "Prompt", value: question.question },
...(question.answer !== undefined ? [{ key: "Answer", value: question.answer }] : []),
],
}));
}
function executionClassName(state: ExecutionState): string {
return state ? ` codex-panel__execution codex-panel__execution--${state}` : "";
}

View file

@ -1,4 +1,3 @@
import { activeAgentRunSummary } from "./agent-summary";
import {
messageStreamIsCoordinationProgress,
messageStreamSemanticClassifications,
@ -9,7 +8,13 @@ import { messageStreamLayoutBlocks, type MessageStreamItemAnnotations, type Mess
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 {
activeAgentRunSummary,
agentRunSummaryView,
messageStreamStatusView,
type AgentRunSummaryView,
type MessageStreamStatusView,
} from "./status-view";
import type { PendingRequestBlockSnapshot } from "../pending-requests/snapshot";
interface PendingRequestMessageStreamBlockInput {

View file

@ -2,11 +2,7 @@ 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 {
MessageStreamForkTarget,
MessageStreamPlanImplementationTarget,
MessageStreamRollbackTarget,
} from "../../presentation/message-stream/text-view";
import type { MessageStreamForkTarget, MessageStreamPlanImplementationTarget } from "../../presentation/message-stream/text-view";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
export interface MessageStreamBlock {
@ -31,11 +27,6 @@ export interface MessageStreamDisclosureState {
approvalDetails: ReadonlySet<string>;
}
export type MessageStreamTurnLifecycleState =
| { kind: "idle" }
| { kind: "starting"; pendingTurnStart: unknown }
| { kind: "running"; turnId: string };
export interface PendingRequestBlockActions {
resolveApproval: (requestId: PendingRequestId, action: ApprovalAction) => void;
resolveUserInput: (requestId: PendingRequestId) => void;
@ -54,12 +45,11 @@ export interface TextItemContentContext extends TextItemDetailStateContext {
}
export interface TextItemActionContext extends TextItemDetailStateContext {
turnLifecycle: MessageStreamTurnLifecycleState;
forkActionsItemId: string | null;
onForkActionsToggle?: (itemId: string | null) => void;
copyText?: (text: string) => void;
onImplementPlan?: (target: MessageStreamPlanImplementationTarget) => void;
onRollback?: (target: MessageStreamRollbackTarget) => void;
onRollback?: () => void;
onFork?: (target: MessageStreamForkTarget, archiveSource: boolean) => void;
}
@ -71,7 +61,6 @@ export interface TextItemMetadataContext extends TextItemDetailStateContext {
interface MessageStreamRenderContext {
activeThreadId: string | null;
turnLifecycle: MessageStreamTurnLifecycleState;
workspaceRoot?: string | null;
loadOlderTurns: () => void;
pendingRequests?: PendingRequestBlockContext;

View file

@ -7,7 +7,7 @@ import {
type PendingUserInputViewModel,
} from "../../presentation/pending-requests/view-model";
import type { PendingRequestBlockActions } from "./context";
import { createStatusMessageClassName } from "./status-message";
import { createStatusMessageClassName } from "./status";
export function pendingRequestBlockNode(
approvals: readonly PendingApprovalViewModel[],

View file

@ -1,11 +0,0 @@
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

@ -2,20 +2,31 @@ 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} />;
return <AgentRunSummary 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} />;
export function statusNode(view: MessageStreamStatusView): UiNode {
if (view.kind === "taskProgress") return <TaskProgress view={view} />;
if (view.kind === "contextCompaction") return <ContextCompaction view={view} />;
if (view.kind === "reasoning") return <Reasoning view={view} />;
return <GenericStatus view={view} />;
}
function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode {
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(" ");
}
function AgentRunSummary({ view }: { view: AgentRunSummaryView }): UiNode {
return (
<StatusMessage label={view.label} className={view.className} state={view.state}>
<div className="codex-panel__stream-summary">{view.summary}</div>
@ -24,7 +35,7 @@ function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode {
);
}
function TaskProgressItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "taskProgress" }> }): UiNode {
function TaskProgress({ 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}
@ -50,7 +61,7 @@ function taskStatusMarker(status: StatusChecklistItem["status"]): string {
return "[ ]";
}
function ContextCompactionItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "contextCompaction" }> }): UiNode {
function ContextCompaction({ 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>
@ -58,7 +69,7 @@ function ContextCompactionItem({ view }: { view: Extract<MessageStreamStatusView
);
}
function GenericStatusItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "generic" }> }): UiNode {
function GenericStatus({ 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>
@ -66,7 +77,7 @@ function GenericStatusItem({ view }: { view: Extract<MessageStreamStatusView, {
);
}
function ReasoningItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "reasoning" }> }): UiNode {
function Reasoning({ 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>

View file

@ -7,14 +7,14 @@ import {
} from "../../presentation/message-stream/view-model";
import { pendingRequestBlockNode } from "./pending-request-block";
import { detailNode } from "./detail";
import { agentRunSummaryNode, statusItemNode } from "./status-items";
import { agentRunSummaryNode, statusNode } from "./status";
import type { MessageStreamBlock, MessageStreamContext, PendingRequestBlockContext } from "./context";
import { textItemNode } from "./text-item";
import { textNode } from "./text";
function streamItemNode(item: MessageStreamRenderedItemView, context: MessageStreamContext): UiNode {
if (item.kind === "text") return textItemNode(item.view, context);
if (item.kind === "text") return textNode(item.view, context);
if (item.kind === "detail") return detailNode(item.view, context);
return statusItemNode(item.view);
return statusNode(item.view);
}
export function messageStreamBlocks(viewBlocks: readonly MessageStreamViewBlock[], context: MessageStreamContext): MessageStreamBlock[] {

View file

@ -3,59 +3,15 @@ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import type { MessageStreamTextView } from "../../presentation/message-stream/text-view";
import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events";
import type { TextItemContentContext, TextItemContext } from "./context";
import { TextItemHeader } from "./text-item-actions";
import {
AutoReviewSummaries,
EditedFiles,
MentionedFiles,
TextItemDetails,
ReferencedThread,
SystemDetails,
userInputQuestionDetails,
} from "./text-item-metadata";
import type { TextItemContentContext } from "./context";
const USER_MESSAGE_COLLAPSE_HEIGHT_PX = 360;
export function textItemNode(view: MessageStreamTextView, context: TextItemContext): UiNode {
return <TextItem view={view} context={context} />;
}
function TextItem({ view, context }: { view: MessageStreamTextView; context: TextItemContext }): UiNode {
const { item } = view;
return (
<div className={view.className}>
<TextItemHeader view={view} context={context} />
{view.collapsible ? (
<CollapsibleTextItemContent view={view} context={context} />
) : (
<TextContent key={view.contentKey} view={view} context={context} />
)}
{item.kind === "message" && view.editedFiles.length > 0 ? (
<EditedFiles item={item} context={context} {...definedProp("annotations", view.annotations)} />
) : null}
{item.kind === "message" && item.referencedThread ? <ReferencedThread item={item} /> : null}
{item.kind === "message" && item.mentionedFiles && item.mentionedFiles.length > 0 ? (
<MentionedFiles item={item} context={context} />
) : null}
{item.kind === "message" && view.autoReviewSummaries.length > 0 ? (
<AutoReviewSummaries summaries={[...view.autoReviewSummaries]} />
) : null}
{item.kind === "system" && item.noticeSections && item.noticeSections.length > 0 ? (
<SystemDetails details={item.noticeSections} />
) : item.kind === "userInputResult" && item.questions.length > 0 ? (
<TextItemDetails itemId={item.id} details={userInputQuestionDetails(item.questions)} context={context} />
) : null}
</div>
);
}
function CollapsibleTextItemContent({ view, context }: { view: MessageStreamTextView; context: TextItemContentContext }): UiNode {
const { item } = view;
export function CollapsibleTextContent({ view, context }: { view: MessageStreamTextView; context: TextItemContentContext }): UiNode {
const collapseRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [overflows, setOverflows] = useState(false);
const expanded = context.disclosures.userMessageExpanded.has(item.id);
const expanded = context.disclosures.userMessageExpanded.has(view.id);
useLayoutEffect(() => {
const content = contentRef.current;
@ -69,7 +25,7 @@ function CollapsibleTextItemContent({ view, context }: { view: MessageStreamText
return () => {
content.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update);
};
}, [item.id, view.body, view.contentMode]);
}, [view.id, view.body, view.contentMode]);
useEffect(() => {
if (!overflows || !expanded) return;
@ -77,13 +33,13 @@ function CollapsibleTextItemContent({ view, context }: { view: MessageStreamText
if (!doc) return;
const collapseOnOutsidePointer = (event: PointerEvent) => {
if (event.target instanceof Node && collapseRef.current?.contains(event.target)) return;
context.onDisclosureToggle?.("userMessageExpanded", item.id, false);
context.onDisclosureToggle?.("userMessageExpanded", view.id, false);
};
doc.addEventListener("pointerdown", collapseOnOutsidePointer, true);
return () => {
doc.removeEventListener("pointerdown", collapseOnOutsidePointer, true);
};
}, [context, expanded, item.id, overflows]);
}, [context, expanded, view.id, overflows]);
return (
<div
@ -103,7 +59,7 @@ function CollapsibleTextItemContent({ view, context }: { view: MessageStreamText
onToggle={(event) => {
if (!event.currentTarget.open) return;
event.currentTarget.open = false;
context.onDisclosureToggle?.("userMessageExpanded", item.id, true);
context.onDisclosureToggle?.("userMessageExpanded", view.id, true);
}}
>
<summary tabIndex={-1}>Show more</summary>
@ -119,7 +75,7 @@ interface TextContentProps {
collapsed?: boolean;
}
function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode {
export function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode {
const rendersMarkdown = view.contentMode === "markdown";
const text = view.body;
const localRef = useRef<HTMLDivElement | null>(null);
@ -163,7 +119,3 @@ function userMessageCollapseHeight(element: HTMLElement): number {
if (viewportHeight <= 0) return USER_MESSAGE_COLLAPSE_HEIGHT_PX;
return Math.min(USER_MESSAGE_COLLAPSE_HEIGHT_PX, viewportHeight * 0.45);
}
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,109 +0,0 @@
import { type ComponentChild as UiNode } from "preact";
import { useEffect, useRef } from "preact/hooks";
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 } from "./context";
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;
const doc = roleRef.current?.ownerDocument;
if (!doc) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (event.target instanceof Node && roleRef.current?.contains(event.target)) return;
context.onForkActionsToggle?.(null);
};
return listenDomEvent(doc, "pointerdown", closeOnOutsidePointer, true);
}, [context, forkActionsOpen]);
const copyAction =
view.copyText !== undefined && context.copyText && !forkActionsOpen ? (
<TextItemAction
icon="copy"
label="Copy message"
className="codex-panel__copy-message"
onClick={() => context.copyText?.(view.copyText ?? "")}
/>
) : null;
return (
<div ref={roleRef} className={`codex-panel__message-role${forkActionsOpen ? " codex-panel__message-role--fork-open" : ""}`}>
<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.onFork?.(fork, true);
}}
/>
) : (
copyAction
)}
{fork ? (
<TextItemAction
icon={forkActionsOpen ? "file-plus-corner" : "lucide-split"}
label={forkActionsOpen ? "Fork" : "Fork from here"}
className="codex-panel__fork-message"
onClick={() => {
if (forkActionsOpen) {
context.onForkActionsToggle?.(null);
context.onFork?.(fork, false);
} else {
context.onForkActionsToggle?.(view.id);
}
}}
/>
) : null}
{implementPlan ? (
<TextItemAction
icon="play"
label="Implement plan"
className="codex-panel__implement-plan"
onClick={() => context.onImplementPlan?.(implementPlan)}
/>
) : null}
{rollback ? (
<TextItemAction
icon="undo-2"
label="Rollback last turn"
className="codex-panel__rollback-turn"
onClick={() => context.onRollback?.(rollback)}
/>
) : null}
</div>
);
}
function TextItemAction({
icon,
label,
className,
onClick,
}: {
icon: string;
label: string;
className: string;
onClick: () => void;
}): UiNode {
return (
<IconButton
icon={icon}
label={label}
className={`clickable-icon codex-panel__message-action ${className}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClick();
}}
/>
);
}

View file

@ -1,235 +0,0 @@
import { Fragment, type ComponentChild as UiNode } from "preact";
import type { MessageStreamItemAnnotations } from "../../presentation/message-stream/layout";
import type {
MessageStreamItem,
MessageStreamNoticeSection,
MessageStreamUserInputQuestionResult,
} from "../../domain/message-stream/items";
import { IconButton } from "../../../../shared/ui/components";
import type { TextItemDetailStateContext, TextItemMetadataContext } from "./context";
export function ReferencedThread({ item }: { item: Extract<MessageStreamItem, { kind: "message" }> }): UiNode {
const reference = item.referencedThread;
if (!reference) return null;
return (
<div className="codex-panel__referenced-thread">
<span className="codex-panel__referenced-thread-label">
<span>Referenced </span>
<span>{reference.title}</span>
<span className="codex-panel__edited-files-separator">·</span>
<span>
{reference.includedTurns}/{reference.turnLimit} turns
</span>
</span>
</div>
);
}
export function EditedFiles({
item,
annotations,
context,
}: {
item: Extract<MessageStreamItem, { kind: "message" }>;
annotations?: MessageStreamItemAnnotations;
context: TextItemMetadataContext;
}): UiNode {
const editedFiles = annotations?.editedFiles ?? [];
const turnDiff = annotations?.turnDiff;
const label = editedFiles.length === 1 ? "Edited 1 file" : `Edited ${String(editedFiles.length)} files`;
return (
<div className="codex-panel__edited-files">
<details className="codex-panel__edited-files-details">
<summary tabIndex={-1}>
<span className="codex-panel__edited-files-summary">
<span>{label}</span>
{turnDiff && item.turnId && context.activeThreadId && context.openTurnDiff ? (
<>
<span className="codex-panel__edited-files-separator">·</span>
<IconButton
icon="file-diff"
label="View diff"
className="clickable-icon codex-panel__open-turn-diff"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
context.openTurnDiff?.({
threadId: context.activeThreadId ?? "",
turnId: item.turnId ?? "",
cwd: context.workspaceRoot ?? null,
files: editedFiles,
diff: turnDiff.diff,
});
}}
>
<span className="codex-panel__open-turn-diff-label">View diff</span>
</IconButton>
</>
) : null}
</span>
</summary>
<ul>
{editedFiles.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</details>
</div>
);
}
export function MentionedFiles({
item,
context,
}: {
item: Extract<MessageStreamItem, { kind: "message" }>;
context: TextItemDetailStateContext;
}): UiNode {
const mentionedFiles = item.mentionedFiles ?? [];
const label = mentionedFiles.length === 1 ? "Mentioned 1 file" : `Mentioned ${String(mentionedFiles.length)} files`;
return (
<RememberedDetails
wrapperClassName="codex-panel__mentioned-files"
detailsClassName="codex-panel__mentioned-files-details"
detailsKey={`${item.id}:mentioned-files`}
summary={label}
context={context}
>
<ul>
{mentionedFiles.map((file) => (
<li key={`${file.name}\n${file.path}`}>
<span>{file.name}</span>
<span className="codex-panel__edited-files-separator"> · </span>
<span>{file.path}</span>
</li>
))}
</ul>
</RememberedDetails>
);
}
export function AutoReviewSummaries({ summaries }: { summaries: string[] }): UiNode {
const label = summaries.length === 1 ? "Auto-reviewed 1 request" : `Auto-reviewed ${String(summaries.length)} requests`;
return (
<details className="codex-panel__auto-reviews">
<summary tabIndex={-1}>{label}</summary>
<ul>
{summaries.map((summary, index) => (
<li key={`${String(index)}:${summary}`}>{summary}</li>
))}
</ul>
</details>
);
}
export function TextItemDetails({
itemId,
details,
context,
}: {
itemId: string;
details: TextItemDetailSection[];
context: TextItemDetailStateContext;
}): UiNode {
return (
<>
{details.map((section, index) => (
<RememberedDetails
key={`${section.title ?? "Details"}:${String(index)}`}
detailsClassName="codex-panel__output"
detailsKey={`${itemId}:text-item-detail:${String(index)}`}
summary={section.title ?? "Details"}
context={context}
>
<DetailSectionBody section={section} />
</RememberedDetails>
))}
</>
);
}
export function SystemDetails({ details }: { details: readonly MessageStreamNoticeSection[] }): UiNode {
return (
<>
{details.map((section, index) => (
<div key={`${section.title ?? ""}:${String(index)}`} className="codex-panel__output codex-panel__system-result-section">
{section.title ? <div className="codex-panel__output-title">{section.title}</div> : null}
<DetailSectionBody section={noticeDetailSection(section)} />
</div>
))}
</>
);
}
export function userInputQuestionDetails(questions: readonly MessageStreamUserInputQuestionResult[]): TextItemDetailSection[] {
return questions.map((question) => ({
title: `Question: ${question.header}`,
facts: [
{ key: "Prompt", value: question.question },
...(question.answer !== undefined ? [{ key: "Answer", value: question.answer }] : []),
],
}));
}
interface TextItemDetailSection {
title?: string;
facts?: readonly { readonly key: string; readonly value: string }[];
body?: string;
}
function noticeDetailSection(section: MessageStreamNoticeSection): TextItemDetailSection {
return {
...(section.title !== undefined ? { title: section.title } : {}),
...(section.auditFacts !== undefined ? { facts: section.auditFacts } : {}),
...(section.body !== undefined ? { body: section.body } : {}),
};
}
function DetailSectionBody({ section }: { section: TextItemDetailSection }): UiNode {
return (
<>
{section.facts && section.facts.length > 0 ? (
<dl className="codex-panel__meta-grid">
{section.facts.map((row) => (
<Fragment key={`${row.key}\n${row.value}`}>
<dt>{row.key}</dt>
<dd>{row.value}</dd>
</Fragment>
))}
</dl>
) : null}
{section.body ? <pre>{section.body}</pre> : null}
</>
);
}
function RememberedDetails({
wrapperClassName,
detailsClassName,
detailsKey,
summary,
context,
children,
}: {
wrapperClassName?: string;
detailsClassName: string;
detailsKey: string;
summary: string;
context: TextItemDetailStateContext;
children: UiNode;
}): UiNode {
const details = (
<details
className={detailsClassName}
open={context.disclosures.textDetails.has(detailsKey)}
onToggle={(event) => {
context.onDisclosureToggle?.("textDetails", detailsKey, event.currentTarget.open);
}}
>
<summary tabIndex={-1}>{summary}</summary>
{children}
</details>
);
return wrapperClassName ? <div className={wrapperClassName}>{details}</div> : details;
}

View file

@ -0,0 +1,325 @@
import { Fragment, type ComponentChild as UiNode } from "preact";
import { useEffect, useRef } from "preact/hooks";
import type {
EditedFilesTextView,
MentionedFileTextView,
MessageStreamTextView,
ReferencedThreadTextView,
TextItemDetailSectionView,
} from "../../presentation/message-stream/text-view";
import { IconButton } from "../../../../shared/ui/components";
import { listenDomEvent } from "../../../../shared/ui/dom-events";
import type { TextItemActionContext, TextItemContext, TextItemDetailStateContext, TextItemMetadataContext } from "./context";
import { CollapsibleTextContent, TextContent } from "./text-content";
export function textNode(view: MessageStreamTextView, context: TextItemContext): UiNode {
return <Text view={view} context={context} />;
}
function Text({ view, context }: { view: MessageStreamTextView; context: TextItemContext }): UiNode {
return (
<div className={view.className}>
<TextHeader view={view} context={context} />
{view.collapsible ? (
<CollapsibleTextContent view={view} context={context} />
) : (
<TextContent key={view.contentKey} view={view} context={context} />
)}
{view.metadata.editedFiles ? <EditedFiles view={view.metadata.editedFiles} context={context} /> : null}
{view.metadata.referencedThread ? <ReferencedThread reference={view.metadata.referencedThread} /> : null}
{view.metadata.mentionedFiles ? (
<MentionedFiles itemId={view.metadata.mentionedFiles.itemId} files={view.metadata.mentionedFiles.files} context={context} />
) : null}
{view.metadata.autoReviewSummaries.length > 0 ? <AutoReviewSummaries summaries={[...view.metadata.autoReviewSummaries]} /> : null}
{view.metadata.systemDetails.length > 0 ? <SystemDetails details={view.metadata.systemDetails} /> : null}
{view.metadata.userInputDetails.length > 0 ? (
<TextDetails itemId={view.id} details={view.metadata.userInputDetails} context={context} />
) : null}
</div>
);
}
function TextHeader({ 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;
const doc = roleRef.current?.ownerDocument;
if (!doc) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (event.target instanceof Node && roleRef.current?.contains(event.target)) return;
context.onForkActionsToggle?.(null);
};
return listenDomEvent(doc, "pointerdown", closeOnOutsidePointer, true);
}, [context, forkActionsOpen]);
const copyAction =
view.copyText !== undefined && context.copyText && !forkActionsOpen ? (
<TextAction
icon="copy"
label="Copy message"
className="codex-panel__copy-message"
onClick={() => context.copyText?.(view.copyText ?? "")}
/>
) : null;
return (
<div ref={roleRef} className={`codex-panel__message-role${forkActionsOpen ? " codex-panel__message-role--fork-open" : ""}`}>
<span>{view.roleLabel}</span>
{forkActionsOpen && fork ? (
<TextAction
icon="archive"
label="Fork and archive"
className="codex-panel__fork-and-archive-message"
onClick={() => {
context.onForkActionsToggle?.(null);
context.onFork?.(fork, true);
}}
/>
) : (
copyAction
)}
{fork ? (
<TextAction
icon={forkActionsOpen ? "file-plus-corner" : "lucide-split"}
label={forkActionsOpen ? "Fork" : "Fork from here"}
className="codex-panel__fork-message"
onClick={() => {
if (forkActionsOpen) {
context.onForkActionsToggle?.(null);
context.onFork?.(fork, false);
} else {
context.onForkActionsToggle?.(view.id);
}
}}
/>
) : null}
{implementPlan ? (
<TextAction
icon="play"
label="Implement plan"
className="codex-panel__implement-plan"
onClick={() => context.onImplementPlan?.(implementPlan)}
/>
) : null}
{rollback ? (
<TextAction
icon="undo-2"
label="Rollback last turn"
className="codex-panel__rollback-turn"
onClick={() => context.onRollback?.()}
/>
) : null}
</div>
);
}
function TextAction({ icon, label, className, onClick }: { icon: string; label: string; className: string; onClick: () => void }): UiNode {
return (
<IconButton
icon={icon}
label={label}
className={`clickable-icon codex-panel__message-action ${className}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClick();
}}
/>
);
}
function ReferencedThread({ reference }: { reference: ReferencedThreadTextView }): UiNode {
return (
<div className="codex-panel__referenced-thread">
<span className="codex-panel__referenced-thread-label">
<span>Referenced </span>
<span>{reference.title}</span>
<span className="codex-panel__edited-files-separator">·</span>
<span>
{reference.includedTurns}/{reference.turnLimit} turns
</span>
</span>
</div>
);
}
function EditedFiles({ view, context }: { view: EditedFilesTextView; context: TextItemMetadataContext }): UiNode {
const editedFiles = view.files;
const turnDiff = view.turnDiff;
const label = editedFiles.length === 1 ? "Edited 1 file" : `Edited ${String(editedFiles.length)} files`;
return (
<div className="codex-panel__edited-files">
<details className="codex-panel__edited-files-details">
<summary tabIndex={-1}>
<span className="codex-panel__edited-files-summary">
<span>{label}</span>
{turnDiff && context.activeThreadId && context.openTurnDiff ? (
<>
<span className="codex-panel__edited-files-separator">·</span>
<IconButton
icon="file-diff"
label="View diff"
className="clickable-icon codex-panel__open-turn-diff"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
context.openTurnDiff?.({
threadId: context.activeThreadId ?? "",
turnId: turnDiff.turnId,
cwd: context.workspaceRoot ?? null,
files: [...editedFiles],
diff: turnDiff.diff,
});
}}
>
<span className="codex-panel__open-turn-diff-label">View diff</span>
</IconButton>
</>
) : null}
</span>
</summary>
<ul>
{editedFiles.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</details>
</div>
);
}
function MentionedFiles({
itemId,
files,
context,
}: {
itemId: string;
files: readonly MentionedFileTextView[];
context: TextItemDetailStateContext;
}): UiNode {
const label = files.length === 1 ? "Mentioned 1 file" : `Mentioned ${String(files.length)} files`;
return (
<RememberedDetails
wrapperClassName="codex-panel__mentioned-files"
detailsClassName="codex-panel__mentioned-files-details"
detailsKey={`${itemId}:mentioned-files`}
summary={label}
context={context}
>
<ul>
{files.map((file) => (
<li key={`${file.name}\n${file.path}`}>
<span>{file.name}</span>
<span className="codex-panel__edited-files-separator"> · </span>
<span>{file.path}</span>
</li>
))}
</ul>
</RememberedDetails>
);
}
function AutoReviewSummaries({ summaries }: { summaries: string[] }): UiNode {
const label = summaries.length === 1 ? "Auto-reviewed 1 request" : `Auto-reviewed ${String(summaries.length)} requests`;
return (
<details className="codex-panel__auto-reviews">
<summary tabIndex={-1}>{label}</summary>
<ul>
{summaries.map((summary, index) => (
<li key={`${String(index)}:${summary}`}>{summary}</li>
))}
</ul>
</details>
);
}
function TextDetails({
itemId,
details,
context,
}: {
itemId: string;
details: readonly TextItemDetailSectionView[];
context: TextItemDetailStateContext;
}): UiNode {
return (
<>
{details.map((section, index) => (
<RememberedDetails
key={`${section.title ?? "Details"}:${String(index)}`}
detailsClassName="codex-panel__output"
detailsKey={`${itemId}:text-item-detail:${String(index)}`}
summary={section.title ?? "Details"}
context={context}
>
<DetailSectionBody section={section} />
</RememberedDetails>
))}
</>
);
}
function SystemDetails({ details }: { details: readonly TextItemDetailSectionView[] }): UiNode {
return (
<>
{details.map((section, index) => (
<div key={`${section.title ?? ""}:${String(index)}`} className="codex-panel__output codex-panel__system-result-section">
{section.title ? <div className="codex-panel__output-title">{section.title}</div> : null}
<DetailSectionBody section={section} />
</div>
))}
</>
);
}
function DetailSectionBody({ section }: { section: TextItemDetailSectionView }): UiNode {
return (
<>
{section.facts && section.facts.length > 0 ? (
<dl className="codex-panel__meta-grid">
{section.facts.map((row) => (
<Fragment key={`${row.key}\n${row.value}`}>
<dt>{row.key}</dt>
<dd>{row.value}</dd>
</Fragment>
))}
</dl>
) : null}
{section.body ? <pre>{section.body}</pre> : null}
</>
);
}
function RememberedDetails({
wrapperClassName,
detailsClassName,
detailsKey,
summary,
context,
children,
}: {
wrapperClassName?: string;
detailsClassName: string;
detailsKey: string;
summary: string;
context: TextItemDetailStateContext;
children: UiNode;
}): UiNode {
const details = (
<details
className={detailsClassName}
open={context.disclosures.textDetails.has(detailsKey)}
onToggle={(event) => {
context.onDisclosureToggle?.("textDetails", detailsKey, event.currentTarget.open);
}}
>
<summary tabIndex={-1}>{summary}</summary>
{children}
</details>
);
return wrapperClassName ? <div className={wrapperClassName}>{details}</div> : details;
}

View file

@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/connection/client";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { implementPlanCandidateFromState } from "../../../../../src/features/chat/application/state/selectors";
import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/state/selectors";
import { implementPlan, type PlanImplementationHost } from "../../../../../src/features/chat/application/conversation/composition";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
@ -71,11 +71,11 @@ describe("implementPlan", () => {
const latest = planItem("latest");
resumeThread(stateStore, [first, latest]);
expect(implementPlanCandidateFromState(stateStore.getState())).toBe(latest);
expect(implementPlanTargetFromState(stateStore.getState())).toEqual({ itemId: latest.id });
stateStore.dispatch({ type: "composer/draft-set", draft: "edit first" });
expect(implementPlanCandidateFromState(stateStore.getState())).toBe(latest);
expect(implementPlanTargetFromState(stateStore.getState())).toEqual({ itemId: latest.id });
});
it("ignores streaming proposed plans until they are implementable turn outcomes", () => {
@ -84,7 +84,7 @@ describe("implementPlan", () => {
const streaming = streamingPlanItem("streaming");
resumeThread(stateStore, [completed, streaming]);
expect(implementPlanCandidateFromState(stateStore.getState())).toBe(completed);
expect(implementPlanTargetFromState(stateStore.getState())).toEqual({ itemId: completed.id });
});
it("switches out of plan mode and submits the implementation prompt", async () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { collabAgentStateExecutionState } from "../../../../src/features/chat/domain/message-stream/agent-state";
import { activeAgentRunSummary } from "../../../../src/features/chat/presentation/message-stream/agent-summary";
import { activeAgentRunSummary } from "../../../../src/features/chat/presentation/message-stream/status-view";
import { messageStreamLayoutBlocks } from "../../../../src/features/chat/presentation/message-stream/layout";
import { upsertMessageStreamItemById } from "../../../../src/features/chat/domain/message-stream/updates";
import { taskProgressMessageStreamItem } from "../../../../src/features/chat/domain/message-stream/factories/task-progress";

View file

@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
forkCandidatesFromItems,
isForkCandidateItem,
isRollbackCandidateItem,
} from "../../../../src/features/chat/domain/message-stream/selectors";
import { forkCandidatesFromItems } from "../../../../src/features/chat/domain/message-stream/selectors";
import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items";
describe("message stream item selectors", () => {
@ -18,17 +14,6 @@ describe("message stream item selectors", () => {
{ itemId: "a2", turnId: "turn-2" },
{ itemId: "a3", turnId: "turn-3" },
]);
expect(isForkCandidateItem(expectPresent(streamItems[4]), candidates)).toBe(true);
expect(isForkCandidateItem(expectPresent(streamItems[3]), candidates)).toBe(false);
});
it("matches rollback candidate stream items", () => {
const streamItems = messageStreamItems();
const candidate = { turnId: "turn-3", itemId: "u3" };
expect(isRollbackCandidateItem(expectPresent(streamItems[5]), candidate)).toBe(true);
expect(isRollbackCandidateItem(expectPresent(streamItems[0]), candidate)).toBe(false);
expect(isRollbackCandidateItem({ ...expectPresent(streamItems[5]), turnId: "turn-other" }, candidate)).toBe(false);
});
});
@ -75,8 +60,3 @@ function messageStreamItems(): MessageStreamItem[] {
},
];
}
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}

View file

@ -5,7 +5,7 @@ import { act } from "preact/test-utils";
import { MarkdownRenderer } from "obsidian";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { implementPlanCandidateFromState } from "../../../../../src/features/chat/application/state/selectors";
import { implementPlanTargetFromState } from "../../../../../src/features/chat/application/state/selectors";
import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer";
import { deferred } from "../../../../support/async";
import { topLevelDetailsSummaries } from "../../../../support/dom";
@ -258,7 +258,7 @@ describe("message stream rendering and message actions", () => {
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
textActionsByItemId: new Map([["u2", { rollback: { itemId: "u2", turnId: "turn-2" } }]]),
textActionsByItemId: new Map([["u2", { rollback: true }]]),
onRollback,
});
@ -269,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(onRollback).toHaveBeenCalledWith({ itemId: "u2", turnId: "turn-2" });
expect(onRollback).toHaveBeenCalledWith();
});
it("renders copy actions for copyable messages", () => {
@ -834,9 +834,9 @@ describe("message stream rendering and message actions", () => {
},
};
expect(implementPlanCandidateFromState(baseState)).toBe(secondPlan);
expect(implementPlanCandidateFromState({ ...baseState, runtime: { selectedCollaborationMode: "default" } })).toBeNull();
expect(implementPlanCandidateFromState({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull();
expect(implementPlanTargetFromState(baseState)).toEqual({ itemId: secondPlan.id });
expect(implementPlanTargetFromState({ ...baseState, runtime: { selectedCollaborationMode: "default" } })).toBeNull();
expect(implementPlanTargetFromState({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull();
});
it("does not render copy actions for tool items", () => {
@ -879,7 +879,7 @@ describe("message stream rendering and message actions", () => {
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
copyText,
textActionsByItemId: new Map([["u1", { rollback: { itemId: "u1", turnId: "turn-1" } }]]),
textActionsByItemId: new Map([["u1", { rollback: true }]]),
onRollback,
})[0];
@ -888,7 +888,7 @@ describe("message stream rendering and message actions", () => {
element.querySelector<HTMLButtonElement>(".codex-panel__rollback-turn")?.click();
expect(copyText).toHaveBeenCalledWith("latest");
expect(onRollback).toHaveBeenCalledWith({ itemId: "u1", turnId: "turn-1" });
expect(onRollback).toHaveBeenCalledWith();
});
it("collapses tall user messages without changing the copy payload", () => {

View file

@ -10,7 +10,6 @@ import type {
MessageStreamBlock,
MessageStreamContext,
MessageStreamDisclosureState,
MessageStreamTurnLifecycleState,
PendingRequestBlockActions,
PendingRequestBlockContext,
} from "../../../../../src/features/chat/ui/message-stream/context";
@ -26,7 +25,7 @@ export function messageStreamBlocks(
const normalized = normalizeMessageStreamContext(context);
const viewBlocks = messageStreamViewBlocks({
activeThreadId: normalized.activeThreadId,
activeTurnId: activeTurnIdForMessageStream(normalized.turnLifecycle),
activeTurnId: activeTurnIdForMessageStream(context.turnLifecycle),
historyCursor: context.historyCursor,
loadingHistory: context.loadingHistory,
items: context.items,
@ -62,6 +61,7 @@ function messageStreamBlockItemsEmpty(context: TestMessageStreamContext): boolea
type TestMessageStreamContext = Omit<MessageStreamContext, "disclosures" | "forkActionsItemId"> &
Partial<Pick<MessageStreamContext, "disclosures" | "forkActionsItemId">> & {
turnLifecycle: MessageStreamTurnLifecycleState;
historyCursor: string | null;
loadingHistory: boolean;
items: readonly MessageStreamItem[];
@ -71,6 +71,11 @@ type TestMessageStreamContext = Omit<MessageStreamContext, "disclosures" | "fork
textActionsByItemId?: ReadonlyMap<string, MessageStreamTextActions>;
};
type MessageStreamTurnLifecycleState =
| { kind: "idle" }
| { kind: "starting"; pendingTurnStart: unknown }
| { kind: "running"; turnId: string };
export function emptyDisclosures(): MessageStreamDisclosureState {
return testDisclosures();
}