mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Project text message metadata views
This commit is contained in:
parent
6445620d5e
commit
d2f8755eb6
25 changed files with 1020 additions and 1068 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}` : "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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>>);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
325
src/features/chat/ui/message-stream/text.tsx
Normal file
325
src/features/chat/ui/message-stream/text.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue