diff --git a/eslint.config.mjs b/eslint.config.mjs index d49be48b..68e31cb0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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", diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 34ac38fd..ce2a9d39 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -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; + implement: (itemId: string) => Promise; } 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 { - if (!canImplementPlan(host.stateStore.getState(), item)) return; +export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise { + if (!canImplementPlanItemId(host.stateStore.getState(), itemId)) return; await host.ensureConnected(); if (!host.currentClient() || !activeThreadId(host.stateStore.getState())) return; diff --git a/src/features/chat/application/state/actions.ts b/src/features/chat/application/state/actions.ts index 6e9a4e0e..cf9de393 100644 --- a/src/features/chat/application/state/actions.ts +++ b/src/features/chat/application/state/actions.ts @@ -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; } diff --git a/src/features/chat/application/state/selectors.ts b/src/features/chat/application/state/selectors.ts index 3ead4964..61ed0199 100644 --- a/src/features/chat/application/state/selectors.ts +++ b/src/features/chat/application/state/selectors.ts @@ -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: { diff --git a/src/features/chat/application/state/ui-state.ts b/src/features/chat/application/state/ui-state.ts index 39bb0368..94854b01 100644 --- a/src/features/chat/application/state/ui-state.ts +++ b/src/features/chat/application/state/ui-state.ts @@ -29,9 +29,8 @@ interface ChatMessageActionsUiState { } const CHAT_DISCLOSURE_BUCKETS = [ - "toolResults", + "details", "activityGroups", - "agentDetails", "textDetails", "userMessageExpanded", "goalObjectiveExpanded", diff --git a/src/features/chat/domain/message-stream/items.ts b/src/features/chat/domain/message-stream/items.ts index bc1a8553..a1e6006e 100644 --- a/src/features/chat/domain/message-stream/items.ts +++ b/src/features/chat/domain/message-stream/items.ts @@ -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"; } diff --git a/src/features/chat/domain/message-stream/semantics/classify.ts b/src/features/chat/domain/message-stream/semantics/classify.ts index bee75304..acfdb2db 100644 --- a/src/features/chat/domain/message-stream/semantics/classify.ts +++ b/src/features/chat/domain/message-stream/semantics/classify.ts @@ -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): MessageStreamMeaning { diff --git a/src/features/chat/domain/message-stream/semantics/index.ts b/src/features/chat/domain/message-stream/semantics/index.ts index f14097cd..5b44f0cf 100644 --- a/src/features/chat/domain/message-stream/semantics/index.ts +++ b/src/features/chat/domain/message-stream/semantics/index.ts @@ -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"; diff --git a/src/features/chat/domain/message-stream/semantics/predicates.ts b/src/features/chat/domain/message-stream/semantics/predicates.ts index c25057ce..19cc0eea 100644 --- a/src/features/chat/domain/message-stream/semantics/predicates.ts +++ b/src/features/chat/domain/message-stream/semantics/predicates.ts @@ -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; +} diff --git a/src/features/chat/domain/message-stream/semantics/types.ts b/src/features/chat/domain/message-stream/semantics/types.ts index c6fa89ce..a0fdd743 100644 --- a/src/features/chat/domain/message-stream/semantics/types.ts +++ b/src/features/chat/domain/message-stream/semantics/types.ts @@ -63,8 +63,6 @@ export interface MessageStreamSemanticActions { isTurnOutcome: boolean; } -export type MessageStreamRenderFamily = "text" | "toolResult" | "work"; - export interface MessageStreamSemanticClassification { item: MessageStreamItem; provenance?: MessageStreamItemProvenance; diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 366c1e03..1d2d4f93 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -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: { diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/message-stream-presenter.ts index f7d946c0..49ef8341 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/message-stream-presenter.ts @@ -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; } diff --git a/src/features/chat/panel/surface/message-stream-projection.ts b/src/features/chat/panel/surface/message-stream-projection.ts index 0dfae376..79fc90eb 100644 --- a/src/features/chat/panel/surface/message-stream-projection.ts +++ b/src/features/chat/panel/surface/message-stream-projection.ts @@ -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 { + const byItemId = new Map(); + 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, itemId: string, patch: MessageStreamTextActions): void { + byItemId.set(itemId, { ...byItemId.get(itemId), ...patch }); +} + function pendingRequestBlockFromContext( context: ChatMessageStreamSurfaceContext, ): { signature: string; snapshot: ReturnType } | null { diff --git a/src/features/chat/presentation/message-stream/tool-result-view.ts b/src/features/chat/presentation/message-stream/codex-detail-view.ts similarity index 54% rename from src/features/chat/presentation/message-stream/tool-result-view.ts rename to src/features/chat/presentation/message-stream/codex-detail-view.ts index e2b459c1..bb350b11 100644 --- a/src/features/chat/presentation/message-stream/tool-result-view.ts +++ b/src/features/chat/presentation/message-stream/codex-detail-view.ts @@ -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"; diff --git a/src/features/chat/presentation/message-stream/detail-types.ts b/src/features/chat/presentation/message-stream/detail-types.ts new file mode 100644 index 00000000..6078b854 --- /dev/null +++ b/src/features/chat/presentation/message-stream/detail-types.ts @@ -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)[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"; +} diff --git a/src/features/chat/presentation/message-stream/detail-view.ts b/src/features/chat/presentation/message-stream/detail-view.ts new file mode 100644 index 00000000..606c1438 --- /dev/null +++ b/src/features/chat/presentation/message-stream/detail-view.ts @@ -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; +} diff --git a/src/features/chat/presentation/message-stream/layout.ts b/src/features/chat/presentation/message-stream/layout.ts index fc57792e..af26d156 100644 --- a/src/features/chat/presentation/message-stream/layout.ts +++ b/src/features/chat/presentation/message-stream/layout.ts @@ -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(); 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(); diff --git a/src/features/chat/presentation/message-stream/render-family.ts b/src/features/chat/presentation/message-stream/render-family.ts new file mode 100644 index 00000000..6769e649 --- /dev/null +++ b/src/features/chat/presentation/message-stream/render-family.ts @@ -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"; +} diff --git a/src/features/chat/presentation/message-stream/status-view.ts b/src/features/chat/presentation/message-stream/status-view.ts new file mode 100644 index 00000000..0144bc88 --- /dev/null +++ b/src/features/chat/presentation/message-stream/status-view.ts @@ -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)[key]; + return typeof value === "string" && value.trim().length > 0 ? value : null; +} diff --git a/src/features/chat/presentation/message-stream/text-view.ts b/src/features/chat/presentation/message-stream/text-view.ts index 19658e1d..c2fd2f26 100644 --- a/src/features/chat/presentation/message-stream/text-view.ts +++ b/src/features/chat/presentation/message-stream/text-view.ts @@ -1,37 +1,87 @@ import type { ExecutionState, MessageStreamItem } from "../../domain/message-stream/items"; import type { MessageStreamItemAnnotations } from "./layout"; -export type TextMessageStreamItem = Extract; +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}` : ""; } diff --git a/src/features/chat/presentation/message-stream/view-model.ts b/src/features/chat/presentation/message-stream/view-model.ts index d38a5d63..eeb7566b 100644 --- a/src/features/chat/presentation/message-stream/view-model.ts +++ b/src/features/chat/presentation/message-stream/view-model.ts @@ -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 | undefined; + textActionsByItemId?: ReadonlyMap | 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[1] { +function statusViewContext(input: MessageStreamPresentationBlockInput): Parameters[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: Key, value: Value | undefined): Partial> { + return value === undefined ? {} : ({ [key]: value } as Partial>); } diff --git a/src/features/chat/presentation/message-stream/work-view.ts b/src/features/chat/presentation/message-stream/work-view.ts deleted file mode 100644 index 2dfc5d0e..00000000 --- a/src/features/chat/presentation/message-stream/work-view.ts +++ /dev/null @@ -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; -} diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index 41c01420..688dd23b 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -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; + details: ReadonlySet; activityGroups: ReadonlySet; - agentDetails: ReadonlySet; textDetails: ReadonlySet; userMessageExpanded: ReadonlySet; goalObjectiveExpanded: ReadonlySet; @@ -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 { diff --git a/src/features/chat/ui/message-stream/tool-result.tsx b/src/features/chat/ui/message-stream/detail.tsx similarity index 55% rename from src/features/chat/ui/message-stream/tool-result.tsx rename to src/features/chat/ui/message-stream/detail.tsx index f8b4b26b..b6b70e8a 100644 --- a/src/features/chat/ui/message-stream/tool-result.tsx +++ b/src/features/chat/ui/message-stream/detail.tsx @@ -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 ; +export function detailNode(view: DetailView, context: DetailRenderContext): UiNode { + return ; } -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 (
- - {hasSummary ? : null} + + {hasSummary ? : null}
); } @@ -40,40 +40,40 @@ function ToolResult({ view, context }: { view: ToolResultView; context: ToolResu return (
{ - context.onDisclosureToggle?.("toolResults", view.detailsKey, event.currentTarget.open); + context.onDisclosureToggle?.("details", view.detailsKey, event.currentTarget.open); }} > - - {view.details.map((section, index) => ( - + + {view.sections.map((section, index) => ( + ))}
- {hasSummary ? : null} + {hasSummary ? : null}
); } -function ToolResultHeader({ view }: { view: ToolResultView }): UiNode { - const content = {view.label}; - return view.details.length > 0 ? ( - +function DetailHeader({ view }: { view: DetailView }): UiNode { + const content = {view.label}; + return view.sections.length > 0 ? ( + {content} ) : ( -
{content}
+
{content}
); } -function ToolSummary({ text }: { text: string }): UiNode { - return
{text}
; +function DetailSummary({ text }: { text: string }): UiNode { + return
{text}
; } -function ToolResultDetailSection({ section }: { section: ToolResultDetailSection }): UiNode { - if (section.kind === "meta") { - return ; +function DetailSectionView({ section }: { section: DetailSection }): UiNode { + if (section.kind === "kv") { + return ; } if (section.kind === "diff") { return ( @@ -85,7 +85,7 @@ function ToolResultDetailSection({ section }: { section: ToolResultDetailSection return ; } -function MetaBlock({ +function KeyValueBlock({ title, rows, }: { @@ -95,7 +95,7 @@ function MetaBlock({ const body = (
{rows.map((row) => ( - + ))}
); @@ -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 ( <>
{row.key}
diff --git a/src/features/chat/ui/message-stream/pending-request-block.tsx b/src/features/chat/ui/message-stream/pending-request-block.tsx index d0d69d8c..d9d6e844 100644 --- a/src/features/chat/ui/message-stream/pending-request-block.tsx +++ b/src/features/chat/ui/message-stream/pending-request-block.tsx @@ -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 ( -
+
Request
{approvals.map((approval) => ( diff --git a/src/features/chat/ui/message-stream/status-items.tsx b/src/features/chat/ui/message-stream/status-items.tsx new file mode 100644 index 00000000..bb00fd10 --- /dev/null +++ b/src/features/chat/ui/message-stream/status-items.tsx @@ -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 ; +} + +export function statusItemNode(view: MessageStreamStatusView): UiNode { + if (view.kind === "taskProgress") return ; + if (view.kind === "contextCompaction") return ; + if (view.kind === "reasoning") return ; + return ; +} + +function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode { + return ( + +
{view.summary}
+ +
+ ); +} + +function TaskProgressItem({ view }: { view: Extract }): UiNode { + return ( + + {view.summary ?
{view.summary}
: null} + {view.checklist.length === 0 ? ( +
Plan updated
+ ) : ( +
    + {view.checklist.map((step) => ( +
  • + {taskStatusMarker(step.status)} + {step.step} +
  • + ))} +
+ )} +
+ ); +} + +function taskStatusMarker(status: StatusChecklistItem["status"]): string { + if (status === "completed") return "[x]"; + if (status === "inProgress") return "[>]"; + return "[ ]"; +} + +function ContextCompactionItem({ view }: { view: Extract }): UiNode { + return ( + +
{view.text}
+
+ ); +} + +function GenericStatusItem({ view }: { view: Extract }): UiNode { + return ( + +
{view.text}
+
+ ); +} + +function ReasoningItem({ view }: { view: Extract }): UiNode { + return ( +
+
{view.label}
+
+ {view.text} + {view.active ? ( + + . + . + . + + ) : null} +
+
+ ); +} + +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 ( +
+
{label}
+ {children} +
+ ); +} + +function AgentSummaryRows({ view }: { view: AgentRunSummaryView }): UiNode { + if (view.rows.length === 0 && view.additionalAgents === 0) return null; + return ( +
    + {view.rows.map((agent) => ( +
  • + {agent.threadLabel} + {agent.status} +
  • + ))} + {view.additionalAgents > 0 ? ( +
  • + + +{String(view.additionalAgents)} more +
  • + ) : null} +
+ ); +} diff --git a/src/features/chat/ui/message-stream/status-message.ts b/src/features/chat/ui/message-stream/status-message.ts new file mode 100644 index 00000000..b1c44439 --- /dev/null +++ b/src/features/chat/ui/message-stream/status-message.ts @@ -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(" "); +} diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/message-stream/stream-blocks.tsx index 85f86270..fff3a8f6 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/message-stream/stream-blocks.tsx @@ -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 }): UiNode { return ( -
-
- {activity.label} +
+
+ {activity.label}
-
{activity.text}
+
{activity.text}
); } diff --git a/src/features/chat/ui/message-stream/text-item-actions.tsx b/src/features/chat/ui/message-stream/text-item-actions.tsx index c1be566a..c1232dec 100644 --- a/src/features/chat/ui/message-stream/text-item-actions.tsx +++ b/src/features/chat/ui/message-stream/text-item-actions.tsx @@ -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(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 ? ( context.copyText?.(item.copyText ?? item.text)} + onClick={() => context.copyText?.(view.copyText ?? "")} /> ) : null; return (
- {displayRoleLabel(item)} - {forkActionsOpen && context.canForkItem?.(item) ? ( + {view.roleLabel} + {forkActionsOpen && fork ? ( { context.onForkActionsToggle?.(null); - context.onForkItem?.(item, true); + context.onFork?.(fork, true); }} /> ) : ( copyAction )} - {context.canForkItem?.(item) ? ( + {fork ? ( { 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 ? ( context.onImplementPlanItem?.(item)} + onClick={() => context.onImplementPlan?.(implementPlan)} /> ) : null} - {context.canRollbackItem?.(item) ? ( + {rollback ? ( context.onRollbackItem?.(item)} + onClick={() => context.onRollback?.(rollback)} /> ) : null}
@@ -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): 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); -} diff --git a/src/features/chat/ui/message-stream/text-item.tsx b/src/features/chat/ui/message-stream/text-item.tsx index 41535d1c..a77b6fa5 100644 --- a/src/features/chat/ui/message-stream/text-item.tsx +++ b/src/features/chat/ui/message-stream/text-item.tsx @@ -25,7 +25,7 @@ function TextItem({ view, context }: { view: MessageStreamTextView; context: Tex const { item } = view; return (
- + {view.collapsible ? ( ) : ( @@ -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(null); const contextRef = useRef(context); useLayoutEffect(() => { diff --git a/src/features/chat/ui/message-stream/work-items.tsx b/src/features/chat/ui/message-stream/work-items.tsx deleted file mode 100644 index 0ab72bb1..00000000 --- a/src/features/chat/ui/message-stream/work-items.tsx +++ /dev/null @@ -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 ; -} - -export function workItemNode(view: MessageStreamWorkView, context: WorkItemContext): UiNode { - if (view.kind === "taskProgress") return ; - if (view.kind === "agent") return ; - if (view.kind === "contextCompaction") return ; - return ; -} - -function AgentRunSummaryItem({ view }: { view: AgentRunSummaryView }): UiNode { - return ( - -
{view.summary}
- -
- ); -} - -function TaskProgressItem({ view }: { view: Extract }): UiNode { - const { item } = view; - return ( - - {item.explanation ?
{item.explanation}
: null} - {item.steps.length === 0 ? ( -
Plan updated
- ) : ( -
    - {item.steps.map((step) => ( -
  • - {taskStatusMarker(step.status)} - {step.step} -
  • - ))} -
- )} -
- ); -} - -function taskStatusMarker(status: TaskProgressMessageStreamItem["steps"][number]["status"]): string { - if (status === "completed") return "[x]"; - if (status === "inProgress") return "[>]"; - return "[ ]"; -} - -function ContextCompactionItem({ view }: { view: Extract }): UiNode { - return ( - -
{view.summary}
-
- ); -} - -function AgentItem({ view, context }: { view: Extract; context: WorkItemContext }): UiNode { - const { item } = view; - const detailsOpen = context.disclosures.agentDetails.has(item.id); - return ( - -
{view.summary}
-
{ - context.onDisclosureToggle?.("agentDetails", item.id, event.currentTarget.open); - }} - > - Details -
- {view.metaRows.map((row) => ( - - ))} -
- {view.prompt ? ( -
-
Prompt
-
{view.prompt}
-
- ) : null} - {view.agentRows.length > 0 ? ( -
    - {view.agentRows.map((agent) => ( -
  • - {agent.threadLabel} - {agent.status} -
  • - ))} -
- ) : null} - {view.expandedMessages.map((agent) => ( -
-
Agent output {agent.threadLabel}
-
{agent.message}
-
- ))} -
-
- ); -} - -function ReasoningItem({ view }: { view: Extract }): UiNode { - return ( -
-
{view.label}
-
- {view.text} - {view.active ? ( - - . - . - . - - ) : null} -
-
- ); -} - -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 ( -
-
{label}
- {children} -
- ); -} - -function MetaPair({ name, value }: { name: string; value: string }): UiNode { - return ( - <> -
{name}
-
{value}
- - ); -} - -function AgentSummaryRows({ view }: { view: AgentRunSummaryView }): UiNode { - if (view.rows.length === 0 && view.additionalAgents === 0) return null; - return ( -
    - {view.rows.map((agent) => ( -
  • - {agent.threadLabel} - {agent.status} -
  • - ))} - {view.additionalAgents > 0 ? ( -
  • - - +{String(view.additionalAgents)} more -
  • - ) : null} -
- ); -} diff --git a/src/features/chat/ui/message-stream/work-message.ts b/src/features/chat/ui/message-stream/work-message.ts deleted file mode 100644 index bef32f12..00000000 --- a/src/features/chat/ui/message-stream/work-message.ts +++ /dev/null @@ -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(" "); -} diff --git a/src/styles/31-chat-messages.css b/src/styles/31-chat-messages.css index d70a161a..0785d275 100644 --- a/src/styles/31-chat-messages.css +++ b/src/styles/31-chat-messages.css @@ -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); diff --git a/src/styles/32-chat-work-items.css b/src/styles/32-chat-stream-items.css similarity index 66% rename from src/styles/32-chat-work-items.css rename to src/styles/32-chat-stream-items.css index 5855df31..20c1284d 100644 --- a/src/styles/32-chat-work-items.css +++ b/src/styles/32-chat-stream-items.css @@ -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); } diff --git a/src/styles/order.json b/src/styles/order.json index 501cd859..c25326e4 100644 --- a/src/styles/order.json +++ b/src/styles/order.json @@ -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", diff --git a/tests/features/chat/conversation/turns/plan-implementation.test.ts b/tests/features/chat/conversation/turns/plan-implementation.test.ts index 239b104c..b33dd9e6 100644 --- a/tests/features/chat/conversation/turns/plan-implementation.test.ts +++ b/tests/features/chat/conversation/turns/plan-implementation.test.ts @@ -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(); diff --git a/tests/features/chat/message-stream/model.test.ts b/tests/features/chat/message-stream/model.test.ts index c1920236..c672a46f 100644 --- a/tests/features/chat/message-stream/model.test.ts +++ b/tests/features/chat/message-stream/model.test.ts @@ -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" }, diff --git a/tests/features/chat/message-stream/presentation/blocks.test.ts b/tests/features/chat/message-stream/presentation/blocks.test.ts index 293c1741..09b6926b 100644 --- a/tests/features/chat/message-stream/presentation/blocks.test.ts +++ b/tests/features/chat/message-stream/presentation/blocks.test.ts @@ -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 { diff --git a/tests/features/chat/message-stream/semantics.test.ts b/tests/features/chat/message-stream/semantics.test.ts index 79eafe71..8a2fd92d 100644 --- a/tests/features/chat/message-stream/semantics.test.ts +++ b/tests/features/chat/message-stream/semantics.test.ts @@ -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", () => { diff --git a/tests/features/chat/state-reducer.test.ts b/tests/features/chat/state-reducer.test.ts index c24373da..8108789b 100644 --- a/tests/features/chat/state-reducer.test.ts +++ b/tests/features/chat/state-reducer.test.ts @@ -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 + diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index 5aba003f..b92e13f8 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -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(".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(".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(".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", () => { diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx index db0be379..3d75b541 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -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(".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"); diff --git a/tests/features/chat/ui/message-stream/work-log.test.tsx b/tests/features/chat/ui/message-stream/stream-items.test.tsx similarity index 90% rename from tests/features/chat/ui/message-stream/work-log.test.tsx rename to tests/features/chat/ui/message-stream/stream-items.test.tsx index 5d699a0a..5c3ae6f8 100644 --- a/tests/features/chat/ui/message-stream/work-log.test.tsx +++ b/tests/features/chat/ui/message-stream/stream-items.test.tsx @@ -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("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(".codex-panel__tool-summary")); + const summary = expectPresent(element.querySelector(".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(".codex-panel__agent-activity-summary")); + const summary = expectPresent(element.querySelector(".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("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('[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('[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); }); diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index b6d9425e..10e5363c 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -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; + textActionsByItemId?: ReadonlyMap; }; export function emptyDisclosures(): MessageStreamDisclosureState { @@ -76,9 +79,8 @@ export function testDisclosures( overrides: Partial> = {}, ): 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),