mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Normalize message stream render families and actions
This commit is contained in:
parent
a10f18f0ed
commit
6445620d5e
44 changed files with 960 additions and 900 deletions
|
|
@ -199,7 +199,7 @@ const chatExternalDomBridgeFiles = [
|
|||
];
|
||||
const chatPreactDomBridgeFiles = [
|
||||
"src/features/chat/ui/message-stream/text-item.tsx",
|
||||
"src/features/chat/ui/message-stream/tool-result.tsx",
|
||||
"src/features/chat/ui/message-stream/detail.tsx",
|
||||
"src/features/chat/ui/message-stream/viewport.tsx",
|
||||
"src/features/chat/ui/composer-dom.ts",
|
||||
"src/features/chat/panel/shell.tsx",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import type { MessageStreamItem, MessageStreamNoticeSection } from "../../domain/message-stream/items";
|
||||
import type { MessageStreamNoticeSection } from "../../domain/message-stream/items";
|
||||
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
|
||||
import { activeThreadId, canImplementPlan } from "../state/selectors";
|
||||
import { activeThreadId, canImplementPlanItemId } from "../state/selectors";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { ThreadManagementActions } from "../threads/thread-management-actions";
|
||||
import type { GoalActions } from "../threads/goal-actions";
|
||||
|
|
@ -69,8 +69,7 @@ export interface PlanImplementationHost {
|
|||
}
|
||||
|
||||
interface PlanImplementation {
|
||||
canImplement: (item: MessageStreamItem) => boolean;
|
||||
implement: (item: MessageStreamItem) => Promise<void>;
|
||||
implement: (itemId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ConversationTurnActions {
|
||||
|
|
@ -151,8 +150,7 @@ export function createConversationTurnActions(
|
|||
|
||||
return {
|
||||
planImplementation: {
|
||||
canImplement: (item) => canImplementPlan(stateStore.getState(), item),
|
||||
implement: (item) => implementPlan(planImplementationHost, item),
|
||||
implement: (itemId) => implementPlan(planImplementationHost, itemId),
|
||||
},
|
||||
composerSubmit: {
|
||||
submit: () => submitComposer(composerSubmitHost),
|
||||
|
|
@ -165,8 +163,8 @@ async function startThreadForGoal(actions: ConversationThreadStarter, objective:
|
|||
return response?.threadId ?? null;
|
||||
}
|
||||
|
||||
export async function implementPlan(host: PlanImplementationHost, item: MessageStreamItem): Promise<void> {
|
||||
if (!canImplementPlan(host.stateStore.getState(), item)) return;
|
||||
export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise<void> {
|
||||
if (!canImplementPlanItemId(host.stateStore.getState(), itemId)) return;
|
||||
await host.ensureConnected();
|
||||
if (!host.currentClient() || !activeThreadId(host.stateStore.getState())) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -76,14 +76,7 @@ export interface ActiveThreadRestoredPlaceholderAction {
|
|||
|
||||
export interface DisclosureSetAction {
|
||||
type: "ui/disclosure-set";
|
||||
bucket:
|
||||
| "toolResults"
|
||||
| "activityGroups"
|
||||
| "agentDetails"
|
||||
| "textDetails"
|
||||
| "userMessageExpanded"
|
||||
| "goalObjectiveExpanded"
|
||||
| "approvalDetails";
|
||||
bucket: "details" | "activityGroups" | "textDetails" | "userMessageExpanded" | "goalObjectiveExpanded" | "approvalDetails";
|
||||
id: string;
|
||||
open: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh
|
|||
};
|
||||
}
|
||||
|
||||
export function canImplementPlan(state: ChatState, item: MessageStreamItem): boolean {
|
||||
return item.id === implementPlanCandidateFromState(state)?.id;
|
||||
export function canImplementPlanItemId(state: ChatState, itemId: string): boolean {
|
||||
return itemId === implementPlanCandidateFromState(state)?.id;
|
||||
}
|
||||
|
||||
export function implementPlanCandidateFromState(state: {
|
||||
|
|
|
|||
|
|
@ -29,9 +29,8 @@ interface ChatMessageActionsUiState {
|
|||
}
|
||||
|
||||
const CHAT_DISCLOSURE_BUCKETS = [
|
||||
"toolResults",
|
||||
"details",
|
||||
"activityGroups",
|
||||
"agentDetails",
|
||||
"textDetails",
|
||||
"userMessageExpanded",
|
||||
"goalObjectiveExpanded",
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ export interface ReasoningMessageStreamItem extends ToolMessageStreamBase {
|
|||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface ContextCompactionMessageStreamItem extends MessageStreamBase {
|
||||
interface ContextCompactionMessageStreamItem extends MessageStreamBase {
|
||||
readonly kind: "contextCompaction";
|
||||
readonly role: "tool";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ function meaningForMessageStreamItem(item: MessageStreamItem): MessageStreamMean
|
|||
case "system":
|
||||
return { plane: "diagnostic", event: "notice" };
|
||||
}
|
||||
return { plane: "diagnostic", event: "notice" };
|
||||
}
|
||||
|
||||
function reviewResultMeaning(item: Extract<MessageStreamItem, { kind: "reviewResult" }>): MessageStreamMeaning {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
export { messageStreamSemanticClassifications } from "./classify";
|
||||
export {
|
||||
messageStreamIsAutoReviewDecision,
|
||||
messageStreamIsCoordinationProgress,
|
||||
messageStreamIsPermissionDecision,
|
||||
messageStreamIsReasoningProgress,
|
||||
messageStreamIsReviewResult,
|
||||
messageStreamIsTaskProgress,
|
||||
messageStreamIsTurnInitiator,
|
||||
messageStreamIsTurnSteer,
|
||||
messageStreamIsWorkspaceResult,
|
||||
messageStreamRenderFamily,
|
||||
} from "./predicates";
|
||||
export type { MessageStreamSemanticClassification } from "./types";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import type {
|
||||
MessageStreamMeaningEvent,
|
||||
MessageStreamMeaningPlane,
|
||||
MessageStreamRenderFamily,
|
||||
MessageStreamSemanticClassification,
|
||||
} from "./types";
|
||||
import type { MessageStreamMeaningEvent, MessageStreamMeaningPlane, MessageStreamSemanticClassification } from "./types";
|
||||
|
||||
function messageStreamHasMeaning(
|
||||
classification: MessageStreamSemanticClassification,
|
||||
|
|
@ -35,41 +30,14 @@ export function messageStreamIsCoordinationProgress(classification: MessageStrea
|
|||
return messageStreamHasMeaning(classification, "coordination", "progress");
|
||||
}
|
||||
|
||||
export function messageStreamIsTaskProgress(classification: MessageStreamSemanticClassification): boolean {
|
||||
return classification.item.kind === "taskProgress";
|
||||
}
|
||||
|
||||
export function messageStreamIsReasoningProgress(classification: MessageStreamSemanticClassification): boolean {
|
||||
return classification.item.kind === "reasoning";
|
||||
}
|
||||
|
||||
export function messageStreamIsReviewResult(classification: MessageStreamSemanticClassification): boolean {
|
||||
return classification.item.kind === "reviewResult";
|
||||
}
|
||||
|
||||
export function messageStreamRenderFamily(classification: MessageStreamSemanticClassification): MessageStreamRenderFamily | null {
|
||||
switch (classification.item.kind) {
|
||||
case "message":
|
||||
case "system":
|
||||
case "userInputResult":
|
||||
return "text";
|
||||
case "command":
|
||||
case "fileChange":
|
||||
case "tool":
|
||||
case "hook":
|
||||
case "goal":
|
||||
case "approvalResult":
|
||||
case "reviewResult":
|
||||
return "toolResult";
|
||||
case "taskProgress":
|
||||
case "agent":
|
||||
case "reasoning":
|
||||
case "contextCompaction":
|
||||
return "work";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function messageStreamIsPermissionDecision(classification: MessageStreamSemanticClassification): boolean {
|
||||
function messageStreamIsPermissionDecision(classification: MessageStreamSemanticClassification): boolean {
|
||||
return messageStreamHasMeaning(classification, "permission", "decision");
|
||||
}
|
||||
|
||||
export function messageStreamIsAutoReviewDecision(classification: MessageStreamSemanticClassification): boolean {
|
||||
const { provenance } = classification;
|
||||
if (!messageStreamIsPermissionDecision(classification) || !provenance) return false;
|
||||
if (provenance.source === "appServer" && provenance.channel === "notification") return provenance.event === "autoReview";
|
||||
if (provenance.source === "panel" && provenance.channel === "notice") return provenance.reason === "parsedAutoReview";
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,6 @@ export interface MessageStreamSemanticActions {
|
|||
isTurnOutcome: boolean;
|
||||
}
|
||||
|
||||
export type MessageStreamRenderFamily = "text" | "toolResult" | "work";
|
||||
|
||||
export interface MessageStreamSemanticClassification {
|
||||
item: MessageStreamItem;
|
||||
provenance?: MessageStreamItemProvenance;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { AppServerClientAccess } from "../../../app-server/connection/clien
|
|||
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
|
||||
import type { MessageStreamNoticeSection } from "../domain/message-stream/items";
|
||||
import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations";
|
||||
import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service";
|
||||
import { PendingRequestController } from "../application/pending-requests/controller";
|
||||
|
|
@ -959,7 +959,7 @@ function createSurfacesAndPresenter(
|
|||
actions: {
|
||||
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
|
||||
implementPlan: (item: MessageStreamItem) => void turnActions.planImplementation.implement(item),
|
||||
implementPlan: (itemId) => void turnActions.planImplementation.implement(itemId),
|
||||
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
|
||||
},
|
||||
requests: {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { ChatStateStore } from "../../application/state/store";
|
|||
import type { MessageStreamScrollIntent, MessageStreamVirtualizerHandle } from "../../ui/message-stream/virtualizer";
|
||||
import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-renderer";
|
||||
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport";
|
||||
import type { MessageStreamItem } from "../../domain/message-stream/items";
|
||||
import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks";
|
||||
import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../shell-state";
|
||||
import type { PendingRequestBlockActions, PendingRequestBlockState } from "../../application/pending-requests/block";
|
||||
|
|
@ -33,7 +32,7 @@ export function ChatPanelMessageStream({ presenter }: { presenter: ChatPanelMess
|
|||
interface ChatMessageStreamActions {
|
||||
rollbackThread: (threadId: string) => void;
|
||||
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
|
||||
implementPlan: (item: MessageStreamItem) => void;
|
||||
implementPlan: (itemId: string) => void;
|
||||
openTurnDiff: (state: ChatTurnDiffViewState) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,7 @@ import {
|
|||
import type { MessageStreamItem } from "../../domain/message-stream/items";
|
||||
import { messageStreamViewBlocks, type MessageStreamViewBlock } from "../../presentation/message-stream/view-model";
|
||||
import { implementPlanCandidateFromState } from "../../application/state/selectors";
|
||||
import {
|
||||
type ForkCandidate,
|
||||
forkCandidatesFromItems,
|
||||
isForkCandidateItem,
|
||||
isRollbackCandidateItem,
|
||||
} from "../../domain/message-stream/selectors";
|
||||
import { type ForkCandidate, forkCandidatesFromItems } from "../../domain/message-stream/selectors";
|
||||
import {
|
||||
messageStreamActiveItems,
|
||||
messageStreamItems,
|
||||
|
|
@ -26,11 +21,12 @@ import type { ChatPanelMessageStreamShellState } from "../shell-state";
|
|||
import { pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/snapshot";
|
||||
import type { PendingRequestBlockActions, PendingRequestBlockState } from "../../application/pending-requests/block";
|
||||
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
|
||||
import type { MessageStreamTextActions } from "../../presentation/message-stream/text-view";
|
||||
|
||||
interface ChatMessageStreamActions {
|
||||
rollbackThread: (threadId: string) => void;
|
||||
forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void;
|
||||
implementPlan: (item: MessageStreamItem) => void;
|
||||
implementPlan: (itemId: string) => void;
|
||||
openTurnDiff: (state: ChatTurnDiffViewState) => void;
|
||||
}
|
||||
|
||||
|
|
@ -65,18 +61,9 @@ export interface MessageStreamSurfaceContextOptions {
|
|||
interface MessageStreamStateProjection {
|
||||
activeThreadId: string | null;
|
||||
turnLifecycle: ChatPanelMessageStreamShellState["turn"]["lifecycle"];
|
||||
historyCursor: string | null;
|
||||
loadingHistory: boolean;
|
||||
items: readonly MessageStreamItem[];
|
||||
stableItems: readonly MessageStreamItem[];
|
||||
activeItems: readonly MessageStreamItem[];
|
||||
turnDiffs: ChatPanelMessageStreamShellState["messageStream"]["turnDiffs"];
|
||||
workspaceRoot: string;
|
||||
disclosures: ChatDisclosureUiState;
|
||||
forkActionsItemId: string | null;
|
||||
implementPlanCandidate: MessageStreamItem | null;
|
||||
rollbackCandidate: MessageStreamRollbackCandidate | null;
|
||||
forkCandidates: readonly ForkCandidate[];
|
||||
viewBlocks: readonly MessageStreamViewBlock[];
|
||||
}
|
||||
|
||||
|
|
@ -128,18 +115,15 @@ function messageStreamContextFromProjection(
|
|||
loadOlderTurns: context.loadOlderTurns,
|
||||
renderMarkdown: context.renderMarkdown,
|
||||
copyText: context.copyMessageText,
|
||||
canImplementPlanItem: (item: MessageStreamItem) => item.id === projection.implementPlanCandidate?.id,
|
||||
onImplementPlanItem: (item) => {
|
||||
context.actions.implementPlan(item);
|
||||
onImplementPlan: (target) => {
|
||||
context.actions.implementPlan(target.itemId);
|
||||
},
|
||||
canRollbackItem: (item: MessageStreamItem) => isRollbackCandidateItem(item, projection.rollbackCandidate),
|
||||
onRollbackItem: () => {
|
||||
onRollback: () => {
|
||||
if (projection.activeThreadId) context.actions.rollbackThread(projection.activeThreadId);
|
||||
},
|
||||
canForkItem: (item: MessageStreamItem) => isForkCandidateItem(item, projection.forkCandidates),
|
||||
onForkItem: (item, archiveSource) => {
|
||||
if (projection.activeThreadId && item.turnId) {
|
||||
context.actions.forkThreadFromTurn(projection.activeThreadId, item.turnId, archiveSource);
|
||||
onFork: (target, archiveSource) => {
|
||||
if (projection.activeThreadId) {
|
||||
context.actions.forkThreadFromTurn(projection.activeThreadId, target.turnId, archiveSource);
|
||||
}
|
||||
},
|
||||
openTurnDiff: (turnDiffState) => {
|
||||
|
|
@ -166,23 +150,15 @@ function messageStreamStateProjection(
|
|||
const rollbackCandidate = busy ? null : messageStreamRollbackCandidate(state.messageStream);
|
||||
const forkCandidates = busy ? [] : forkCandidatesFromItems(items);
|
||||
const implementPlanCandidate = implementPlanCandidateFromState(state);
|
||||
const textActionsByItemId = textActionsForMessageStreamItems(rollbackCandidate, forkCandidates, implementPlanCandidate);
|
||||
const activeTurn = activeTurnId({ lifecycle: state.turn.lifecycle });
|
||||
|
||||
return {
|
||||
activeThreadId: state.activeThread.id,
|
||||
turnLifecycle: state.turn.lifecycle,
|
||||
historyCursor: state.messageStream.historyCursor,
|
||||
loadingHistory: state.messageStream.loadingHistory,
|
||||
items,
|
||||
stableItems,
|
||||
activeItems,
|
||||
turnDiffs: state.messageStream.turnDiffs,
|
||||
workspaceRoot,
|
||||
disclosures: state.ui.disclosures,
|
||||
forkActionsItemId: state.ui.messageActions.forkActionsItemId,
|
||||
implementPlanCandidate,
|
||||
rollbackCandidate,
|
||||
forkCandidates,
|
||||
viewBlocks: messageStreamViewBlocks({
|
||||
activeThreadId: state.activeThread.id,
|
||||
activeTurnId: activeTurn,
|
||||
|
|
@ -193,11 +169,36 @@ function messageStreamStateProjection(
|
|||
activeItems,
|
||||
workspaceRoot,
|
||||
turnDiffs: state.messageStream.turnDiffs,
|
||||
textActionsByItemId,
|
||||
pendingRequests: messageStreamBlockItemsEmpty(stableItems, activeItems) ? null : pendingRequestBlockFromContext(context),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function textActionsForMessageStreamItems(
|
||||
rollbackCandidate: MessageStreamRollbackCandidate | null,
|
||||
forkCandidates: readonly ForkCandidate[],
|
||||
implementPlanCandidate: MessageStreamItem | null,
|
||||
): ReadonlyMap<string, MessageStreamTextActions> {
|
||||
const byItemId = new Map<string, MessageStreamTextActions>();
|
||||
for (const candidate of forkCandidates) {
|
||||
patchTextActions(byItemId, candidate.itemId, { fork: { itemId: candidate.itemId, turnId: candidate.turnId } });
|
||||
}
|
||||
if (rollbackCandidate) {
|
||||
patchTextActions(byItemId, rollbackCandidate.itemId, {
|
||||
rollback: { itemId: rollbackCandidate.itemId, turnId: rollbackCandidate.turnId },
|
||||
});
|
||||
}
|
||||
if (implementPlanCandidate) {
|
||||
patchTextActions(byItemId, implementPlanCandidate.id, { implementPlan: { itemId: implementPlanCandidate.id } });
|
||||
}
|
||||
return byItemId;
|
||||
}
|
||||
|
||||
function patchTextActions(byItemId: Map<string, MessageStreamTextActions>, itemId: string, patch: MessageStreamTextActions): void {
|
||||
byItemId.set(itemId, { ...byItemId.get(itemId), ...patch });
|
||||
}
|
||||
|
||||
function pendingRequestBlockFromContext(
|
||||
context: ChatMessageStreamSurfaceContext,
|
||||
): { signature: string; snapshot: ReturnType<typeof pendingRequestBlockSnapshotFromState> } | null {
|
||||
|
|
|
|||
|
|
@ -1,53 +1,58 @@
|
|||
import { jsonPreview, truncate } from "../../../../utils";
|
||||
import { shortThreadId, truncate } from "../../../../utils";
|
||||
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
|
||||
import type {
|
||||
AgentMessageStreamItem,
|
||||
ApprovalResultMessageStreamItem,
|
||||
CommandMessageStreamItem,
|
||||
CommandMessageStreamTarget,
|
||||
ExecutionState,
|
||||
FileChangeMessageStreamItem,
|
||||
GoalMessageStreamItem,
|
||||
HookMessageStreamItem,
|
||||
MessageStreamFileChange,
|
||||
MessageStreamItem,
|
||||
MessageStreamPrimaryTarget,
|
||||
ReviewResultMessageStreamItem,
|
||||
ToolCallMessageStreamItem,
|
||||
} from "../../domain/message-stream/items";
|
||||
import { agentActivityMetaLabel, agentMessagePreview } from "./agent-summary";
|
||||
import {
|
||||
compactSummary,
|
||||
detailViewBase,
|
||||
failedStatusLabel,
|
||||
jsonOutputSection,
|
||||
metaRow,
|
||||
outputSection,
|
||||
primaryTargetSummary,
|
||||
statusQualifier,
|
||||
type DetailSection,
|
||||
type DetailView,
|
||||
} from "./detail-types";
|
||||
|
||||
export type ToolResultMessageStreamItem =
|
||||
| CommandMessageStreamItem
|
||||
| FileChangeMessageStreamItem
|
||||
| GoalMessageStreamItem
|
||||
| ToolCallMessageStreamItem
|
||||
| HookMessageStreamItem
|
||||
| ApprovalResultMessageStreamItem
|
||||
| ReviewResultMessageStreamItem;
|
||||
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
|
||||
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;
|
||||
|
||||
export type ToolResultDetailSection =
|
||||
| { kind: "meta"; title?: string; rows: readonly { readonly key: string; readonly value: string }[] }
|
||||
| { kind: "output"; title: string; body: string }
|
||||
| { kind: "diff"; title: string; diff: string };
|
||||
|
||||
export interface ToolResultView {
|
||||
className: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
detailsKey: string;
|
||||
details: ToolResultDetailSection[];
|
||||
state: ExecutionState;
|
||||
export function codexDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView | null {
|
||||
switch (item.kind) {
|
||||
case "command":
|
||||
return commandDetailView(item);
|
||||
case "fileChange":
|
||||
return fileChangeDetailView(item, workspaceRoot);
|
||||
case "goal":
|
||||
return goalDetailView(item);
|
||||
case "approvalResult":
|
||||
return approvalDetailView(item);
|
||||
case "reviewResult":
|
||||
return reviewDetailView(item);
|
||||
case "agent":
|
||||
return agentDetailView(item);
|
||||
case "tool":
|
||||
case "hook":
|
||||
return genericToolDetailView(item, workspaceRoot);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toolResultView(item: ToolResultMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
|
||||
if (item.kind === "command") return commandToolView(item);
|
||||
if (item.kind === "fileChange") return fileChangeToolView(item, workspaceRoot);
|
||||
if (item.kind === "goal") return goalToolView(item);
|
||||
if (item.kind === "approvalResult") return approvalToolView(item);
|
||||
if (item.kind === "reviewResult") return reviewToolView(item);
|
||||
return genericToolView(item, workspaceRoot);
|
||||
}
|
||||
|
||||
function commandToolView(item: CommandMessageStreamItem): ToolResultView {
|
||||
function commandDetailView(item: CommandMessageStreamItem): DetailView {
|
||||
const rows = [
|
||||
{ key: "command", value: item.command },
|
||||
{ key: "cwd", value: item.cwd },
|
||||
|
|
@ -55,31 +60,31 @@ function commandToolView(item: CommandMessageStreamItem): ToolResultView {
|
|||
...(item.exitCode !== undefined ? [{ key: "exit", value: String(item.exitCode) }] : []),
|
||||
...(item.durationMs !== undefined ? [{ key: "duration", value: `${String(item.durationMs)}ms` }] : []),
|
||||
];
|
||||
const details: ToolResultDetailSection[] = [
|
||||
const sections: DetailSection[] = [
|
||||
{
|
||||
kind: "meta",
|
||||
kind: "kv",
|
||||
rows,
|
||||
},
|
||||
...outputSection("Output", item.output),
|
||||
];
|
||||
return toolView(
|
||||
return detailViewBase(
|
||||
item,
|
||||
"codex-panel__tool-item",
|
||||
"codex-panel__detail-item",
|
||||
commandActionLabel(item.commandAction),
|
||||
`${item.id}:command-details`,
|
||||
details,
|
||||
sections,
|
||||
commandSummary(item),
|
||||
);
|
||||
}
|
||||
|
||||
function fileChangeToolView(item: FileChangeMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
|
||||
function fileChangeDetailView(item: FileChangeMessageStreamItem, workspaceRoot?: string | null): DetailView {
|
||||
const displayChanges = item.changes.map((change) => ({
|
||||
...change,
|
||||
displayPath: change.path && change.path !== "(unknown)" ? pathRelativeToRoot(change.path, workspaceRoot) : change.path,
|
||||
}));
|
||||
const details: ToolResultDetailSection[] = [
|
||||
const sections: DetailSection[] = [
|
||||
{
|
||||
kind: "meta",
|
||||
kind: "kv",
|
||||
rows: [
|
||||
{ key: "status", value: item.status },
|
||||
{ key: "files", value: String(item.changes.length) },
|
||||
|
|
@ -92,24 +97,41 @@ function fileChangeToolView(item: FileChangeMessageStreamItem, workspaceRoot?: s
|
|||
})),
|
||||
...outputSection("Patch output", item.output),
|
||||
];
|
||||
return toolView(
|
||||
return detailViewBase(
|
||||
item,
|
||||
"codex-panel__file-change",
|
||||
"codex-panel__detail-item codex-panel__detail-item--file-change",
|
||||
"file change",
|
||||
`${item.id}:file-change-details`,
|
||||
details,
|
||||
sections,
|
||||
fileChangeSummary(item, displayChanges),
|
||||
);
|
||||
}
|
||||
|
||||
function goalToolView(item: GoalMessageStreamItem): ToolResultView {
|
||||
return toolView(item, "codex-panel__tool-item codex-panel__tool-item--goal", "goal", `${item.id}:goal-details`, goalDetails(item));
|
||||
function goalDetailView(item: GoalMessageStreamItem): DetailView {
|
||||
return detailViewBase(
|
||||
item,
|
||||
"codex-panel__detail-item codex-panel__detail-item--goal",
|
||||
"goal",
|
||||
`${item.id}:goal-details`,
|
||||
goalDetails(item),
|
||||
);
|
||||
}
|
||||
|
||||
function genericToolView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
|
||||
return toolView(
|
||||
function agentDetailView(item: AgentMessageStreamItem): DetailView {
|
||||
return detailViewBase(
|
||||
item,
|
||||
`codex-panel__tool-item codex-panel__tool-item--${item.kind}`,
|
||||
"codex-panel__detail-item codex-panel__agent-activity",
|
||||
"agent",
|
||||
`${item.id}:agent-details`,
|
||||
agentDetailSections(item),
|
||||
agentSummaryText(item),
|
||||
);
|
||||
}
|
||||
|
||||
function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): DetailView {
|
||||
return detailViewBase(
|
||||
item,
|
||||
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
|
||||
item.toolName ?? item.kind,
|
||||
`${item.id}:details`,
|
||||
[...genericToolDetails(item), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)],
|
||||
|
|
@ -117,70 +139,77 @@ function genericToolView(item: ToolCallMessageStreamItem | HookMessageStreamItem
|
|||
);
|
||||
}
|
||||
|
||||
function reviewToolView(item: ReviewResultMessageStreamItem): ToolResultView {
|
||||
return resultToolView(
|
||||
function reviewDetailView(item: ReviewResultMessageStreamItem): DetailView {
|
||||
return resultDetailView(
|
||||
item,
|
||||
"auto-review",
|
||||
`${item.id}:review-details`,
|
||||
"codex-panel__message--review-result codex-panel__tool-item--review",
|
||||
"codex-panel__message--review-result codex-panel__detail-item--review",
|
||||
);
|
||||
}
|
||||
|
||||
function approvalToolView(item: ApprovalResultMessageStreamItem): ToolResultView {
|
||||
return resultToolView(
|
||||
function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView {
|
||||
return resultDetailView(
|
||||
item,
|
||||
"approval",
|
||||
`${item.id}:approval-details`,
|
||||
"codex-panel__message--approval-result codex-panel__tool-item--approval",
|
||||
"codex-panel__message--approval-result codex-panel__detail-item--approval",
|
||||
);
|
||||
}
|
||||
|
||||
function resultToolView(
|
||||
function resultDetailView(
|
||||
item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem,
|
||||
label: string,
|
||||
detailsKey: string,
|
||||
className: string,
|
||||
): ToolResultView {
|
||||
return toolView(item, `codex-panel__tool-item ${className}`, label, detailsKey, resultDetails(item));
|
||||
): DetailView {
|
||||
return detailViewBase(item, `codex-panel__detail-item ${className}`, label, detailsKey, resultDetails(item));
|
||||
}
|
||||
|
||||
function toolView(
|
||||
item: ToolResultMessageStreamItem,
|
||||
className: string,
|
||||
label: string,
|
||||
detailsKey: string,
|
||||
details: ToolResultDetailSection[],
|
||||
summary = fallbackSummary(item),
|
||||
): ToolResultView {
|
||||
return {
|
||||
className: `codex-panel__message codex-panel__message--tool ${className}`,
|
||||
label,
|
||||
summary,
|
||||
detailsKey,
|
||||
details,
|
||||
state: item.executionState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function outputSection(title: string, body: string | null | undefined): ToolResultDetailSection[] {
|
||||
return body ? [{ kind: "output", title, body }] : [];
|
||||
}
|
||||
|
||||
function goalDetails(item: GoalMessageStreamItem): ToolResultDetailSection[] {
|
||||
function goalDetails(item: GoalMessageStreamItem): DetailSection[] {
|
||||
return [
|
||||
{
|
||||
kind: "meta",
|
||||
kind: "kv",
|
||||
rows: [{ key: "action", value: item.action }],
|
||||
},
|
||||
...outputSection("Objective", item.objective),
|
||||
];
|
||||
}
|
||||
|
||||
function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem): ToolResultDetailSection[] {
|
||||
function agentDetailSections(item: AgentMessageStreamItem): DetailSection[] {
|
||||
const rows = [
|
||||
{ key: "tool", value: agentActivityMetaLabel(item.tool) },
|
||||
{ key: "status", value: item.status },
|
||||
{ key: "sender", value: item.senderThreadId },
|
||||
...(item.receiverThreadIds.length > 0 ? [{ key: "target", value: item.receiverThreadIds.join(", ") }] : []),
|
||||
...(item.model ? [{ key: "model", value: item.model }] : []),
|
||||
...(item.reasoningEffort ? [{ key: "effort", value: item.reasoningEffort }] : []),
|
||||
];
|
||||
return [
|
||||
{ kind: "kv", rows },
|
||||
...outputSection("Prompt", item.prompt),
|
||||
...agentStateSection(item),
|
||||
...item.agents.flatMap((agent) =>
|
||||
agent.message && isLongAgentMessage(agent.message)
|
||||
? outputSection(`Agent output ${shortThreadId(agent.threadId)}`, agent.message)
|
||||
: [],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function agentStateSection(item: AgentMessageStreamItem): DetailSection[] {
|
||||
const rows = item.agents.map((agent) => ({
|
||||
key: shortThreadId(agent.threadId),
|
||||
value: agentStatusLabel(agent.status, agent.message),
|
||||
}));
|
||||
return rows.length > 0 ? [{ kind: "kv", title: "agents", rows }] : [];
|
||||
}
|
||||
|
||||
function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem): DetailSection[] {
|
||||
if (item.kind === "approvalResult") {
|
||||
return [
|
||||
{
|
||||
kind: "meta",
|
||||
kind: "kv",
|
||||
rows: [
|
||||
{ key: "status", value: item.approval.status },
|
||||
{ key: "scope", value: item.approval.scope },
|
||||
|
|
@ -190,15 +219,15 @@ function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessa
|
|||
},
|
||||
];
|
||||
}
|
||||
return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "meta", rows: item.review.auditFacts }] : [];
|
||||
return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "kv", rows: item.review.auditFacts }] : [];
|
||||
}
|
||||
|
||||
function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): ToolResultDetailSection[] {
|
||||
function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): DetailSection[] {
|
||||
if (item.kind === "hook") return hookRunDetails(item);
|
||||
return [...toolCallDetails(item), ...webSearchDetails(item), ...imageGenerationDetails(item)];
|
||||
}
|
||||
|
||||
function toolCallDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
|
||||
function toolCallDetails(item: ToolCallMessageStreamItem): DetailSection[] {
|
||||
const details = item.toolCall;
|
||||
if (!details) return [];
|
||||
return [
|
||||
|
|
@ -208,7 +237,7 @@ function toolCallDetails(item: ToolCallMessageStreamItem): ToolResultDetailSecti
|
|||
];
|
||||
}
|
||||
|
||||
function webSearchDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
|
||||
function webSearchDetails(item: ToolCallMessageStreamItem): DetailSection[] {
|
||||
const details = item.webSearch;
|
||||
if (!details) return [];
|
||||
const rows = [
|
||||
|
|
@ -217,10 +246,10 @@ function webSearchDetails(item: ToolCallMessageStreamItem): ToolResultDetailSect
|
|||
...metaRow("pattern", details.pattern),
|
||||
...metaRow("url", details.url),
|
||||
];
|
||||
return rows.length > 0 ? [{ kind: "meta", title: "web search", rows }] : [];
|
||||
return rows.length > 0 ? [{ kind: "kv", title: "web search", rows }] : [];
|
||||
}
|
||||
|
||||
function imageGenerationDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
|
||||
function imageGenerationDetails(item: ToolCallMessageStreamItem): DetailSection[] {
|
||||
const details = item.imageGeneration;
|
||||
if (!details) return [];
|
||||
return [
|
||||
|
|
@ -230,7 +259,7 @@ function imageGenerationDetails(item: ToolCallMessageStreamItem): ToolResultDeta
|
|||
];
|
||||
}
|
||||
|
||||
function hookRunDetails(item: HookMessageStreamItem): ToolResultDetailSection[] {
|
||||
function hookRunDetails(item: HookMessageStreamItem): DetailSection[] {
|
||||
const details = item.hookRun;
|
||||
if (!details) return [];
|
||||
const rows = [
|
||||
|
|
@ -240,15 +269,7 @@ function hookRunDetails(item: HookMessageStreamItem): ToolResultDetailSection[]
|
|||
...metaRow("duration", details.durationMs),
|
||||
];
|
||||
const entries = details.entries.map((entry) => `${entry.kind}: ${entry.text}`).join("\n");
|
||||
return [{ kind: "meta", rows }, ...outputSection("Hook output", entries)];
|
||||
}
|
||||
|
||||
function jsonOutputSection(title: string, value: unknown): ToolResultDetailSection[] {
|
||||
return value === null || value === undefined ? [] : outputSection(title, jsonPreview(value));
|
||||
}
|
||||
|
||||
function metaRow(key: string, value: string | null | undefined): { key: string; value: string }[] {
|
||||
return value ? [{ key, value }] : [];
|
||||
return [{ kind: "kv", rows }, ...outputSection("Hook output", entries)];
|
||||
}
|
||||
|
||||
function commandActionLabel(action: CommandMessageStreamItem["commandAction"]): string {
|
||||
|
|
@ -287,14 +308,30 @@ function genericToolSummary(item: ToolCallMessageStreamItem | HookMessageStreamI
|
|||
return compactSummary(toolOperationLabel(item.operation), target, statusQualifier(item.status, item.failureReason));
|
||||
}
|
||||
|
||||
function fileChangeSummary(item: MessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
|
||||
if (item.kind !== "fileChange") return "text" in item && typeof item.text === "string" ? item.text : "details";
|
||||
function fileChangeSummary(item: FileChangeMessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
|
||||
const target = fileChangeTargetSummary(changes);
|
||||
return compactSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status)));
|
||||
}
|
||||
|
||||
function fallbackSummary(item: ToolResultMessageStreamItem): string {
|
||||
return "text" in item && typeof item.text === "string" ? item.text : "details";
|
||||
function agentSummaryText(item: AgentMessageStreamItem): string {
|
||||
const target = item.receiverThreadIds.length === 0 ? "" : ` ${item.receiverThreadIds.map(shortThreadId).join(", ")}`;
|
||||
const promptPreview = agentPromptPreview(item.prompt);
|
||||
return `${agentActivityMetaLabel(item.tool)}${target}${promptPreview ? `: ${promptPreview}` : ""} (${item.status})`;
|
||||
}
|
||||
|
||||
function agentPromptPreview(prompt: string | null): string | null {
|
||||
if (!prompt) return null;
|
||||
const normalized = prompt.trim().replace(/\s+/g, " ");
|
||||
return normalized ? truncate(normalized, AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT) : null;
|
||||
}
|
||||
|
||||
function agentStatusLabel(status: string, message: string | null): string {
|
||||
const preview = agentMessagePreview(message, AGENT_ROW_MESSAGE_PREVIEW_LIMIT);
|
||||
return preview ? `${status}: ${preview}` : status;
|
||||
}
|
||||
|
||||
function isLongAgentMessage(message: string): boolean {
|
||||
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
|
||||
}
|
||||
|
||||
function fileChangeTargetSummary(changes: (MessageStreamFileChange & { displayPath: string })[]): string {
|
||||
|
|
@ -303,30 +340,6 @@ function fileChangeTargetSummary(changes: (MessageStreamFileChange & { displayPa
|
|||
return `${String(changes.length)} files`;
|
||||
}
|
||||
|
||||
function primaryTargetSummary(target: MessageStreamPrimaryTarget | undefined, workspaceRoot?: string | null): string | null {
|
||||
if (!target) return null;
|
||||
if (target.kind === "path") return pathRelativeToRoot(target.path, workspaceRoot);
|
||||
return target.value;
|
||||
}
|
||||
|
||||
function compactSummary(label: string | null, target?: string | null, qualifier?: string | null): string {
|
||||
const targetText = target?.trim();
|
||||
const base = label ? (targetText ? `${label}: ${targetText}` : label) : (targetText ?? "details");
|
||||
return truncate(qualifier ? `${base} (${qualifier})` : base, 140);
|
||||
}
|
||||
|
||||
function statusQualifier(status: unknown, failure?: string | null): string | null {
|
||||
if (status === "declined") return "declined";
|
||||
if (status === "failed") return failure && failure.length > 0 ? failure : "failed";
|
||||
return null;
|
||||
}
|
||||
|
||||
function failedStatusLabel(status: unknown): string | null {
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "declined") return "declined";
|
||||
return null;
|
||||
}
|
||||
|
||||
function toolOperationLabel(operation: string | null | undefined): string | null {
|
||||
if (!operation) return null;
|
||||
if (operation === "openPage") return "open page";
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { jsonPreview, truncate } from "../../../../utils";
|
||||
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
|
||||
import type { ExecutionState, MessageStreamItem, MessageStreamPrimaryTarget } from "../../domain/message-stream/items";
|
||||
|
||||
export type DetailSection =
|
||||
| { kind: "kv"; title?: string; rows: readonly { readonly key: string; readonly value: string }[] }
|
||||
| { kind: "output"; title: string; body: string }
|
||||
| { kind: "diff"; title: string; diff: string };
|
||||
|
||||
export interface DetailView {
|
||||
className: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
detailsKey: string;
|
||||
sections: DetailSection[];
|
||||
state: ExecutionState;
|
||||
}
|
||||
|
||||
export function detailViewBase(
|
||||
item: MessageStreamItem,
|
||||
className: string,
|
||||
label: string,
|
||||
detailsKey: string,
|
||||
sections: DetailSection[],
|
||||
summary = fallbackSummary(item),
|
||||
): DetailView {
|
||||
return {
|
||||
className: `codex-panel__message codex-panel__message--tool ${className}`,
|
||||
label,
|
||||
summary,
|
||||
detailsKey,
|
||||
sections,
|
||||
state: item.executionState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function outputSection(title: string, body: string | null | undefined): DetailSection[] {
|
||||
return body ? [{ kind: "output", title, body }] : [];
|
||||
}
|
||||
|
||||
export function jsonOutputSection(title: string, value: unknown): DetailSection[] {
|
||||
return value === null || value === undefined ? [] : outputSection(title, jsonPreview(value));
|
||||
}
|
||||
|
||||
export function metaRow(key: string, value: string | null | undefined): { key: string; value: string }[] {
|
||||
return value ? [{ key, value }] : [];
|
||||
}
|
||||
|
||||
export function primaryTargetSummary(target: MessageStreamPrimaryTarget | undefined, workspaceRoot?: string | null): string | null {
|
||||
if (!target) return null;
|
||||
if (target.kind === "path") return pathRelativeToRoot(target.path, workspaceRoot);
|
||||
return target.value;
|
||||
}
|
||||
|
||||
export function textField(item: MessageStreamItem): string | null {
|
||||
return "text" in item && typeof item.text === "string" && item.text.trim().length > 0 ? item.text : null;
|
||||
}
|
||||
|
||||
export function outputField(item: MessageStreamItem): string | null {
|
||||
return "output" in item && typeof item.output === "string" && item.output.trim().length > 0 ? item.output : null;
|
||||
}
|
||||
|
||||
export function stringField(item: MessageStreamItem, key: "failureReason" | "operation" | "status" | "toolName"): string | null {
|
||||
if (!(key in item)) return null;
|
||||
const value = (item as unknown as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function compactSummary(label: string | null, target?: string | null, qualifier?: string | null): string {
|
||||
const targetText = target?.trim();
|
||||
const base = label ? (targetText ? `${label}: ${targetText}` : label) : (targetText ?? "details");
|
||||
return truncate(qualifier ? `${base} (${qualifier})` : base, 140);
|
||||
}
|
||||
|
||||
export function statusQualifier(status: unknown, failure?: string | null): string | null {
|
||||
if (status === "declined") return "declined";
|
||||
if (status === "failed") return failure && failure.length > 0 ? failure : "failed";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function failedStatusLabel(status: unknown): string | null {
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "declined") return "declined";
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallbackSummary(item: MessageStreamItem): string {
|
||||
return textField(item) ?? "details";
|
||||
}
|
||||
56
src/features/chat/presentation/message-stream/detail-view.ts
Normal file
56
src/features/chat/presentation/message-stream/detail-view.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { MessageStreamItem, MessageStreamPrimaryTarget } from "../../domain/message-stream/items";
|
||||
import { codexDetailView } from "./codex-detail-view";
|
||||
import {
|
||||
compactSummary,
|
||||
detailViewBase,
|
||||
metaRow,
|
||||
outputField,
|
||||
outputSection,
|
||||
primaryTargetSummary,
|
||||
stringField,
|
||||
textField,
|
||||
type DetailSection,
|
||||
type DetailView,
|
||||
} from "./detail-types";
|
||||
|
||||
export { type DetailSection, type DetailView } from "./detail-types";
|
||||
|
||||
export function detailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView {
|
||||
return codexDetailView(item, workspaceRoot) ?? genericDetailView(item, workspaceRoot);
|
||||
}
|
||||
|
||||
function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView {
|
||||
return detailViewBase(
|
||||
item,
|
||||
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
|
||||
detailLabel(item),
|
||||
`${item.id}:details`,
|
||||
genericDetailSections(item, workspaceRoot),
|
||||
genericDetailSummary(item, workspaceRoot),
|
||||
);
|
||||
}
|
||||
|
||||
function genericDetailSections(item: MessageStreamItem, workspaceRoot?: string | null): DetailSection[] {
|
||||
const rows = [
|
||||
...metaRow("kind", item.kind),
|
||||
...metaRow("status", stringField(item, "status")),
|
||||
...metaRow("operation", stringField(item, "operation")),
|
||||
...metaRow("target", primaryTargetSummary(primaryTargetField(item), workspaceRoot)),
|
||||
...metaRow("failure", stringField(item, "failureReason")),
|
||||
];
|
||||
return [...(rows.length > 0 ? [{ kind: "kv" as const, rows }] : []), ...outputSection("Output", outputField(item))];
|
||||
}
|
||||
|
||||
function genericDetailSummary(item: MessageStreamItem, workspaceRoot?: string | null): string {
|
||||
const target = primaryTargetSummary(primaryTargetField(item), workspaceRoot);
|
||||
return compactSummary(null, target ?? textField(item) ?? outputField(item) ?? stringField(item, "status") ?? item.kind);
|
||||
}
|
||||
|
||||
function detailLabel(item: MessageStreamItem): string {
|
||||
return stringField(item, "toolName") ?? item.kind;
|
||||
}
|
||||
|
||||
function primaryTargetField(item: MessageStreamItem): MessageStreamPrimaryTarget | undefined {
|
||||
if (!("primaryTarget" in item)) return undefined;
|
||||
return item.primaryTarget;
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import type { MessageStreamItem } from "../../domain/message-stream/items";
|
||||
import { pathRelativeToRoot } from "../../domain/message-stream/format/path-labels";
|
||||
import {
|
||||
messageStreamIsPermissionDecision,
|
||||
messageStreamIsReasoningProgress,
|
||||
messageStreamIsReviewResult,
|
||||
messageStreamIsAutoReviewDecision,
|
||||
messageStreamIsTurnInitiator,
|
||||
messageStreamIsTurnSteer,
|
||||
messageStreamIsWorkspaceResult,
|
||||
|
|
@ -113,12 +111,11 @@ export function messageStreamLayoutBlocks(
|
|||
type GroupedActivity = MessageStreamActivityGroupItem;
|
||||
|
||||
function shouldShowPresentationItem(classification: MessageStreamSemanticClassification): boolean {
|
||||
const { item } = classification;
|
||||
return (
|
||||
!messageStreamIsReasoningProgress(classification) ||
|
||||
item.executionState !== "completed" ||
|
||||
textForMessageStreamItem(item).trim().length > 0
|
||||
);
|
||||
return !isEmptyCompletedReasoningItem(classification.item);
|
||||
}
|
||||
|
||||
function isEmptyCompletedReasoningItem(item: MessageStreamItem): boolean {
|
||||
return item.kind === "reasoning" && item.executionState === "completed" && textForMessageStreamItem(item).trim().length === 0;
|
||||
}
|
||||
|
||||
function steeringActivityGroupItem(classification: MessageStreamSemanticClassification): MessageStreamActivityGroupItem {
|
||||
|
|
@ -195,7 +192,7 @@ function autoReviewSummariesForTurns(items: readonly MessageStreamSemanticClassi
|
|||
const byTurn = new Map<string, string[]>();
|
||||
for (const classification of items) {
|
||||
const { item } = classification;
|
||||
if (!item.turnId || !messageStreamIsReviewResult(classification) || !messageStreamIsPermissionDecision(classification)) {
|
||||
if (!item.turnId || !messageStreamIsAutoReviewDecision(classification)) {
|
||||
continue;
|
||||
}
|
||||
const summary = textForMessageStreamItem(item).trim();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { MessageStreamSemanticClassification } from "../../domain/message-stream/semantics";
|
||||
|
||||
export type MessageStreamRenderFamily = "text" | "detail" | "status";
|
||||
|
||||
export function messageStreamRenderFamily(classification: MessageStreamSemanticClassification): MessageStreamRenderFamily {
|
||||
switch (classification.item.kind) {
|
||||
case "message":
|
||||
case "system":
|
||||
case "userInputResult":
|
||||
return "text";
|
||||
case "command":
|
||||
case "fileChange":
|
||||
case "tool":
|
||||
case "hook":
|
||||
case "goal":
|
||||
case "approvalResult":
|
||||
case "reviewResult":
|
||||
case "agent":
|
||||
return "detail";
|
||||
case "taskProgress":
|
||||
case "reasoning":
|
||||
case "contextCompaction":
|
||||
return "status";
|
||||
}
|
||||
return "status";
|
||||
}
|
||||
143
src/features/chat/presentation/message-stream/status-view.ts
Normal file
143
src/features/chat/presentation/message-stream/status-view.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { agentRunSummaryLabel } from "./agent-summary";
|
||||
import { shortThreadId } from "../../../../utils";
|
||||
import type {
|
||||
AgentRunSummary,
|
||||
AgentRunSummaryAgent,
|
||||
ExecutionState,
|
||||
MessageStreamItem,
|
||||
ReasoningMessageStreamItem,
|
||||
TaskProgressMessageStreamItem,
|
||||
} from "../../domain/message-stream/items";
|
||||
|
||||
export type StatusChecklistItem = TaskProgressMessageStreamItem["steps"][number];
|
||||
|
||||
export type MessageStreamStatusView =
|
||||
| {
|
||||
kind: "taskProgress";
|
||||
label: "tasks";
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
summary: string | null;
|
||||
checklist: readonly StatusChecklistItem[];
|
||||
}
|
||||
| {
|
||||
kind: "contextCompaction";
|
||||
label: "context";
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: "reasoning";
|
||||
active: boolean;
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: "generic";
|
||||
label: string;
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export interface MessageStreamStatusViewContext {
|
||||
activeTurnId: string | null;
|
||||
items: readonly MessageStreamItem[];
|
||||
activeItems?: readonly MessageStreamItem[] | undefined;
|
||||
}
|
||||
|
||||
export interface AgentRunSummaryView {
|
||||
label: "agents";
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
summary: string;
|
||||
rows: readonly { threadId: string; threadLabel: string; status: string }[];
|
||||
additionalAgents: number;
|
||||
}
|
||||
|
||||
export function messageStreamStatusView(item: MessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView {
|
||||
if (item.kind === "taskProgress") {
|
||||
return {
|
||||
kind: "taskProgress",
|
||||
label: "tasks",
|
||||
className: "codex-panel__task-progress",
|
||||
state: item.executionState ?? null,
|
||||
summary: item.explanation,
|
||||
checklist: item.steps,
|
||||
};
|
||||
}
|
||||
if (item.kind === "contextCompaction") return contextCompactionStatusView(item, context);
|
||||
if (item.kind === "reasoning") return reasoningStatusView(item, context);
|
||||
return genericStatusView(item);
|
||||
}
|
||||
|
||||
export function agentRunSummaryView(summary: AgentRunSummary): AgentRunSummaryView {
|
||||
return {
|
||||
label: "agents",
|
||||
className: "codex-panel__agent-summary",
|
||||
state: summary.failed > 0 ? "failed" : "running",
|
||||
summary: agentRunSummaryLabel(summary),
|
||||
rows: summary.agents.map(agentRunSummaryRow),
|
||||
additionalAgents: summary.additionalAgents,
|
||||
};
|
||||
}
|
||||
|
||||
function contextCompactionStatusView(item: MessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView {
|
||||
const active = context.activeTurnId === item.turnId;
|
||||
return {
|
||||
kind: "contextCompaction",
|
||||
label: "context",
|
||||
className: "codex-panel__context-compaction",
|
||||
state: active ? "running" : "completed",
|
||||
text: active ? "Compacting context..." : "Context compacted",
|
||||
};
|
||||
}
|
||||
|
||||
function reasoningStatusView(item: ReasoningMessageStreamItem, context: MessageStreamStatusViewContext): MessageStreamStatusView {
|
||||
const active = isReasoningActive(item, context);
|
||||
return {
|
||||
kind: "reasoning",
|
||||
active,
|
||||
label: active ? "reasoning" : "thought",
|
||||
text: item.text || (active ? "Reasoning" : "Thought"),
|
||||
};
|
||||
}
|
||||
|
||||
function genericStatusView(item: MessageStreamItem): MessageStreamStatusView {
|
||||
return {
|
||||
kind: "generic",
|
||||
label: item.kind,
|
||||
className: "codex-panel__status-item",
|
||||
state: item.executionState ?? null,
|
||||
text:
|
||||
stringField(item, "text") ??
|
||||
stringField(item, "status") ??
|
||||
stringField(item, "output") ??
|
||||
stringField(item, "failureReason") ??
|
||||
stringField(item, "operation") ??
|
||||
item.kind,
|
||||
};
|
||||
}
|
||||
|
||||
function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; threadLabel: string; status: string } {
|
||||
return {
|
||||
threadId: agent.threadId,
|
||||
threadLabel: shortThreadId(agent.threadId),
|
||||
status: agent.messagePreview ? `${agent.status}: ${agent.messagePreview}` : agent.status,
|
||||
};
|
||||
}
|
||||
|
||||
function isReasoningActive(item: ReasoningMessageStreamItem, context: MessageStreamStatusViewContext): boolean {
|
||||
const activeTurn = context.activeTurnId;
|
||||
if (!activeTurn || item.turnId !== activeTurn) return false;
|
||||
if (item.executionState === "completed") return false;
|
||||
const latestActiveTurnItem = [...(context.activeItems ?? context.items)].reverse().find((candidate) => candidate.turnId === activeTurn);
|
||||
return latestActiveTurnItem?.id === item.id;
|
||||
}
|
||||
|
||||
function stringField(item: MessageStreamItem, key: "failureReason" | "operation" | "output" | "status" | "text"): string | null {
|
||||
if (!(key in item)) return null;
|
||||
const value = (item as unknown as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
|
@ -1,37 +1,87 @@
|
|||
import type { ExecutionState, MessageStreamItem } from "../../domain/message-stream/items";
|
||||
import type { MessageStreamItemAnnotations } from "./layout";
|
||||
|
||||
export type TextMessageStreamItem = Extract<MessageStreamItem, { kind: "message" | "system" | "userInputResult" }>;
|
||||
export interface MessageStreamForkTarget {
|
||||
itemId: string;
|
||||
turnId: string;
|
||||
}
|
||||
|
||||
export interface MessageStreamRollbackTarget {
|
||||
itemId: string;
|
||||
turnId: string;
|
||||
}
|
||||
|
||||
export interface MessageStreamPlanImplementationTarget {
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export interface MessageStreamTextActions {
|
||||
fork?: MessageStreamForkTarget;
|
||||
rollback?: MessageStreamRollbackTarget;
|
||||
implementPlan?: MessageStreamPlanImplementationTarget;
|
||||
}
|
||||
|
||||
export interface MessageStreamTextView {
|
||||
item: TextMessageStreamItem;
|
||||
id: string;
|
||||
item: MessageStreamItem;
|
||||
roleLabel: string;
|
||||
body: string;
|
||||
className: string;
|
||||
contentKey: string;
|
||||
contentMode: "markdown" | "text";
|
||||
collapsible: boolean;
|
||||
copyText?: string;
|
||||
actions: MessageStreamTextActions;
|
||||
annotations?: MessageStreamItemAnnotations;
|
||||
editedFiles: readonly string[];
|
||||
autoReviewSummaries: readonly string[];
|
||||
}
|
||||
|
||||
export function messageStreamTextView(item: TextMessageStreamItem, annotations?: MessageStreamItemAnnotations): MessageStreamTextView {
|
||||
export function messageStreamTextView(
|
||||
item: MessageStreamItem,
|
||||
annotations?: MessageStreamItemAnnotations,
|
||||
options: { activeTurnId?: string | null; actions?: MessageStreamTextActions } = {},
|
||||
): MessageStreamTextView {
|
||||
const contentMode = textContentMode(item);
|
||||
const body = bodyForTextItem(item);
|
||||
return {
|
||||
id: item.id,
|
||||
item,
|
||||
roleLabel: roleLabelForTextItem(item),
|
||||
body,
|
||||
className: `${textItemClass(item)}${executionClassName(item.executionState ?? null)}`,
|
||||
contentKey: `${item.id}\u001f${contentMode}`,
|
||||
contentMode,
|
||||
collapsible: item.kind === "message" && item.role === "user",
|
||||
...definedProp("copyText", copyTextForTextItem(item, options.activeTurnId ?? null)),
|
||||
actions: options.actions ?? {},
|
||||
...definedProp("annotations", annotations),
|
||||
editedFiles: annotations?.editedFiles ?? [],
|
||||
autoReviewSummaries: annotations?.autoReviewSummaries ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function textContentMode(item: TextMessageStreamItem): "markdown" | "text" {
|
||||
function textContentMode(item: MessageStreamItem): "markdown" | "text" {
|
||||
return item.kind === "message" && (item.messageKind !== "proposedPlan" || item.messageState === "completed") ? "markdown" : "text";
|
||||
}
|
||||
|
||||
function bodyForTextItem(item: MessageStreamItem): string {
|
||||
return "text" in item && typeof item.text === "string" ? item.text : "";
|
||||
}
|
||||
|
||||
function roleLabelForTextItem(item: MessageStreamItem): string {
|
||||
if (item.kind === "userInputResult") return "Input";
|
||||
if (item.role === "user") return "You";
|
||||
if (item.role === "assistant") return "Codex";
|
||||
return "System";
|
||||
}
|
||||
|
||||
function copyTextForTextItem(item: MessageStreamItem, activeTurnId: string | null): string | undefined {
|
||||
if (item.kind !== "message" || item.copyText === undefined) return undefined;
|
||||
if (activeTurnId && item.role === "assistant" && item.turnId === activeTurnId) return undefined;
|
||||
return item.copyText;
|
||||
}
|
||||
|
||||
function executionClassName(state: ExecutionState): string {
|
||||
return state ? ` codex-panel__execution codex-panel__execution--${state}` : "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
import { activeAgentRunSummary } from "./agent-summary";
|
||||
import { messageStreamRenderFamily } from "../../domain/message-stream/semantics";
|
||||
import {
|
||||
messageStreamIsCoordinationProgress,
|
||||
messageStreamIsTaskProgress,
|
||||
messageStreamSemanticClassifications,
|
||||
type MessageStreamSemanticClassification,
|
||||
} from "../../domain/message-stream/semantics";
|
||||
import type { AgentRunSummary, MessageStreamItem, TaskProgressMessageStreamItem } from "../../domain/message-stream/items";
|
||||
import { messageStreamLayoutBlocks, type MessageStreamItemAnnotations, type MessageStreamLayoutBlock } from "./layout";
|
||||
import { messageStreamTextView, type MessageStreamTextView, type TextMessageStreamItem } from "./text-view";
|
||||
import { toolResultView, type ToolResultMessageStreamItem, type ToolResultView } from "./tool-result-view";
|
||||
import {
|
||||
agentRunSummaryView,
|
||||
messageStreamWorkView,
|
||||
type AgentRunSummaryView,
|
||||
type MessageStreamWorkView,
|
||||
type WorkMessageStreamItem,
|
||||
} from "./work-view";
|
||||
import { detailView, type DetailView } from "./detail-view";
|
||||
import { messageStreamRenderFamily } from "./render-family";
|
||||
import { messageStreamTextView, type MessageStreamTextActions, type MessageStreamTextView } from "./text-view";
|
||||
import { agentRunSummaryView, messageStreamStatusView, type AgentRunSummaryView, type MessageStreamStatusView } from "./status-view";
|
||||
import type { PendingRequestBlockSnapshot } from "../pending-requests/snapshot";
|
||||
|
||||
interface PendingRequestMessageStreamBlockInput {
|
||||
|
|
@ -34,6 +27,7 @@ export interface MessageStreamPresentationBlockInput {
|
|||
activeItems?: readonly MessageStreamItem[] | undefined;
|
||||
workspaceRoot?: string | null | undefined;
|
||||
turnDiffs?: ReadonlyMap<string, string> | undefined;
|
||||
textActionsByItemId?: ReadonlyMap<string, MessageStreamTextActions> | undefined;
|
||||
pendingRequests?: PendingRequestMessageStreamBlockInput | null | undefined;
|
||||
}
|
||||
|
||||
|
|
@ -80,12 +74,12 @@ export type MessageStreamRenderedItemView =
|
|||
view: MessageStreamTextView;
|
||||
}
|
||||
| {
|
||||
kind: "toolResult";
|
||||
view: ToolResultView;
|
||||
kind: "detail";
|
||||
view: DetailView;
|
||||
}
|
||||
| {
|
||||
kind: "work";
|
||||
view: MessageStreamWorkView;
|
||||
kind: "status";
|
||||
view: MessageStreamStatusView;
|
||||
};
|
||||
|
||||
export type MessageStreamActivityItemView =
|
||||
|
|
@ -203,7 +197,7 @@ function activeTurnLiveBlocks(
|
|||
|
||||
return semanticItems.flatMap((classification): MessageStreamPresentationBlock[] => {
|
||||
const { item } = classification;
|
||||
if (messageStreamIsTaskProgress(classification) && item.turnId === activeTurnId) {
|
||||
if (isLiveTaskProgressItem(item, activeTurnId)) {
|
||||
return [
|
||||
{
|
||||
kind: "liveTask",
|
||||
|
|
@ -219,6 +213,10 @@ function activeTurnLiveBlocks(
|
|||
});
|
||||
}
|
||||
|
||||
function isLiveTaskProgressItem(item: MessageStreamItem, activeTurnId: string): boolean {
|
||||
return item.kind === "taskProgress" && item.turnId === activeTurnId;
|
||||
}
|
||||
|
||||
function activeAgentRunSummaryAnchorId(items: readonly MessageStreamSemanticClassification[], activeTurnId: string): string | null {
|
||||
const firstActiveAgent = items.find(
|
||||
(classification) => messageStreamIsCoordinationProgress(classification) && classification.item.turnId === activeTurnId,
|
||||
|
|
@ -243,7 +241,7 @@ function messageStreamViewBlockFromPresentationBlock(
|
|||
if (block.kind === "pendingRequests") return block;
|
||||
if (block.kind === "liveAgentSummary") return { kind: "liveAgentSummary", key: block.key, view: agentRunSummaryView(block.summary) };
|
||||
if (block.kind === "liveTask") {
|
||||
return { kind: "work", key: block.key, view: messageStreamWorkView(block.item, workViewContext(input)) };
|
||||
return { kind: "status", key: block.key, view: messageStreamStatusView(block.item, statusViewContext(input)) };
|
||||
}
|
||||
if (block.kind === "activityGroup") {
|
||||
return {
|
||||
|
|
@ -272,16 +270,23 @@ function messageStreamRenderedItemView(
|
|||
annotations?: MessageStreamItemAnnotations,
|
||||
): MessageStreamRenderedItemView {
|
||||
const renderFamily = messageStreamRenderFamily(classification);
|
||||
if (renderFamily === "text") return { kind: "text", view: messageStreamTextView(textItemFromSemantic(classification), annotations) };
|
||||
if (renderFamily === "toolResult") {
|
||||
return { kind: "toolResult", view: toolResultView(toolResultItemFromSemantic(classification), input.workspaceRoot) };
|
||||
switch (renderFamily) {
|
||||
case "text":
|
||||
return {
|
||||
kind: "text",
|
||||
view: messageStreamTextView(classification.item, annotations, {
|
||||
activeTurnId: input.activeTurnId,
|
||||
...definedProp("actions", input.textActionsByItemId?.get(classification.item.id)),
|
||||
}),
|
||||
};
|
||||
case "detail":
|
||||
return { kind: "detail", view: detailView(classification.item, input.workspaceRoot) };
|
||||
case "status":
|
||||
return { kind: "status", view: messageStreamStatusView(classification.item, statusViewContext(input)) };
|
||||
}
|
||||
if (renderFamily === "work")
|
||||
return { kind: "work", view: messageStreamWorkView(workItemFromSemantic(classification), workViewContext(input)) };
|
||||
return unhandledClassification(classification);
|
||||
}
|
||||
|
||||
function workViewContext(input: MessageStreamPresentationBlockInput): Parameters<typeof messageStreamWorkView>[1] {
|
||||
function statusViewContext(input: MessageStreamPresentationBlockInput): Parameters<typeof messageStreamStatusView>[1] {
|
||||
return {
|
||||
activeTurnId: input.activeTurnId,
|
||||
items: input.items,
|
||||
|
|
@ -289,31 +294,6 @@ function workViewContext(input: MessageStreamPresentationBlockInput): Parameters
|
|||
};
|
||||
}
|
||||
|
||||
function textItemFromSemantic({ item }: MessageStreamSemanticClassification): TextMessageStreamItem {
|
||||
if (item.kind === "message" || item.kind === "system" || item.kind === "userInputResult") return item;
|
||||
throw new Error(`Message stream semantic expected text item: ${JSON.stringify(item)}`);
|
||||
}
|
||||
|
||||
function toolResultItemFromSemantic({ item }: MessageStreamSemanticClassification): ToolResultMessageStreamItem {
|
||||
if (
|
||||
item.kind === "command" ||
|
||||
item.kind === "fileChange" ||
|
||||
item.kind === "goal" ||
|
||||
item.kind === "tool" ||
|
||||
item.kind === "hook" ||
|
||||
item.kind === "approvalResult" ||
|
||||
item.kind === "reviewResult"
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
throw new Error(`Message stream semantic expected tool result item: ${JSON.stringify(item)}`);
|
||||
}
|
||||
|
||||
function workItemFromSemantic({ item }: MessageStreamSemanticClassification): WorkMessageStreamItem {
|
||||
if (item.kind === "taskProgress" || item.kind === "agent" || item.kind === "reasoning" || item.kind === "contextCompaction") return item;
|
||||
throw new Error(`Message stream semantic expected work item: ${JSON.stringify(item)}`);
|
||||
}
|
||||
|
||||
function unhandledClassification(classification: MessageStreamSemanticClassification): never {
|
||||
throw new Error(`Unhandled message stream classification: ${JSON.stringify(classification)}`);
|
||||
function definedProp<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
|
||||
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
import { shortThreadId, truncate } from "../../../../utils";
|
||||
import { agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel } from "./agent-summary";
|
||||
import type {
|
||||
AgentMessageStreamItem,
|
||||
AgentRunSummary,
|
||||
AgentRunSummaryAgent,
|
||||
AgentStateSummary,
|
||||
ContextCompactionMessageStreamItem,
|
||||
ExecutionState,
|
||||
MessageStreamItem,
|
||||
ReasoningMessageStreamItem,
|
||||
TaskProgressMessageStreamItem,
|
||||
} from "../../domain/message-stream/items";
|
||||
|
||||
const AGENT_ROW_MESSAGE_PREVIEW_LIMIT = 120;
|
||||
const AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT = 96;
|
||||
|
||||
export type WorkMessageStreamItem =
|
||||
| TaskProgressMessageStreamItem
|
||||
| AgentMessageStreamItem
|
||||
| ReasoningMessageStreamItem
|
||||
| ContextCompactionMessageStreamItem;
|
||||
|
||||
export type MessageStreamWorkView =
|
||||
| {
|
||||
kind: "taskProgress";
|
||||
item: TaskProgressMessageStreamItem;
|
||||
label: "tasks";
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
}
|
||||
| {
|
||||
kind: "agent";
|
||||
item: AgentMessageStreamItem;
|
||||
label: "agent";
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
summary: string;
|
||||
metaRows: readonly { key: string; value: string }[];
|
||||
prompt: string | null;
|
||||
agentRows: readonly { threadId: string; threadLabel: string; status: string }[];
|
||||
expandedMessages: readonly { threadId: string; threadLabel: string; message: string }[];
|
||||
}
|
||||
| {
|
||||
kind: "contextCompaction";
|
||||
item: ContextCompactionMessageStreamItem;
|
||||
label: "context";
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
summary: string;
|
||||
}
|
||||
| {
|
||||
kind: "reasoning";
|
||||
item: ReasoningMessageStreamItem;
|
||||
active: boolean;
|
||||
label: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export interface MessageStreamWorkViewContext {
|
||||
activeTurnId: string | null;
|
||||
items: readonly MessageStreamItem[];
|
||||
activeItems?: readonly MessageStreamItem[] | undefined;
|
||||
}
|
||||
|
||||
export interface AgentRunSummaryView {
|
||||
label: "agents";
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
summary: string;
|
||||
rows: readonly { threadId: string; threadLabel: string; status: string }[];
|
||||
additionalAgents: number;
|
||||
}
|
||||
|
||||
export function messageStreamWorkView(item: WorkMessageStreamItem, context: MessageStreamWorkViewContext): MessageStreamWorkView {
|
||||
if (item.kind === "taskProgress") {
|
||||
return { kind: "taskProgress", item, label: "tasks", className: "codex-panel__task-progress", state: item.executionState ?? null };
|
||||
}
|
||||
if (item.kind === "agent") return agentWorkView(item);
|
||||
if (item.kind === "contextCompaction") return contextCompactionWorkView(item, context);
|
||||
return reasoningWorkView(item, context);
|
||||
}
|
||||
|
||||
export function agentRunSummaryView(summary: AgentRunSummary): AgentRunSummaryView {
|
||||
return {
|
||||
label: "agents",
|
||||
className: "codex-panel__agent-summary",
|
||||
state: summary.failed > 0 ? "failed" : "running",
|
||||
summary: agentRunSummaryLabel(summary),
|
||||
rows: summary.agents.map(agentRunSummaryRow),
|
||||
additionalAgents: summary.additionalAgents,
|
||||
};
|
||||
}
|
||||
|
||||
function agentWorkView(item: AgentMessageStreamItem): MessageStreamWorkView {
|
||||
return {
|
||||
kind: "agent",
|
||||
item,
|
||||
label: "agent",
|
||||
className: "codex-panel__agent-activity",
|
||||
state: item.executionState ?? null,
|
||||
summary: agentSummaryText(item),
|
||||
metaRows: [
|
||||
{ key: "tool", value: agentActivityMetaLabel(item.tool) },
|
||||
{ key: "status", value: item.status },
|
||||
{ key: "sender", value: item.senderThreadId },
|
||||
...(item.receiverThreadIds.length > 0 ? [{ key: "target", value: item.receiverThreadIds.join(", ") }] : []),
|
||||
...(item.model ? [{ key: "model", value: item.model }] : []),
|
||||
...(item.reasoningEffort ? [{ key: "effort", value: item.reasoningEffort }] : []),
|
||||
],
|
||||
prompt: item.prompt,
|
||||
agentRows: item.agents.map(agentStatusRow),
|
||||
expandedMessages: item.agents.flatMap((agent) =>
|
||||
agent.message && isLongAgentMessage(agent.message)
|
||||
? [{ threadId: agent.threadId, threadLabel: shortThreadId(agent.threadId), message: agent.message }]
|
||||
: [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function contextCompactionWorkView(item: ContextCompactionMessageStreamItem, context: MessageStreamWorkViewContext): MessageStreamWorkView {
|
||||
const active = context.activeTurnId === item.turnId;
|
||||
return {
|
||||
kind: "contextCompaction",
|
||||
item,
|
||||
label: "context",
|
||||
className: "codex-panel__context-compaction",
|
||||
state: active ? "running" : "completed",
|
||||
summary: active ? "Compacting context..." : "Context compacted",
|
||||
};
|
||||
}
|
||||
|
||||
function reasoningWorkView(item: ReasoningMessageStreamItem, context: MessageStreamWorkViewContext): MessageStreamWorkView {
|
||||
const active = isReasoningActive(item, context);
|
||||
return {
|
||||
kind: "reasoning",
|
||||
item,
|
||||
active,
|
||||
label: active ? "reasoning" : "thought",
|
||||
text: item.text || (active ? "Reasoning" : "Thought"),
|
||||
};
|
||||
}
|
||||
|
||||
function agentSummaryText(item: AgentMessageStreamItem): string {
|
||||
const target = item.receiverThreadIds.length === 0 ? "" : ` ${item.receiverThreadIds.map(shortThreadId).join(", ")}`;
|
||||
const promptPreview = agentPromptPreview(item.prompt);
|
||||
return `${agentActivityMetaLabel(item.tool)}${target}${promptPreview ? `: ${promptPreview}` : ""} (${item.status})`;
|
||||
}
|
||||
|
||||
function agentPromptPreview(prompt: string | null): string | null {
|
||||
if (!prompt) return null;
|
||||
const normalized = prompt.trim().replace(/\s+/g, " ");
|
||||
return normalized ? truncate(normalized, AGENT_ACTIVITY_PROMPT_PREVIEW_LIMIT) : null;
|
||||
}
|
||||
|
||||
function agentStatusRow(agent: AgentStateSummary): { threadId: string; threadLabel: string; status: string } {
|
||||
return {
|
||||
threadId: agent.threadId,
|
||||
threadLabel: shortThreadId(agent.threadId),
|
||||
status: agentStatusLabel(agent.status, agent.message),
|
||||
};
|
||||
}
|
||||
|
||||
function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; threadLabel: string; status: string } {
|
||||
return {
|
||||
threadId: agent.threadId,
|
||||
threadLabel: shortThreadId(agent.threadId),
|
||||
status: agent.messagePreview ? `${agent.status}: ${agent.messagePreview}` : agent.status,
|
||||
};
|
||||
}
|
||||
|
||||
function agentStatusLabel(status: string, message: string | null): string {
|
||||
const preview = agentMessagePreview(message, AGENT_ROW_MESSAGE_PREVIEW_LIMIT);
|
||||
return preview ? `${status}: ${preview}` : status;
|
||||
}
|
||||
|
||||
function isLongAgentMessage(message: string): boolean {
|
||||
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
|
||||
}
|
||||
|
||||
function isReasoningActive(item: ReasoningMessageStreamItem, context: MessageStreamWorkViewContext): boolean {
|
||||
const activeTurn = context.activeTurnId;
|
||||
if (!activeTurn || item.turnId !== activeTurn) return false;
|
||||
if (item.executionState === "completed") return false;
|
||||
const latestActiveTurnItem = [...(context.activeItems ?? context.items)].reverse().find((candidate) => candidate.turnId === activeTurn);
|
||||
return latestActiveTurnItem?.id === item.id;
|
||||
}
|
||||
|
|
@ -2,8 +2,11 @@ import type { ComponentChild as UiNode } from "preact";
|
|||
|
||||
import type { ApprovalAction, PendingRequestId } from "../../domain/pending-requests/model";
|
||||
import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/snapshot";
|
||||
import type { MessageStreamItem } from "../../domain/message-stream/items";
|
||||
import type { TextMessageStreamItem } from "../../presentation/message-stream/text-view";
|
||||
import type {
|
||||
MessageStreamForkTarget,
|
||||
MessageStreamPlanImplementationTarget,
|
||||
MessageStreamRollbackTarget,
|
||||
} from "../../presentation/message-stream/text-view";
|
||||
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
|
||||
|
||||
export interface MessageStreamBlock {
|
||||
|
|
@ -11,21 +14,17 @@ export interface MessageStreamBlock {
|
|||
node: UiNode;
|
||||
}
|
||||
|
||||
export type { TextMessageStreamItem };
|
||||
|
||||
type MessageStreamDisclosureBucket =
|
||||
| "toolResults"
|
||||
| "details"
|
||||
| "activityGroups"
|
||||
| "agentDetails"
|
||||
| "textDetails"
|
||||
| "userMessageExpanded"
|
||||
| "goalObjectiveExpanded"
|
||||
| "approvalDetails";
|
||||
|
||||
export interface MessageStreamDisclosureState {
|
||||
toolResults: ReadonlySet<string>;
|
||||
details: ReadonlySet<string>;
|
||||
activityGroups: ReadonlySet<string>;
|
||||
agentDetails: ReadonlySet<string>;
|
||||
textDetails: ReadonlySet<string>;
|
||||
userMessageExpanded: ReadonlySet<string>;
|
||||
goalObjectiveExpanded: ReadonlySet<string>;
|
||||
|
|
@ -59,12 +58,9 @@ export interface TextItemActionContext extends TextItemDetailStateContext {
|
|||
forkActionsItemId: string | null;
|
||||
onForkActionsToggle?: (itemId: string | null) => void;
|
||||
copyText?: (text: string) => void;
|
||||
canImplementPlanItem?: (item: MessageStreamItem) => boolean;
|
||||
onImplementPlanItem?: (item: MessageStreamItem) => void;
|
||||
canRollbackItem?: (item: MessageStreamItem) => boolean;
|
||||
onRollbackItem?: (item: MessageStreamItem) => void;
|
||||
canForkItem?: (item: MessageStreamItem) => boolean;
|
||||
onForkItem?: (item: MessageStreamItem, archiveSource: boolean) => void;
|
||||
onImplementPlan?: (target: MessageStreamPlanImplementationTarget) => void;
|
||||
onRollback?: (target: MessageStreamRollbackTarget) => void;
|
||||
onFork?: (target: MessageStreamForkTarget, archiveSource: boolean) => void;
|
||||
}
|
||||
|
||||
export interface TextItemMetadataContext extends TextItemDetailStateContext {
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
import { useLayoutEffect, useRef } from "preact/hooks";
|
||||
|
||||
import { type ToolResultDetailSection, type ToolResultView } from "../../presentation/message-stream/tool-result-view";
|
||||
import { renderRawDiffLines } from "../../../../shared/diff/render";
|
||||
import { type DetailSection, type DetailView } from "../../presentation/message-stream/detail-view";
|
||||
import type { MessageStreamDisclosureState } from "./context";
|
||||
|
||||
export interface ToolResultRenderContext {
|
||||
export interface DetailRenderContext {
|
||||
disclosures: MessageStreamDisclosureState;
|
||||
onDisclosureToggle?: (bucket: "toolResults", id: string, open: boolean) => void;
|
||||
onDisclosureToggle?: (bucket: "details", id: string, open: boolean) => void;
|
||||
}
|
||||
|
||||
export function toolResultNode(view: ToolResultView, context: ToolResultRenderContext): UiNode {
|
||||
return <ToolResult view={view} context={context} />;
|
||||
export function detailNode(view: DetailView, context: DetailRenderContext): UiNode {
|
||||
return <Detail view={view} context={context} />;
|
||||
}
|
||||
|
||||
function ToolResult({ view, context }: { view: ToolResultView; context: ToolResultRenderContext }): UiNode {
|
||||
const open = context.disclosures.toolResults.has(view.detailsKey);
|
||||
function Detail({ view, context }: { view: DetailView; context: DetailRenderContext }): UiNode {
|
||||
const open = context.disclosures.details.has(view.detailsKey);
|
||||
const hasSummary = view.summary.trim().length > 0;
|
||||
|
||||
const className = [
|
||||
view.className,
|
||||
"codex-panel__tool-result",
|
||||
view.details.length === 0 ? "codex-panel__tool-result--plain" : "",
|
||||
"codex-panel__detail",
|
||||
view.sections.length === 0 ? "codex-panel__detail--plain" : "",
|
||||
view.state ? `codex-panel__execution codex-panel__execution--${view.state}` : "",
|
||||
open ? "is-open" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (view.details.length === 0) {
|
||||
if (view.sections.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ToolResultHeader view={view} />
|
||||
{hasSummary ? <ToolSummary text={view.summary} /> : null}
|
||||
<DetailHeader view={view} />
|
||||
{hasSummary ? <DetailSummary text={view.summary} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,40 +40,40 @@ function ToolResult({ view, context }: { view: ToolResultView; context: ToolResu
|
|||
return (
|
||||
<div className={className}>
|
||||
<details
|
||||
className="codex-panel__tool-result-details"
|
||||
className="codex-panel__detail-disclosure"
|
||||
open={open}
|
||||
onToggle={(event) => {
|
||||
context.onDisclosureToggle?.("toolResults", view.detailsKey, event.currentTarget.open);
|
||||
context.onDisclosureToggle?.("details", view.detailsKey, event.currentTarget.open);
|
||||
}}
|
||||
>
|
||||
<ToolResultHeader view={view} />
|
||||
{view.details.map((section, index) => (
|
||||
<ToolResultDetailSection key={`${section.kind}:${section.title ?? ""}:${String(index)}`} section={section} />
|
||||
<DetailHeader view={view} />
|
||||
{view.sections.map((section, index) => (
|
||||
<DetailSectionView key={`${section.kind}:${section.title ?? ""}:${String(index)}`} section={section} />
|
||||
))}
|
||||
</details>
|
||||
{hasSummary ? <ToolSummary text={view.summary} /> : null}
|
||||
{hasSummary ? <DetailSummary text={view.summary} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolResultHeader({ view }: { view: ToolResultView }): UiNode {
|
||||
const content = <span className="codex-panel__message-role codex-panel__tool-result-label">{view.label}</span>;
|
||||
return view.details.length > 0 ? (
|
||||
<summary className="codex-panel__tool-result-header" tabIndex={-1}>
|
||||
function DetailHeader({ view }: { view: DetailView }): UiNode {
|
||||
const content = <span className="codex-panel__message-role codex-panel__detail-label">{view.label}</span>;
|
||||
return view.sections.length > 0 ? (
|
||||
<summary className="codex-panel__detail-header" tabIndex={-1}>
|
||||
{content}
|
||||
</summary>
|
||||
) : (
|
||||
<div className="codex-panel__tool-result-header">{content}</div>
|
||||
<div className="codex-panel__detail-header">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolSummary({ text }: { text: string }): UiNode {
|
||||
return <div className="codex-panel__tool-summary">{text}</div>;
|
||||
function DetailSummary({ text }: { text: string }): UiNode {
|
||||
return <div className="codex-panel__stream-summary">{text}</div>;
|
||||
}
|
||||
|
||||
function ToolResultDetailSection({ section }: { section: ToolResultDetailSection }): UiNode {
|
||||
if (section.kind === "meta") {
|
||||
return <MetaBlock title={section.title} rows={section.rows} />;
|
||||
function DetailSectionView({ section }: { section: DetailSection }): UiNode {
|
||||
if (section.kind === "kv") {
|
||||
return <KeyValueBlock title={section.title} rows={section.rows} />;
|
||||
}
|
||||
if (section.kind === "diff") {
|
||||
return (
|
||||
|
|
@ -85,7 +85,7 @@ function ToolResultDetailSection({ section }: { section: ToolResultDetailSection
|
|||
return <OutputBlock title={section.title} body={section.body} />;
|
||||
}
|
||||
|
||||
function MetaBlock({
|
||||
function KeyValueBlock({
|
||||
title,
|
||||
rows,
|
||||
}: {
|
||||
|
|
@ -95,7 +95,7 @@ function MetaBlock({
|
|||
const body = (
|
||||
<dl className="codex-panel__meta-grid">
|
||||
{rows.map((row) => (
|
||||
<FragmentPair key={`${row.key}\n${row.value}`} row={row} />
|
||||
<KeyValuePair key={`${row.key}\n${row.value}`} row={row} />
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
|
|
@ -108,7 +108,7 @@ function MetaBlock({
|
|||
);
|
||||
}
|
||||
|
||||
function FragmentPair({ row }: { row: { key: string; value: string } }): UiNode {
|
||||
function KeyValuePair({ row }: { row: { key: string; value: string } }): UiNode {
|
||||
return (
|
||||
<>
|
||||
<dt>{row.key}</dt>
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type PendingUserInputViewModel,
|
||||
} from "../../presentation/pending-requests/view-model";
|
||||
import type { PendingRequestBlockActions } from "./context";
|
||||
import { createWorkMessageClassName } from "./work-message";
|
||||
import { createStatusMessageClassName } from "./status-message";
|
||||
|
||||
export function pendingRequestBlockNode(
|
||||
approvals: readonly PendingApprovalViewModel[],
|
||||
|
|
@ -61,7 +61,7 @@ function PendingRequestBlock({
|
|||
}, [autoFocusRequested, consumeAutoFocus, autoFocusSignature]);
|
||||
if (approvals.length === 0 && pendingUserInputs.length === 0) return null;
|
||||
return (
|
||||
<div ref={requestRef} className={createWorkMessageClassName("codex-panel__pending-request-block", "warning")}>
|
||||
<div ref={requestRef} className={createStatusMessageClassName("codex-panel__pending-request-block", "warning")}>
|
||||
<div className="codex-panel__message-role">Request</div>
|
||||
{approvals.map((approval) => (
|
||||
<ApprovalCard key={String(approval.requestId)} approval={approval} approvalDetails={approvalDetails} actions={actions} />
|
||||
|
|
|
|||
127
src/features/chat/ui/message-stream/status-items.tsx
Normal file
127
src/features/chat/ui/message-stream/status-items.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
|
||||
import type { ExecutionState } from "../../domain/message-stream/items";
|
||||
import type { AgentRunSummaryView, MessageStreamStatusView, StatusChecklistItem } from "../../presentation/message-stream/status-view";
|
||||
import { createStatusMessageClassName } from "./status-message";
|
||||
|
||||
export function agentRunSummaryNode(view: AgentRunSummaryView): UiNode {
|
||||
return <AgentRunSummaryItem view={view} />;
|
||||
}
|
||||
|
||||
export function statusItemNode(view: MessageStreamStatusView): UiNode {
|
||||
if (view.kind === "taskProgress") return <TaskProgressItem view={view} />;
|
||||
if (view.kind === "contextCompaction") return <ContextCompactionItem view={view} />;
|
||||
if (view.kind === "reasoning") return <ReasoningItem view={view} />;
|
||||
return <GenericStatusItem view={view} />;
|
||||
}
|
||||
|
||||
function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode {
|
||||
return (
|
||||
<StatusMessage label={view.label} className={view.className} state={view.state}>
|
||||
<div className="codex-panel__stream-summary">{view.summary}</div>
|
||||
<AgentSummaryRows view={view} />
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskProgressItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "taskProgress" }> }): UiNode {
|
||||
return (
|
||||
<StatusMessage label={view.label} className={view.className} state={view.state}>
|
||||
{view.summary ? <div className="codex-panel__stream-summary">{view.summary}</div> : null}
|
||||
{view.checklist.length === 0 ? (
|
||||
<div className="codex-panel__stream-summary">Plan updated</div>
|
||||
) : (
|
||||
<ul className="codex-panel__task-list">
|
||||
{view.checklist.map((step) => (
|
||||
<li key={`${step.status}\n${step.step}`} className={`codex-panel__task-step codex-panel__task-step--${step.status}`}>
|
||||
<span className="codex-panel__task-marker">{taskStatusMarker(step.status)}</span>
|
||||
<span className="codex-panel__task-text">{step.step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function taskStatusMarker(status: StatusChecklistItem["status"]): string {
|
||||
if (status === "completed") return "[x]";
|
||||
if (status === "inProgress") return "[>]";
|
||||
return "[ ]";
|
||||
}
|
||||
|
||||
function ContextCompactionItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "contextCompaction" }> }): UiNode {
|
||||
return (
|
||||
<StatusMessage label={view.label} className={view.className} state={view.state}>
|
||||
<div className="codex-panel__stream-summary">{view.text}</div>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericStatusItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "generic" }> }): UiNode {
|
||||
return (
|
||||
<StatusMessage label={view.label} className={view.className} state={view.state}>
|
||||
<div className="codex-panel__stream-summary">{view.text}</div>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningItem({ view }: { view: Extract<MessageStreamStatusView, { kind: "reasoning" }> }): UiNode {
|
||||
return (
|
||||
<div className={`codex-panel__reasoning${view.active ? " is-active" : ""}`}>
|
||||
<div className="codex-panel__reasoning-role">{view.label}</div>
|
||||
<div className="codex-panel__reasoning-content">
|
||||
<span>{view.text}</span>
|
||||
{view.active ? (
|
||||
<span className="codex-panel__reasoning-dots">
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusMessage({
|
||||
label,
|
||||
className,
|
||||
state,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
children: UiNode;
|
||||
}): UiNode {
|
||||
const classes = [createStatusMessageClassName(className), state ? `codex-panel__execution codex-panel__execution--${state}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className="codex-panel__message-role">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSummaryRows({ view }: { view: AgentRunSummaryView }): UiNode {
|
||||
if (view.rows.length === 0 && view.additionalAgents === 0) return null;
|
||||
return (
|
||||
<ul className="codex-panel__agent-list codex-panel__agent-list--summary">
|
||||
{view.rows.map((agent) => (
|
||||
<li key={agent.threadId} className="codex-panel__agent-row">
|
||||
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
|
||||
<span className="codex-panel__agent-status">{agent.status}</span>
|
||||
</li>
|
||||
))}
|
||||
{view.additionalAgents > 0 ? (
|
||||
<li className="codex-panel__agent-row codex-panel__agent-row--more">
|
||||
<span className="codex-panel__agent-thread" />
|
||||
<span className="codex-panel__agent-status">+{String(view.additionalAgents)} more</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
11
src/features/chat/ui/message-stream/status-message.ts
Normal file
11
src/features/chat/ui/message-stream/status-message.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export function createStatusMessageClassName(className: string, tone?: "warning"): string {
|
||||
return [
|
||||
"codex-panel__message",
|
||||
"codex-panel__message--tool",
|
||||
"codex-panel__status-message",
|
||||
className,
|
||||
tone ? `codex-panel__status-message--${tone}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -6,15 +6,15 @@ import {
|
|||
type MessageStreamViewBlock,
|
||||
} from "../../presentation/message-stream/view-model";
|
||||
import { pendingRequestBlockNode } from "./pending-request-block";
|
||||
import { toolResultNode } from "./tool-result";
|
||||
import { agentRunSummaryNode, workItemNode } from "./work-items";
|
||||
import { detailNode } from "./detail";
|
||||
import { agentRunSummaryNode, statusItemNode } from "./status-items";
|
||||
import type { MessageStreamBlock, MessageStreamContext, PendingRequestBlockContext } from "./context";
|
||||
import { textItemNode } from "./text-item";
|
||||
|
||||
function streamItemNode(item: MessageStreamRenderedItemView, context: MessageStreamContext): UiNode {
|
||||
if (item.kind === "text") return textItemNode(item.view, context);
|
||||
if (item.kind === "toolResult") return toolResultNode(item.view, context);
|
||||
return workItemNode(item.view, context);
|
||||
if (item.kind === "detail") return detailNode(item.view, context);
|
||||
return statusItemNode(item.view);
|
||||
}
|
||||
|
||||
export function messageStreamBlocks(viewBlocks: readonly MessageStreamViewBlock[], context: MessageStreamContext): MessageStreamBlock[] {
|
||||
|
|
@ -110,11 +110,11 @@ function ActivityGroup({
|
|||
|
||||
function SteeringActivity({ activity }: { activity: Extract<MessageStreamActivityItemView, { type: "steering" }> }): UiNode {
|
||||
return (
|
||||
<div className="codex-panel__message codex-panel__message--tool codex-panel__tool-item codex-panel__tool-result codex-panel__tool-result--plain">
|
||||
<div className="codex-panel__tool-result-header">
|
||||
<span className="codex-panel__message-role codex-panel__tool-result-label">{activity.label}</span>
|
||||
<div className="codex-panel__message codex-panel__message--tool codex-panel__detail-item codex-panel__detail codex-panel__detail--plain">
|
||||
<div className="codex-panel__detail-header">
|
||||
<span className="codex-panel__message-role codex-panel__detail-label">{activity.label}</span>
|
||||
</div>
|
||||
<div className="codex-panel__tool-summary">{activity.text}</div>
|
||||
<div className="codex-panel__stream-summary">{activity.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { type ComponentChild as UiNode } from "preact";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import type { MessageStreamItem } from "../../domain/message-stream/items";
|
||||
import type { MessageStreamTextView } from "../../presentation/message-stream/text-view";
|
||||
import { IconButton } from "../../../../shared/ui/components";
|
||||
import { listenDomEvent } from "../../../../shared/ui/dom-events";
|
||||
import type { TextItemActionContext, TextMessageStreamItem } from "./context";
|
||||
import type { TextItemActionContext } from "./context";
|
||||
|
||||
export function TextItemHeader({ item, context }: { item: TextMessageStreamItem; context: TextItemActionContext }): UiNode {
|
||||
const forkActionsOpen = context.forkActionsItemId === item.id;
|
||||
export function TextItemHeader({ view, context }: { view: MessageStreamTextView; context: TextItemActionContext }): UiNode {
|
||||
const forkActionsOpen = context.forkActionsItemId === view.id;
|
||||
const roleRef = useRef<HTMLDivElement | null>(null);
|
||||
const { fork, implementPlan, rollback } = view.actions;
|
||||
|
||||
useEffect(() => {
|
||||
if (!forkActionsOpen) return;
|
||||
|
|
@ -22,32 +23,32 @@ export function TextItemHeader({ item, context }: { item: TextMessageStreamItem;
|
|||
}, [context, forkActionsOpen]);
|
||||
|
||||
const copyAction =
|
||||
item.kind === "message" && context.copyText && isMessageCopyActionVisible(item, context) && !forkActionsOpen ? (
|
||||
view.copyText !== undefined && context.copyText && !forkActionsOpen ? (
|
||||
<TextItemAction
|
||||
icon="copy"
|
||||
label="Copy message"
|
||||
className="codex-panel__copy-message"
|
||||
onClick={() => context.copyText?.(item.copyText ?? item.text)}
|
||||
onClick={() => context.copyText?.(view.copyText ?? "")}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div ref={roleRef} className={`codex-panel__message-role${forkActionsOpen ? " codex-panel__message-role--fork-open" : ""}`}>
|
||||
<span>{displayRoleLabel(item)}</span>
|
||||
{forkActionsOpen && context.canForkItem?.(item) ? (
|
||||
<span>{view.roleLabel}</span>
|
||||
{forkActionsOpen && fork ? (
|
||||
<TextItemAction
|
||||
icon="archive"
|
||||
label="Fork and archive"
|
||||
className="codex-panel__fork-and-archive-message"
|
||||
onClick={() => {
|
||||
context.onForkActionsToggle?.(null);
|
||||
context.onForkItem?.(item, true);
|
||||
context.onFork?.(fork, true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
copyAction
|
||||
)}
|
||||
{context.canForkItem?.(item) ? (
|
||||
{fork ? (
|
||||
<TextItemAction
|
||||
icon={forkActionsOpen ? "file-plus-corner" : "lucide-split"}
|
||||
label={forkActionsOpen ? "Fork" : "Fork from here"}
|
||||
|
|
@ -55,27 +56,27 @@ export function TextItemHeader({ item, context }: { item: TextMessageStreamItem;
|
|||
onClick={() => {
|
||||
if (forkActionsOpen) {
|
||||
context.onForkActionsToggle?.(null);
|
||||
context.onForkItem?.(item, false);
|
||||
context.onFork?.(fork, false);
|
||||
} else {
|
||||
context.onForkActionsToggle?.(item.id);
|
||||
context.onForkActionsToggle?.(view.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{context.canImplementPlanItem?.(item) ? (
|
||||
{implementPlan ? (
|
||||
<TextItemAction
|
||||
icon="play"
|
||||
label="Implement plan"
|
||||
className="codex-panel__implement-plan"
|
||||
onClick={() => context.onImplementPlanItem?.(item)}
|
||||
onClick={() => context.onImplementPlan?.(implementPlan)}
|
||||
/>
|
||||
) : null}
|
||||
{context.canRollbackItem?.(item) ? (
|
||||
{rollback ? (
|
||||
<TextItemAction
|
||||
icon="undo-2"
|
||||
label="Rollback last turn"
|
||||
className="codex-panel__rollback-turn"
|
||||
onClick={() => context.onRollbackItem?.(item)}
|
||||
onClick={() => context.onRollback?.(rollback)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -106,16 +107,3 @@ function TextItemAction({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function displayRoleLabel(item: MessageStreamItem): string {
|
||||
if (item.kind === "userInputResult") return "Input";
|
||||
if (item.role === "user") return "You";
|
||||
if (item.role === "assistant") return "Codex";
|
||||
return "System";
|
||||
}
|
||||
|
||||
function isMessageCopyActionVisible(item: MessageStreamItem, context: Pick<TextItemActionContext, "turnLifecycle">): boolean {
|
||||
if (item.kind !== "message" || item.copyText === undefined) return false;
|
||||
const activeTurn = context.turnLifecycle.kind === "running" ? context.turnLifecycle.turnId : null;
|
||||
return !(activeTurn && item.role === "assistant" && item.turnId === activeTurn);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function TextItem({ view, context }: { view: MessageStreamTextView; context: Tex
|
|||
const { item } = view;
|
||||
return (
|
||||
<div className={view.className}>
|
||||
<TextItemHeader item={item} context={context} />
|
||||
<TextItemHeader view={view} context={context} />
|
||||
{view.collapsible ? (
|
||||
<CollapsibleTextItemContent view={view} context={context} />
|
||||
) : (
|
||||
|
|
@ -69,7 +69,7 @@ function CollapsibleTextItemContent({ view, context }: { view: MessageStreamText
|
|||
return () => {
|
||||
content.removeEventListener(MESSAGE_CONTENT_RENDERED_EVENT, update);
|
||||
};
|
||||
}, [item.id, item.text, view.contentMode]);
|
||||
}, [item.id, view.body, view.contentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overflows || !expanded) return;
|
||||
|
|
@ -121,8 +121,7 @@ interface TextContentProps {
|
|||
|
||||
function TextContent({ view, context, contentRef, collapsed = false }: TextContentProps): UiNode {
|
||||
const rendersMarkdown = view.contentMode === "markdown";
|
||||
const { item } = view;
|
||||
const text = item.text;
|
||||
const text = view.body;
|
||||
const localRef = useRef<HTMLDivElement | null>(null);
|
||||
const contextRef = useRef(context);
|
||||
useLayoutEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
|
||||
import type { ExecutionState, TaskProgressMessageStreamItem } from "../../domain/message-stream/items";
|
||||
import type { AgentRunSummaryView, MessageStreamWorkView } from "../../presentation/message-stream/work-view";
|
||||
import type { MessageStreamDisclosureState } from "./context";
|
||||
import { createWorkMessageClassName } from "./work-message";
|
||||
|
||||
export interface WorkItemContext {
|
||||
disclosures: MessageStreamDisclosureState;
|
||||
onDisclosureToggle?: (bucket: "agentDetails", id: string, open: boolean) => void;
|
||||
}
|
||||
|
||||
export function agentRunSummaryNode(view: AgentRunSummaryView): UiNode {
|
||||
return <AgentRunSummaryItem view={view} />;
|
||||
}
|
||||
|
||||
export function workItemNode(view: MessageStreamWorkView, context: WorkItemContext): UiNode {
|
||||
if (view.kind === "taskProgress") return <TaskProgressItem view={view} />;
|
||||
if (view.kind === "agent") return <AgentItem view={view} context={context} />;
|
||||
if (view.kind === "contextCompaction") return <ContextCompactionItem view={view} />;
|
||||
return <ReasoningItem view={view} />;
|
||||
}
|
||||
|
||||
function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode {
|
||||
return (
|
||||
<WorkMessage label={view.label} className={view.className} state={view.state}>
|
||||
<div className="codex-panel__tool-summary">{view.summary}</div>
|
||||
<AgentSummaryRows view={view} />
|
||||
</WorkMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskProgressItem({ view }: { view: Extract<MessageStreamWorkView, { kind: "taskProgress" }> }): UiNode {
|
||||
const { item } = view;
|
||||
return (
|
||||
<WorkMessage label={view.label} className={view.className} state={view.state}>
|
||||
{item.explanation ? <div className="codex-panel__tool-summary">{item.explanation}</div> : null}
|
||||
{item.steps.length === 0 ? (
|
||||
<div className="codex-panel__tool-summary">Plan updated</div>
|
||||
) : (
|
||||
<ul className="codex-panel__task-list">
|
||||
{item.steps.map((step) => (
|
||||
<li key={`${step.status}\n${step.step}`} className={`codex-panel__task-step codex-panel__task-step--${step.status}`}>
|
||||
<span className="codex-panel__task-marker">{taskStatusMarker(step.status)}</span>
|
||||
<span className="codex-panel__task-text">{step.step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</WorkMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function taskStatusMarker(status: TaskProgressMessageStreamItem["steps"][number]["status"]): string {
|
||||
if (status === "completed") return "[x]";
|
||||
if (status === "inProgress") return "[>]";
|
||||
return "[ ]";
|
||||
}
|
||||
|
||||
function ContextCompactionItem({ view }: { view: Extract<MessageStreamWorkView, { kind: "contextCompaction" }> }): UiNode {
|
||||
return (
|
||||
<WorkMessage label={view.label} className={view.className} state={view.state}>
|
||||
<div className="codex-panel__tool-summary">{view.summary}</div>
|
||||
</WorkMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentItem({ view, context }: { view: Extract<MessageStreamWorkView, { kind: "agent" }>; context: WorkItemContext }): UiNode {
|
||||
const { item } = view;
|
||||
const detailsOpen = context.disclosures.agentDetails.has(item.id);
|
||||
return (
|
||||
<WorkMessage label={view.label} className={`${view.className}${detailsOpen ? " is-open" : ""}`} state={view.state}>
|
||||
<div className="codex-panel__tool-summary codex-panel__agent-activity-summary">{view.summary}</div>
|
||||
<details
|
||||
className="codex-panel__output codex-panel__agent-details"
|
||||
open={detailsOpen}
|
||||
onToggle={(event) => {
|
||||
context.onDisclosureToggle?.("agentDetails", item.id, event.currentTarget.open);
|
||||
}}
|
||||
>
|
||||
<summary tabIndex={-1}>Details</summary>
|
||||
<dl className="codex-panel__meta-grid">
|
||||
{view.metaRows.map((row) => (
|
||||
<MetaPair key={`${row.key}\n${row.value}`} name={row.key} value={row.value} />
|
||||
))}
|
||||
</dl>
|
||||
{view.prompt ? (
|
||||
<section className="codex-panel__agent-detail-section">
|
||||
<div className="codex-panel__output-title">Prompt</div>
|
||||
<pre>{view.prompt}</pre>
|
||||
</section>
|
||||
) : null}
|
||||
{view.agentRows.length > 0 ? (
|
||||
<ul className="codex-panel__agent-list">
|
||||
{view.agentRows.map((agent) => (
|
||||
<li key={agent.threadId} className="codex-panel__agent-row">
|
||||
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
|
||||
<span className="codex-panel__agent-status">{agent.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{view.expandedMessages.map((agent) => (
|
||||
<section key={agent.threadId} className="codex-panel__agent-detail-section">
|
||||
<div className="codex-panel__output-title">Agent output {agent.threadLabel}</div>
|
||||
<pre>{agent.message}</pre>
|
||||
</section>
|
||||
))}
|
||||
</details>
|
||||
</WorkMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningItem({ view }: { view: Extract<MessageStreamWorkView, { kind: "reasoning" }> }): UiNode {
|
||||
return (
|
||||
<div className={`codex-panel__reasoning${view.active ? " is-active" : ""}`}>
|
||||
<div className="codex-panel__reasoning-role">{view.label}</div>
|
||||
<div className="codex-panel__reasoning-content">
|
||||
<span>{view.text}</span>
|
||||
{view.active ? (
|
||||
<span className="codex-panel__reasoning-dots">
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkMessage({
|
||||
label,
|
||||
className,
|
||||
state,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className: string;
|
||||
state: ExecutionState;
|
||||
children: UiNode;
|
||||
}): UiNode {
|
||||
const classes = [createWorkMessageClassName(className), state ? `codex-panel__execution codex-panel__execution--${state}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className="codex-panel__message-role">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaPair({ name, value }: { name: string; value: string }): UiNode {
|
||||
return (
|
||||
<>
|
||||
<dt>{name}</dt>
|
||||
<dd>{value}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSummaryRows({ view }: { view: AgentRunSummaryView }): UiNode {
|
||||
if (view.rows.length === 0 && view.additionalAgents === 0) return null;
|
||||
return (
|
||||
<ul className="codex-panel__agent-list codex-panel__agent-list--summary">
|
||||
{view.rows.map((agent) => (
|
||||
<li key={agent.threadId} className="codex-panel__agent-row">
|
||||
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
|
||||
<span className="codex-panel__agent-status">{agent.status}</span>
|
||||
</li>
|
||||
))}
|
||||
{view.additionalAgents > 0 ? (
|
||||
<li className="codex-panel__agent-row codex-panel__agent-row--more">
|
||||
<span className="codex-panel__agent-thread" />
|
||||
<span className="codex-panel__agent-status">+{String(view.additionalAgents)} more</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export function createWorkMessageClassName(className: string, tone?: "warning"): string {
|
||||
return [
|
||||
"codex-panel__message",
|
||||
"codex-panel__message--tool",
|
||||
"codex-panel__work-message",
|
||||
className,
|
||||
tone ? `codex-panel__work-message--${tone}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
}
|
||||
|
||||
.codex-panel__activity-group > .codex-panel__message,
|
||||
.codex-panel__activity-group > .codex-panel__work-message,
|
||||
.codex-panel__activity-group > .codex-panel__status-message,
|
||||
.codex-panel__activity-group > .codex-panel__reasoning {
|
||||
margin-top: var(--codex-panel-section-gap);
|
||||
margin-bottom: var(--codex-panel-section-gap);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.codex-panel__work-message {
|
||||
.codex-panel__status-message {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-bottom: var(--codex-panel-section-gap);
|
||||
|
|
@ -9,21 +9,20 @@
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
.codex-panel__work-message .codex-panel__message-role {
|
||||
.codex-panel__status-message .codex-panel__message-role {
|
||||
font-size: var(--codex-panel-secondary-label-size);
|
||||
font-weight: var(--codex-panel-secondary-label-weight);
|
||||
}
|
||||
|
||||
.codex-panel__work-message--warning {
|
||||
.codex-panel__status-message--warning {
|
||||
border-left-color: var(--codex-panel-color-warning);
|
||||
}
|
||||
|
||||
.codex-panel__work-message--warning > .codex-panel__message-role {
|
||||
.codex-panel__status-message--warning > .codex-panel__message-role {
|
||||
color: var(--codex-panel-color-warning);
|
||||
}
|
||||
|
||||
.codex-panel__tool-item,
|
||||
.codex-panel__file-change {
|
||||
.codex-panel__detail-item {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-bottom: var(--codex-panel-section-gap);
|
||||
|
|
@ -34,8 +33,7 @@
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
.codex-panel__tool-item .codex-panel__message-role,
|
||||
.codex-panel__file-change .codex-panel__message-role {
|
||||
.codex-panel__detail-item .codex-panel__message-role {
|
||||
font-size: var(--codex-panel-secondary-label-size);
|
||||
font-weight: var(--codex-panel-secondary-label-weight);
|
||||
}
|
||||
|
|
@ -52,11 +50,11 @@
|
|||
border-left-color: var(--codex-panel-color-danger);
|
||||
}
|
||||
|
||||
.codex-panel__tool-item--hook:not(.codex-panel__execution) {
|
||||
.codex-panel__detail-item--hook:not(.codex-panel__execution) {
|
||||
border-left-color: var(--text-accent);
|
||||
}
|
||||
|
||||
.codex-panel__tool-result-header {
|
||||
.codex-panel__detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
|
|
@ -64,34 +62,34 @@
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
.codex-panel__tool-result--plain .codex-panel__tool-result-header {
|
||||
.codex-panel__detail--plain .codex-panel__detail-header {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.codex-panel__tool-result-details > .codex-panel__tool-result-header:hover,
|
||||
.codex-panel__tool-result-details > .codex-panel__tool-result-header:hover .codex-panel__message-role {
|
||||
.codex-panel__detail-disclosure > .codex-panel__detail-header:hover,
|
||||
.codex-panel__detail-disclosure > .codex-panel__detail-header:hover .codex-panel__message-role {
|
||||
color: var(--nav-item-color-hover, var(--text-normal));
|
||||
}
|
||||
|
||||
.codex-panel__tool-result-header .codex-panel__message-role {
|
||||
.codex-panel__detail-header .codex-panel__message-role {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.codex-panel__tool-result > .codex-panel__output,
|
||||
.codex-panel__tool-result > .codex-panel-diff-file,
|
||||
.codex-panel__tool-result > .codex-panel__meta-grid,
|
||||
.codex-panel__tool-result-details > .codex-panel__output,
|
||||
.codex-panel__tool-result-details > .codex-panel-diff-file,
|
||||
.codex-panel__tool-result-details > .codex-panel__meta-grid {
|
||||
.codex-panel__detail > .codex-panel__output,
|
||||
.codex-panel__detail > .codex-panel-diff-file,
|
||||
.codex-panel__detail > .codex-panel__meta-grid,
|
||||
.codex-panel__detail-disclosure > .codex-panel__output,
|
||||
.codex-panel__detail-disclosure > .codex-panel-diff-file,
|
||||
.codex-panel__detail-disclosure > .codex-panel__meta-grid {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.codex-panel__tool-result-details {
|
||||
.codex-panel__detail-disclosure {
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.codex-panel__tool-summary {
|
||||
.codex-panel__stream-summary {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-muted);
|
||||
|
|
@ -100,7 +98,7 @@
|
|||
user-select: text;
|
||||
}
|
||||
|
||||
.codex-panel__tool-result > .codex-panel__tool-summary {
|
||||
.codex-panel__detail > .codex-panel__stream-summary {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-top: var(--codex-panel-panel-gap);
|
||||
|
|
@ -110,7 +108,7 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codex-panel__tool-result.is-open > .codex-panel__tool-summary {
|
||||
.codex-panel__detail.is-open > .codex-panel__stream-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -155,27 +153,6 @@
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.codex-panel__agent-activity > .codex-panel__agent-activity-summary {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
line-height: var(--line-height-tight);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codex-panel__agent-activity.is-open > .codex-panel__agent-activity-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.codex-panel__agent-detail-section {
|
||||
margin-top: var(--codex-panel-section-gap);
|
||||
}
|
||||
|
||||
.codex-panel__agent-detail-section > pre {
|
||||
margin: var(--codex-panel-item-gap) 0 0;
|
||||
}
|
||||
|
||||
.codex-panel__agent-list--summary {
|
||||
margin-top: var(--codex-panel-panel-gap);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"23-chat-goal.css",
|
||||
"30-chat-layout.css",
|
||||
"31-chat-messages.css",
|
||||
"32-chat-work-items.css",
|
||||
"32-chat-stream-items.css",
|
||||
"33-chat-pending-requests.css",
|
||||
"34-chat-composer.css",
|
||||
"35-chat-turn-diff-view.css",
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ describe("implementPlan", () => {
|
|||
resumeThread(stateStore, [plan]);
|
||||
stateStore.dispatch({ type: "ui/panel-set", panel: "status-panel" });
|
||||
|
||||
await implementPlan(host, plan);
|
||||
await implementPlan(host, plan.id);
|
||||
|
||||
expect(ensureConnected).toHaveBeenCalledOnce();
|
||||
expect(requestDefaultCollaborationModeForNextTurn).toHaveBeenCalledOnce();
|
||||
|
|
@ -108,7 +108,7 @@ describe("implementPlan", () => {
|
|||
const latest = planItem("latest");
|
||||
resumeThread(stateStore, [first, latest]);
|
||||
|
||||
await implementPlan(host, first);
|
||||
await implementPlan(host, first.id);
|
||||
|
||||
expect(ensureConnected).not.toHaveBeenCalled();
|
||||
expect(sendTurnText).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -811,7 +811,7 @@ describe("turn item conversion preserves app-server semantics", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves context compaction items as short work items", () => {
|
||||
it("preserves context compaction items as short status items", () => {
|
||||
const item: TurnItem = { type: "contextCompaction", id: "compact-1" };
|
||||
|
||||
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
|
||||
|
|
@ -897,7 +897,7 @@ describe("permission detail rows", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("display block grouping keeps work logs subordinate to conversation messages", () => {
|
||||
describe("display block grouping keeps message stream details subordinate to conversation messages", () => {
|
||||
it("groups completed turn activities before the final assistant message", () => {
|
||||
const items: MessageStreamItem[] = [
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "do it", turnId: "t1" },
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ describe("message stream presentation blocks", () => {
|
|||
items: [userMessage("u1", "turn"), taskProgressItem("task", "turn"), assistantMessage("a1", "turn")],
|
||||
});
|
||||
|
||||
expect(blocks.map((block) => block.kind)).toEqual(["text", "text", "work"]);
|
||||
expect(blocks.find((block) => block.key === "live-task:task")).toMatchObject({ kind: "work" });
|
||||
expect(blocks.map((block) => block.kind)).toEqual(["text", "text", "status"]);
|
||||
expect(blocks.find((block) => block.key === "live-task:task")).toMatchObject({ kind: "status" });
|
||||
});
|
||||
|
||||
it("anchors active agent summaries at the first active agent item", () => {
|
||||
|
|
@ -38,9 +38,40 @@ describe("message stream presentation blocks", () => {
|
|||
items: [userMessage("u1", "turn"), agentItem("agent", "turn")],
|
||||
});
|
||||
|
||||
expect(blocks.map((block) => block.kind)).toEqual(["text", "work", "liveAgentSummary"]);
|
||||
expect(blocks.map((block) => block.kind)).toEqual(["text", "detail", "liveAgentSummary"]);
|
||||
expect(blocks.find((block) => block.kind === "liveAgentSummary")).toMatchObject({ key: "live-agents:turn" });
|
||||
});
|
||||
|
||||
it("renders unknown item kinds as generic status updates", () => {
|
||||
const blocks = messageStreamViewBlocks({
|
||||
activeThreadId: "thread",
|
||||
activeTurnId: null,
|
||||
historyCursor: null,
|
||||
loadingHistory: false,
|
||||
items: [
|
||||
{
|
||||
id: "unknown",
|
||||
kind: "futureKind",
|
||||
role: "tool",
|
||||
status: "running",
|
||||
output: "raw output",
|
||||
operation: "future operation",
|
||||
} as unknown as MessageStreamItem,
|
||||
],
|
||||
});
|
||||
|
||||
expect(blocks).toMatchObject([
|
||||
{
|
||||
kind: "status",
|
||||
key: "item:unknown",
|
||||
view: {
|
||||
kind: "generic",
|
||||
label: "futureKind",
|
||||
text: "running",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function userMessage(id: string, turnId: string): MessageStreamItem {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { messageStreamItemFromTurnItem } from "../../../../src/features/chat/app-server/mappers/message-stream/turn-items";
|
||||
import { messageStreamSemanticClassifications } from "../../../../src/features/chat/domain/message-stream/semantics";
|
||||
import {
|
||||
messageStreamIsAutoReviewDecision,
|
||||
messageStreamSemanticClassifications,
|
||||
} from "../../../../src/features/chat/domain/message-stream/semantics";
|
||||
import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items";
|
||||
import type { TurnItem } from "../../../../src/app-server/protocol/turn";
|
||||
|
||||
|
|
@ -62,7 +65,7 @@ describe("message stream semantic classification", () => {
|
|||
expect(semantic.map(({ placement }) => ("turnRole" in placement ? placement.turnRole : null))).toEqual(["initiator", "initiator"]);
|
||||
});
|
||||
|
||||
it("classifies work item meaning independently from payload shape", () => {
|
||||
it("classifies message stream item meaning independently from payload shape", () => {
|
||||
const semantic = messageStreamSemanticClassifications([
|
||||
commandItem("cmd"),
|
||||
{
|
||||
|
|
@ -143,6 +146,7 @@ describe("message stream semantic classification", () => {
|
|||
{ plane: "permission", event: "decision" },
|
||||
{ plane: "permission", event: "decision" },
|
||||
]);
|
||||
expect(semantic.map(messageStreamIsAutoReviewDecision)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("marks completed proposed plans as implementable turn outcomes", () => {
|
||||
|
|
|
|||
|
|
@ -803,9 +803,8 @@ function goal(threadId: string): ThreadGoal {
|
|||
function uiDisclosureCount(state: ChatState): number {
|
||||
const disclosures = state.ui.disclosures;
|
||||
return (
|
||||
disclosures.toolResults.size +
|
||||
disclosures.details.size +
|
||||
disclosures.activityGroups.size +
|
||||
disclosures.agentDetails.size +
|
||||
disclosures.textDetails.size +
|
||||
disclosures.userMessageExpanded.size +
|
||||
disclosures.goalObjectiveExpanded.size +
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ describe("message stream rendering and message actions", () => {
|
|||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.classList.contains("codex-panel__message--review-result")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__tool-result--plain")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__detail--plain")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("auto-review");
|
||||
expect(element.textContent).toContain("Auto-review denied this command.");
|
||||
expect(element.querySelector("details")).toBeNull();
|
||||
|
|
@ -135,7 +135,7 @@ describe("message stream rendering and message actions", () => {
|
|||
auditFacts: [
|
||||
{ key: "status", value: "approved" },
|
||||
{ key: "action", value: "apply patch" },
|
||||
{ key: "files", value: "src/ui/tool-result-view.ts\nsrc/ui/message-stream.ts" },
|
||||
{ key: "files", value: "src/ui/detail-view.ts\nsrc/ui/message-stream.ts" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -155,9 +155,7 @@ describe("message stream rendering and message actions", () => {
|
|||
expect(element.textContent).not.toContain("▶Review");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain(
|
||||
"filessrc/ui/tool-result-view.ts\nsrc/ui/message-stream.ts",
|
||||
);
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("filessrc/ui/detail-view.ts\nsrc/ui/message-stream.ts");
|
||||
expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -227,8 +225,8 @@ describe("message stream rendering and message actions", () => {
|
|||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.classList.contains("codex-panel__message--tool")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__tool-result-label")?.textContent).toBe("goal");
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("set: Ship the feature");
|
||||
expect(element.querySelector(".codex-panel__detail-label")?.textContent).toBe("goal");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("set: Ship the feature");
|
||||
expect(element.querySelector("details")?.open).toBe(false);
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionset");
|
||||
expect(element.querySelector(".codex-panel__output-title")?.textContent).toBe("Objective");
|
||||
|
|
@ -236,7 +234,7 @@ describe("message stream rendering and message actions", () => {
|
|||
});
|
||||
|
||||
it("renders rollback action only for the eligible user message", () => {
|
||||
const onRollbackItem = vi.fn();
|
||||
const onRollback = vi.fn();
|
||||
const items = [
|
||||
{ id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" },
|
||||
{
|
||||
|
|
@ -260,8 +258,8 @@ describe("message stream rendering and message actions", () => {
|
|||
forkActionsItemId: null,
|
||||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
canRollbackItem: (item) => item.id === "u2",
|
||||
onRollbackItem,
|
||||
textActionsByItemId: new Map([["u2", { rollback: { itemId: "u2", turnId: "turn-2" } }]]),
|
||||
onRollback,
|
||||
});
|
||||
|
||||
const rendered = blocks.map((block) => renderMessageBlockElement(block));
|
||||
|
|
@ -271,7 +269,7 @@ describe("message stream rendering and message actions", () => {
|
|||
const button = expectPresent(rendered[2]).querySelector<HTMLButtonElement>(".codex-panel__rollback-turn");
|
||||
expect(button?.getAttribute("aria-label")).toBe("Rollback last turn");
|
||||
button?.click();
|
||||
expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u2" }));
|
||||
expect(onRollback).toHaveBeenCalledWith({ itemId: "u2", turnId: "turn-2" });
|
||||
});
|
||||
|
||||
it("renders copy actions for copyable messages", () => {
|
||||
|
|
@ -315,7 +313,7 @@ describe("message stream rendering and message actions", () => {
|
|||
|
||||
it("expands assistant fork actions in the copy action region and defaults repeat clicks to plain fork", () => {
|
||||
const onForkActionsToggle = vi.fn();
|
||||
const onForkItem = vi.fn();
|
||||
const onFork = vi.fn();
|
||||
const item: MessageStreamItem = {
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
|
|
@ -339,8 +337,8 @@ describe("message stream rendering and message actions", () => {
|
|||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
copyText: vi.fn(),
|
||||
canForkItem: () => true,
|
||||
onForkItem,
|
||||
textActionsByItemId: new Map([["a1", { fork: { itemId: "a1", turnId: "turn-1" } }]]),
|
||||
onFork,
|
||||
})[0];
|
||||
|
||||
const closedElement = renderMessageBlockElement(closedBlock);
|
||||
|
|
@ -363,8 +361,8 @@ describe("message stream rendering and message actions", () => {
|
|||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
copyText: vi.fn(),
|
||||
canForkItem: () => true,
|
||||
onForkItem,
|
||||
textActionsByItemId: new Map([["a1", { fork: { itemId: "a1", turnId: "turn-1" } }]]),
|
||||
onFork,
|
||||
})[0];
|
||||
|
||||
const openElement = renderMessageBlockElement(openBlock);
|
||||
|
|
@ -376,11 +374,11 @@ describe("message stream rendering and message actions", () => {
|
|||
expect(openFork.getAttribute("aria-label")).toBe("Fork");
|
||||
expect(openFork.getAttribute("data-icon")).toBe("file-plus-corner");
|
||||
openFork.click();
|
||||
expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), false);
|
||||
expect(onFork).toHaveBeenCalledWith({ itemId: "a1", turnId: "turn-1" }, false);
|
||||
});
|
||||
|
||||
it("runs fork and archive from the expanded assistant fork actions", () => {
|
||||
const onForkItem = vi.fn();
|
||||
const onFork = vi.fn();
|
||||
const item: MessageStreamItem = {
|
||||
id: "a1",
|
||||
kind: "message",
|
||||
|
|
@ -403,14 +401,14 @@ describe("message stream rendering and message actions", () => {
|
|||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
copyText: vi.fn(),
|
||||
canForkItem: () => true,
|
||||
onForkItem,
|
||||
textActionsByItemId: new Map([["a1", { fork: { itemId: "a1", turnId: "turn-1" } }]]),
|
||||
onFork,
|
||||
})[0];
|
||||
|
||||
const element = renderMessageBlockElement(block);
|
||||
expectPresent(element.querySelector<HTMLButtonElement>(".codex-panel__fork-and-archive-message")).click();
|
||||
|
||||
expect(onForkItem).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }), true);
|
||||
expect(onFork).toHaveBeenCalledWith({ itemId: "a1", turnId: "turn-1" }, true);
|
||||
});
|
||||
|
||||
it("updates message content when a streaming plan delta completes", () => {
|
||||
|
|
@ -761,7 +759,7 @@ describe("message stream rendering and message actions", () => {
|
|||
});
|
||||
|
||||
it("renders implement plan action for eligible proposed plans", () => {
|
||||
const onImplementPlanItem = vi.fn();
|
||||
const onImplementPlan = vi.fn();
|
||||
const block = messageStreamBlocks({
|
||||
activeThreadId: "thread",
|
||||
turnLifecycle: idleTurnLifecycle(),
|
||||
|
|
@ -784,8 +782,8 @@ describe("message stream rendering and message actions", () => {
|
|||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
copyText: vi.fn(),
|
||||
canImplementPlanItem: () => true,
|
||||
onImplementPlanItem,
|
||||
textActionsByItemId: new Map([["p1", { implementPlan: { itemId: "p1" } }]]),
|
||||
onImplementPlan,
|
||||
})[0];
|
||||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
|
@ -793,7 +791,7 @@ describe("message stream rendering and message actions", () => {
|
|||
|
||||
expect(button?.getAttribute("aria-label")).toBe("Implement plan");
|
||||
button?.click();
|
||||
expect(onImplementPlanItem).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" }));
|
||||
expect(onImplementPlan).toHaveBeenCalledWith({ itemId: "p1" });
|
||||
});
|
||||
|
||||
it("selects only the latest proposed plan as an implement candidate", () => {
|
||||
|
|
@ -869,7 +867,7 @@ describe("message stream rendering and message actions", () => {
|
|||
|
||||
it("renders copy and rollback actions together when both apply", () => {
|
||||
const copyText = vi.fn();
|
||||
const onRollbackItem = vi.fn();
|
||||
const onRollback = vi.fn();
|
||||
const block = messageStreamBlocks({
|
||||
activeThreadId: "thread",
|
||||
turnLifecycle: idleTurnLifecycle(),
|
||||
|
|
@ -881,8 +879,8 @@ describe("message stream rendering and message actions", () => {
|
|||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
copyText,
|
||||
canRollbackItem: () => true,
|
||||
onRollbackItem,
|
||||
textActionsByItemId: new Map([["u1", { rollback: { itemId: "u1", turnId: "turn-1" } }]]),
|
||||
onRollback,
|
||||
})[0];
|
||||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
|
@ -890,7 +888,7 @@ describe("message stream rendering and message actions", () => {
|
|||
element.querySelector<HTMLButtonElement>(".codex-panel__rollback-turn")?.click();
|
||||
|
||||
expect(copyText).toHaveBeenCalledWith("latest");
|
||||
expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u1" }));
|
||||
expect(onRollback).toHaveBeenCalledWith({ itemId: "u1", turnId: "turn-1" });
|
||||
});
|
||||
|
||||
it("collapses tall user messages without changing the copy payload", () => {
|
||||
|
|
@ -1029,8 +1027,6 @@ describe("message stream rendering and message actions", () => {
|
|||
forkActionsItemId: null,
|
||||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
canRollbackItem: () => false,
|
||||
onRollbackItem: vi.fn(),
|
||||
})[0];
|
||||
|
||||
expect(renderMessageBlockElement(block).querySelector(".codex-panel__rollback-turn")).toBeNull();
|
||||
|
|
@ -1065,8 +1061,8 @@ describe("message stream rendering and message actions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("npm run check (exit 1)");
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull();
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("npm run check (exit 1)");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.getAttribute("title")).toBeNull();
|
||||
expect(topLevelDetailsSummaries(element)).toEqual(["command"]);
|
||||
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["command"]);
|
||||
expect(element.textContent).not.toContain("Details");
|
||||
|
|
@ -1171,7 +1167,7 @@ describe("message stream rendering and message actions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe('"semantic target" in src');
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe('"semantic target" in src');
|
||||
});
|
||||
|
||||
it("renders file diffs inside a single file change details block", () => {
|
||||
|
|
@ -1200,7 +1196,7 @@ describe("message stream rendering and message actions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/main.ts");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("src/main.ts");
|
||||
expect(topLevelDetailsSummaries(element)).toEqual(["file change"]);
|
||||
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["file change"]);
|
||||
expect(element.textContent).not.toContain("Details");
|
||||
|
|
@ -1235,7 +1231,7 @@ describe("message stream rendering and message actions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/main.ts (failed)");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("src/main.ts (failed)");
|
||||
});
|
||||
|
||||
it("renders the edited files footer with an open diff action when aggregated turn diff exists", () => {
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ describe("pending request renderer decisions", () => {
|
|||
);
|
||||
|
||||
expect(parent.querySelectorAll(".codex-panel__pending-request-block")).toHaveLength(1);
|
||||
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__work-message")).toBe(true);
|
||||
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__work-message--warning")).toBe(
|
||||
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__status-message")).toBe(true);
|
||||
expect(parent.querySelector(".codex-panel__pending-request-block")?.classList.contains("codex-panel__status-message--warning")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")?.textContent).toBe("Submit");
|
||||
|
|
@ -420,11 +420,11 @@ describe("pending request renderer decisions", () => {
|
|||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.classList.contains("codex-panel__message--approval-result")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__tool-result")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__detail")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__execution--completed")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__message-content")).toBeNull();
|
||||
expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("approval");
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Allowed for this session: Need access");
|
||||
expect(element.querySelector(".codex-panel__detail-header")?.textContent).toBe("approval");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("Allowed for this session: Need access");
|
||||
expect(element.querySelector("details summary")?.textContent).toBe("approval");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusallowed for session");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("scopesession");
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
unmountUiRootInAct,
|
||||
} from "./test-helpers";
|
||||
|
||||
describe("work log renderer decisions", () => {
|
||||
describe("message stream item renderer decisions", () => {
|
||||
it("renders generic tool details as visible sections inside one details block", () => {
|
||||
const block = messageStreamBlocks({
|
||||
activeThreadId: "thread",
|
||||
|
|
@ -47,7 +47,7 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("123");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("123");
|
||||
expect(topLevelDetailsSummaries(element)).toEqual(["github.pull_request_read"]);
|
||||
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["github.pull_request_read"]);
|
||||
expect(element.querySelector<HTMLElement>("details summary")?.tabIndex).toBe(-1);
|
||||
|
|
@ -92,8 +92,8 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(expectPresent(block));
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-result-header")?.textContent).toBe("steer");
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("also check tests and keep the summary compact");
|
||||
expect(element.querySelector(".codex-panel__detail-header")?.textContent).toBe("steer");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("also check tests and keep the summary compact");
|
||||
});
|
||||
|
||||
it("renders path summary tools relative to the workspace root", () => {
|
||||
|
|
@ -122,8 +122,8 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png");
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull();
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("assets/image.png");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
it("derives generic tool summaries from primary targets instead of item text", () => {
|
||||
|
|
@ -153,7 +153,7 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("search: codex app-server");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("search: codex app-server");
|
||||
});
|
||||
|
||||
it("updates path summary tools when the workspace root changes", () => {
|
||||
|
|
@ -180,10 +180,10 @@ describe("work log renderer decisions", () => {
|
|||
};
|
||||
|
||||
renderMessageStreamBlocksInAct(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault" }));
|
||||
expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("project/assets/image.png");
|
||||
expect(parent.querySelector(".codex-panel__stream-summary")?.textContent).toBe("project/assets/image.png");
|
||||
|
||||
renderMessageStreamBlocksInAct(parent, messageStreamBlocks({ ...baseContext, workspaceRoot: "/vault/project" }));
|
||||
expect(parent.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png");
|
||||
expect(parent.querySelector(".codex-panel__stream-summary")?.textContent).toBe("assets/image.png");
|
||||
unmountUiRootInAct(parent);
|
||||
});
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/tmp/image.png");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("/tmp/image.png");
|
||||
});
|
||||
|
||||
it("does not treat generic tool summaries as paths without an explicit marker", () => {
|
||||
|
|
@ -241,7 +241,7 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/vault/project");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("/vault/project");
|
||||
});
|
||||
|
||||
it("renders hook metadata as rows inside one details block", () => {
|
||||
|
|
@ -279,7 +279,7 @@ describe("work log renderer decisions", () => {
|
|||
expect(topLevelDetailsSummaries(element)).toEqual(["hook"]);
|
||||
expect(element.classList.contains("codex-panel__execution--completed")).toBe(true);
|
||||
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["hook"]);
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
|
||||
expect(element.textContent).not.toContain("Details");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse");
|
||||
|
|
@ -321,7 +321,7 @@ describe("work log renderer decisions", () => {
|
|||
messageState: "completed",
|
||||
},
|
||||
],
|
||||
disclosures: testDisclosures({ activityGroups: ["turn"], toolResults: ["hook-1"] }),
|
||||
disclosures: testDisclosures({ activityGroups: ["turn"], details: ["hook-1:details"] }),
|
||||
forkActionsItemId: null,
|
||||
loadOlderTurns: vi.fn(),
|
||||
renderMarkdown: (parent, text) => parent.createDiv({ text }),
|
||||
|
|
@ -331,8 +331,8 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
expect(element).toBeDefined();
|
||||
expect(element.querySelector(":scope > summary")?.textContent).toBe("Work details");
|
||||
expect(element.querySelector(".codex-panel__tool-item--hook")?.classList.contains("codex-panel__execution--completed")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
|
||||
expect(element.querySelector(".codex-panel__detail-item--hook")?.classList.contains("codex-panel__execution--completed")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("postToolUse: Formatted 1 file.");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse");
|
||||
expect(element.querySelector(".codex-panel__output-title")?.textContent).toBe("Hook output");
|
||||
|
|
@ -368,7 +368,7 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.classList.contains("codex-panel__work-message")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__status-message")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__task-progress")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("tasks");
|
||||
expect(element.textContent).toContain("[x]Inspect code");
|
||||
|
|
@ -588,13 +588,12 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.classList.contains("codex-panel__work-message")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__detail")).toBe(true);
|
||||
expect(element.classList.contains("codex-panel__agent-activity")).toBe(true);
|
||||
expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("agent");
|
||||
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__tool-summary"));
|
||||
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__stream-summary"));
|
||||
expect(summary.textContent).toBe("spawn child: Inspect the renderer. (completed)");
|
||||
expect(summary.classList.contains("codex-panel__agent-activity-summary")).toBe(true);
|
||||
expect([...element.querySelectorAll("details summary")].map((detailsSummary) => detailsSummary.textContent)).toEqual(["Details"]);
|
||||
expect([...element.querySelectorAll("details summary")].map((detailsSummary) => detailsSummary.textContent)).toEqual(["agent"]);
|
||||
expect(element.textContent).toContain("targetchild");
|
||||
expect(element.textContent).toContain("PromptInspect the renderer.");
|
||||
expect(element.textContent).toContain("childcompleted: Done");
|
||||
|
|
@ -630,7 +629,7 @@ describe("work log renderer decisions", () => {
|
|||
})[0];
|
||||
|
||||
const element = renderMessageBlockElement(block);
|
||||
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__agent-activity-summary"));
|
||||
const summary = expectPresent(element.querySelector<HTMLElement>(".codex-panel__stream-summary"));
|
||||
|
||||
expect(summary.textContent).toBe(`spawn child: Inspect the renderer. ${"a".repeat(73)}... (running)`);
|
||||
});
|
||||
|
|
@ -670,17 +669,20 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const element = renderMessageBlockElement(block);
|
||||
|
||||
expect(element.querySelector(".codex-panel__agent-thread")?.textContent).toBe("019e061e");
|
||||
expect(element.querySelector(".codex-panel__agent-status")?.textContent).toBe("completed: Done");
|
||||
expect(element.querySelector(".codex-panel__agent-status")?.textContent).not.toContain("a".repeat(180));
|
||||
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["Details"]);
|
||||
const agentRows = [...element.querySelectorAll(".codex-panel__meta-grid dt, .codex-panel__meta-grid dd")].map(
|
||||
(node) => node.textContent,
|
||||
);
|
||||
expect(agentRows).toContain("019e061e");
|
||||
expect(agentRows).toContain("completed: Done");
|
||||
expect(agentRows.join("")).not.toContain("a".repeat(180));
|
||||
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["agent"]);
|
||||
expect(element.querySelector<HTMLElement>("details summary")?.tabIndex).toBe(-1);
|
||||
expect(element.textContent).toContain("Agent output 019e061e");
|
||||
expect(element.textContent).toContain(longMessage);
|
||||
const details = element.querySelector("details");
|
||||
expect(details?.hasAttribute("open")).toBe(false);
|
||||
details?.dispatchEvent(new Event("toggle"));
|
||||
expect(onDisclosureToggle).toHaveBeenCalledWith("agentDetails", "agent-1", false);
|
||||
expect(onDisclosureToggle).toHaveBeenCalledWith("details", "agent-1:agent-details", false);
|
||||
});
|
||||
|
||||
it("renders a compact live agent summary while subagents are running", () => {
|
||||
|
|
@ -717,7 +719,7 @@ describe("work log renderer decisions", () => {
|
|||
|
||||
const summary = renderMessageBlockElement(expectPresent(blocks.at(-1)));
|
||||
|
||||
expect(summary.classList.contains("codex-panel__work-message")).toBe(true);
|
||||
expect(summary.classList.contains("codex-panel__status-message")).toBe(true);
|
||||
expect(summary.classList.contains("codex-panel__agent-summary")).toBe(true);
|
||||
expect(summary.textContent).toContain("agents");
|
||||
expect(summary.textContent).toContain("Agents 1 running, 1 done");
|
||||
|
|
@ -725,7 +727,7 @@ describe("work log renderer decisions", () => {
|
|||
expect(summary.textContent).not.toContain("donecompleted");
|
||||
});
|
||||
|
||||
it("renders context compaction as a one-line work item while running and after completion", () => {
|
||||
it("renders context compaction as a one-line status item while running and after completion", () => {
|
||||
const runningParent = document.createElement("div");
|
||||
const item: MessageStreamItem = {
|
||||
id: "compact-1",
|
||||
|
|
@ -753,7 +755,7 @@ describe("work log renderer decisions", () => {
|
|||
runningParent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:compact-1"] .codex-panel__context-compaction'),
|
||||
);
|
||||
expect(running.querySelector(".codex-panel__message-role")?.textContent).toBe("context");
|
||||
expect(running.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Compacting context...");
|
||||
expect(running.querySelector(".codex-panel__stream-summary")?.textContent).toBe("Compacting context...");
|
||||
expect(running.classList.contains("codex-panel__execution--running")).toBe(true);
|
||||
unmountUiRootInAct(runningParent);
|
||||
|
||||
|
|
@ -776,7 +778,7 @@ describe("work log renderer decisions", () => {
|
|||
const completed = expectPresent(
|
||||
completedParent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:compact-1"] .codex-panel__context-compaction'),
|
||||
);
|
||||
expect(completed.querySelector(".codex-panel__tool-summary")?.textContent).toBe("Context compacted");
|
||||
expect(completed.querySelector(".codex-panel__stream-summary")?.textContent).toBe("Context compacted");
|
||||
expect(completed.classList.contains("codex-panel__execution--completed")).toBe(true);
|
||||
unmountUiRootInAct(completedParent);
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
} from "../../../../../src/features/chat/ui/message-stream/context";
|
||||
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
|
||||
import { messageStreamViewBlocks } from "../../../../../src/features/chat/presentation/message-stream/view-model";
|
||||
import type { MessageStreamTextActions } from "../../../../../src/features/chat/presentation/message-stream/text-view";
|
||||
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/viewport";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ export function messageStreamBlocks(
|
|||
activeItems: context.activeItems,
|
||||
workspaceRoot: normalized.workspaceRoot,
|
||||
turnDiffs: context.turnDiffs,
|
||||
textActionsByItemId: context.textActionsByItemId,
|
||||
pendingRequests: pendingRequestBlockInput(context),
|
||||
});
|
||||
const blocks = rawMessageStreamBlocks(viewBlocks, normalized);
|
||||
|
|
@ -66,6 +68,7 @@ type TestMessageStreamContext = Omit<MessageStreamContext, "disclosures" | "fork
|
|||
stableItems?: readonly MessageStreamItem[];
|
||||
activeItems?: readonly MessageStreamItem[];
|
||||
turnDiffs?: ReadonlyMap<string, string>;
|
||||
textActionsByItemId?: ReadonlyMap<string, MessageStreamTextActions>;
|
||||
};
|
||||
|
||||
export function emptyDisclosures(): MessageStreamDisclosureState {
|
||||
|
|
@ -76,9 +79,8 @@ export function testDisclosures(
|
|||
overrides: Partial<Record<keyof MessageStreamDisclosureState, readonly string[]>> = {},
|
||||
): MessageStreamDisclosureState {
|
||||
return {
|
||||
toolResults: new Set(overrides.toolResults),
|
||||
details: new Set(overrides.details),
|
||||
activityGroups: new Set(overrides.activityGroups),
|
||||
agentDetails: new Set(overrides.agentDetails),
|
||||
textDetails: new Set(overrides.textDetails),
|
||||
userMessageExpanded: new Set(overrides.userMessageExpanded),
|
||||
goalObjectiveExpanded: new Set(overrides.goalObjectiveExpanded),
|
||||
|
|
|
|||
Loading…
Reference in a new issue