diff --git a/eslint.config.mjs b/eslint.config.mjs index 182dacd2..775853ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -103,8 +103,8 @@ const chatExternalDomBridgeFiles = [ "src/features/chat/ui/message-virtualizer.ts", ]; const chatPreactDomBridgeFiles = [ - "src/features/chat/ui/message-stream/message-actions.tsx", - "src/features/chat/ui/message-stream/message-item.tsx", + "src/features/chat/ui/message-stream/text-item-actions.tsx", + "src/features/chat/ui/message-stream/text-item.tsx", "src/features/chat/ui/message-stream/render.tsx", "src/features/chat/ui/composer.tsx", "src/features/chat/ui/goal-banner.tsx", diff --git a/src/features/chat/composition-actions.ts b/src/features/chat/composition-actions.ts index 44576d1a..7de26237 100644 --- a/src/features/chat/composition-actions.ts +++ b/src/features/chat/composition-actions.ts @@ -81,12 +81,12 @@ export function createChatControllerCompositionActions( const status = { set: ports.status.set, addSystemMessage: (text: string) => { - ports.state.stateStore.dispatch({ type: "transcript/system-message-added", item: ports.state.systemItem(text) }); + ports.state.stateStore.dispatch({ type: "transcript/system-item-added", item: ports.state.systemItem(text) }); render.now(); }, addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => { ports.state.stateStore.dispatch({ - type: "transcript/system-message-added", + type: "transcript/system-item-added", item: ports.state.structuredSystemItem(text, details), }); render.now(); diff --git a/src/features/chat/composition.ts b/src/features/chat/composition.ts index a781636a..f9b6c096 100644 --- a/src/features/chat/composition.ts +++ b/src/features/chat/composition.ts @@ -4,21 +4,21 @@ import type { ChatServerMetadataActions } from "./connection/server-actions/meta import type { ChatServerThreadActions } from "./connection/server-actions/threads"; import type { ChatComposerController } from "./conversation/composer/controller"; import type { ChatInboundController } from "./protocol/inbound/controller"; -import type { ChatThreadGoalActions } from "./threads/thread-goal-actions"; +import type { GoalActions } from "./threads/goal-actions"; import { createChatRuntimeSettingsActions, type ChatRuntimeSettingsActions } from "./runtime/settings-actions"; -import type { ChatThreadActions } from "./threads/thread-actions"; -import type { ThreadHistoryController } from "./threads/thread-history-controller"; -import type { ThreadRenameController } from "./threads/thread-rename-controller"; +import type { ChatThreadActions } from "./threads/actions"; +import type { HistoryController } from "./threads/history-controller"; +import type { RenameController } from "./threads/rename-controller"; import type { ToolbarPanelController } from "./panel/regions/toolbar"; import type { ChatConnectionController } from "./connection/connection-controller"; import type { ChatReconnectActions } from "./connection/reconnect-actions"; import type { PendingRequestController } from "./conversation/pending-requests/controller"; import { rejectServerRequest, respondToServerRequest } from "./protocol/requests/server-request-responder"; import type { ComposerSubmissionActions } from "./conversation/turns/composer-submission-actions"; -import type { RestoredThreadController } from "./threads/restored-thread-controller"; -import type { ThreadIdentitySync } from "./threads/thread-identity-sync"; -import type { ThreadResumeController } from "./threads/thread-resume-controller"; -import type { ThreadSelectionActions } from "./threads/thread-selection-actions"; +import type { RestorationController } from "./threads/restoration-controller"; +import type { IdentitySync } from "./threads/identity-sync"; +import type { ResumeController } from "./threads/resume-controller"; +import type { SelectionActions } from "./threads/selection-actions"; import type { ChatViewRenderController } from "./panel/view-render-controller"; import type { ChatMessageRenderer } from "./ui/message-stream/renderer"; import type { ChatControllerCompositionPorts } from "./composition-ports"; @@ -53,17 +53,17 @@ export interface ChatViewControllers { diagnostics: ChatServerDiagnosticsActions; }; thread: { - history: ThreadHistoryController; - resume: ThreadResumeController; + history: HistoryController; + resume: ResumeController; actions: ChatThreadActions; - restored: RestoredThreadController; - identity: ThreadIdentitySync; - rename: ThreadRenameController; - selection: ThreadSelectionActions; + restoration: RestorationController; + identity: IdentitySync; + rename: RenameController; + selection: SelectionActions; }; runtime: { settings: ChatRuntimeSettingsActions; - goals: ChatThreadGoalActions; + goals: GoalActions; }; requests: { pending: PendingRequestController; @@ -105,7 +105,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) }, }); let connectionController: ChatConnectionController | null = null; - let threadSelection: ThreadSelectionActions | null = null; + let selection: SelectionActions | null = null; let composerController: ChatComposerController | null = null; const actions = createChatControllerCompositionActions( { @@ -121,7 +121,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) ensureConnected: () => requireComposedController(connectionController, "connection controller").ensureConnected(), refreshThreads: () => requireComposedController(connectionController, "connection controller").refreshThreads(), refreshSkills: (forceReload) => requireComposedController(connectionController, "connection controller").refreshSkills(forceReload), - selectThread: (threadId) => requireComposedController(threadSelection, "thread selection actions").selectThread(threadId), + selectThread: (threadId) => requireComposedController(selection, "selection actions").selectThread(threadId), setComposerText: (text) => { requireComposedController(composerController, "composer controller").setDraft(text, { focus: true }); }, @@ -182,8 +182,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) connection, }, ); - const { history, threadActions, goals, threadIdentity } = threadControllers; - const { restoredThread, threadResume, threadRename } = threadControllers; + const { history, actions: threadActions, goals, identity, restoration, resume, rename } = threadControllers; const lifecycleActions = { deferredTasks: ports.lifecycle.deferredTasks, resumeWork: ports.lifecycle.resumeWork, @@ -210,10 +209,10 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) render: actions.render, thread: { restorePlaceholder: (restoredThreadState) => { - restoredThread.restore(restoredThreadState); + restoration.restore(restoredThreadState); }, clearRestoredLifecycle: () => { - restoredThread.clear(); + restoration.clear(); }, }, }, @@ -221,7 +220,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) threadActions, }, ); - threadSelection = createThreadSelectionActionGroup( + selection = createThreadSelectionActionGroup( { plugin: { focusThreadInOpenView: (threadId) => ports.plugin.focusThreadInOpenView(threadId), @@ -230,7 +229,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) stateStore: ports.state.stateStore, }, thread: { - resumeThread: (threadId) => threadResume.resumeThread(threadId), + resumeThread: (threadId) => resume.resumeThread(threadId), }, status: actions.status, }, @@ -239,7 +238,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) toolbarPanels.closeForThreadSelection(); }, }, - ).threadSelection; + ).selection; const { reconnectActions } = createChatReconnectControllerGroup( { state: { @@ -250,7 +249,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) render: actions.render, status: actions.status, thread: { - resumeThread: (threadId) => threadResume.resumeThread(threadId), + resumeThread: (threadId) => resume.resumeThread(threadId), }, }, { @@ -309,7 +308,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) { serverMetadata, serverDiagnostics, - threadRename, + rename, respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result), rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message), }, @@ -331,7 +330,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) loadSharedThreadList: ports.thread.loadSharedThreadList, refreshTabHeader: ports.thread.refreshTabHeader, resetTurnPresence: (hadTurns) => { - threadRename.resetThreadTurnPresence(hadTurns); + rename.resetThreadTurnPresence(hadTurns); }, }, status: actions.status, @@ -399,7 +398,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) selectThread: actions.thread.selectThread, notifyIdentityChanged: ports.thread.notifyIdentityChanged, resetTurnPresence: (hadTurns) => { - threadRename.resetThreadTurnPresence(hadTurns); + rename.resetThreadTurnPresence(hadTurns); }, }, status: actions.status, @@ -423,7 +422,6 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) serverThreads, runtimeSettings, threadActions, - threadRename, reconnectActions, goals, history, @@ -493,12 +491,12 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts) }, thread: { history, - resume: threadResume, + resume, actions: threadActions, - restored: restoredThread, - identity: threadIdentity, - rename: threadRename, - selection: threadSelection, + restoration, + identity, + rename, + selection, }, runtime: { settings: runtimeSettings, diff --git a/src/features/chat/connection/composition.ts b/src/features/chat/connection/composition.ts index a5dbb688..c6386e2b 100644 --- a/src/features/chat/connection/composition.ts +++ b/src/features/chat/connection/composition.ts @@ -12,8 +12,8 @@ import { createChatServerThreadActions } from "./server-actions/threads"; import { ChatConnectionController } from "./connection-controller"; import { createChatReconnectActions } from "./reconnect-actions"; import type { rejectServerRequest, respondToServerRequest } from "../protocol/requests/server-request-responder"; -import type { ChatThreadGoalActions } from "../threads/thread-goal-actions"; -import type { ThreadRenameController } from "../threads/thread-rename-controller"; +import type { GoalActions } from "../threads/goal-actions"; +import type { RenameController } from "../threads/rename-controller"; import { ChatInboundController } from "../protocol/inbound/controller"; import type { ChatConnectionWorkTracker } from "../lifecycle"; @@ -35,7 +35,7 @@ export function createChatServerActionControllers( context: ChatServerActionControllerPorts, refs: { connection: ConnectionManager; - goals: ChatThreadGoalActions; + goals: GoalActions; }, ) { const { plugin, runtime } = context; @@ -97,7 +97,7 @@ export function createChatInboundController( refs: { serverMetadata: ChatServerMetadataActions; serverDiagnostics: ChatServerDiagnosticsActions; - threadRename: ThreadRenameController; + rename: RenameController; respondToServerRequest: (requestId: Parameters[1], result: unknown) => boolean; rejectServerRequest: (requestId: Parameters[1], code: number, message: string) => boolean; }, @@ -114,7 +114,7 @@ export function createChatInboundController( refreshSkills: (forceReload) => void thread.refreshSkills(forceReload), publishAppServerMetadata: thread.publishAppServerMetadataSnapshot, maybeNameThread: (threadId, turnId, completedSummary) => { - refs.threadRename.maybeAutoNameThread(threadId, turnId, completedSummary); + refs.rename.maybeAutoNameThread(threadId, turnId, completedSummary); }, notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin), notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin), diff --git a/src/features/chat/connection/server-actions/diagnostics.ts b/src/features/chat/connection/server-actions/diagnostics.ts index 01036618..8f9b071b 100644 --- a/src/features/chat/connection/server-actions/diagnostics.ts +++ b/src/features/chat/connection/server-actions/diagnostics.ts @@ -11,7 +11,7 @@ import { type McpServerStatusSummary, } from "../../../../app-server/diagnostics"; import type { SharedAppServerMetadata } from "../../../../app-server/shared-cache-state"; -import { mcpStatusLines as buildMcpStatusLines } from "../../display/diagnostics"; +import { mcpStatusLines as buildMcpStatusLines } from "../../display/status/diagnostics"; import { cloneAppServerDiagnostics, type ChatServerActionHost } from "./host"; interface RefreshDiagnosticProbesOptions { diff --git a/src/features/chat/connection/server-actions/threads.ts b/src/features/chat/connection/server-actions/threads.ts index ead3891c..016b9ada 100644 --- a/src/features/chat/connection/server-actions/threads.ts +++ b/src/features/chat/connection/server-actions/threads.ts @@ -3,7 +3,7 @@ import type { Thread } from "../../../../domain/threads/model"; import type { RuntimeSnapshot } from "../../runtime/snapshot"; import { runtimeConfigOrDefault } from "../../runtime/effective"; import { serviceTierRequestForThreadStart } from "../../runtime/thread-settings"; -import { resumedThreadActionFromAppServerResponse } from "../../threads/thread-resume"; +import { resumedThreadActionFromAppServerResponse } from "../../threads/resume"; import type { ChatServerActionHost } from "./host"; import type { ChatState } from "../../state/reducer"; diff --git a/src/features/chat/conversation/composition.ts b/src/features/chat/conversation/composition.ts index 9bd978f6..9e818888 100644 --- a/src/features/chat/conversation/composition.ts +++ b/src/features/chat/conversation/composition.ts @@ -11,10 +11,9 @@ import { createComposerSubmissionActions } from "./turns/composer-submission-act import { createPlanImplementationActions } from "./turns/plan-implementation-actions"; import { createSlashCommandActions } from "./turns/slash-command-actions"; import { TurnSubmissionController } from "./turns/turn-submission-controller"; -import type { ChatThreadActions } from "../threads/thread-actions"; -import type { ChatThreadGoalActions } from "../threads/thread-goal-actions"; -import type { ThreadHistoryController } from "../threads/thread-history-controller"; -import type { ThreadRenameController } from "../threads/thread-rename-controller"; +import type { ChatThreadActions } from "../threads/actions"; +import type { GoalActions } from "../threads/goal-actions"; +import type { HistoryController } from "../threads/history-controller"; import type { ChatInboundController } from "../protocol/inbound/controller"; import { currentModel, runtimeConfigOrDefault } from "../runtime/effective"; import type { RuntimeSnapshot } from "../runtime/snapshot"; @@ -94,10 +93,9 @@ export function createConversationSurfaceControllerGroup( serverThreads: ChatServerThreadActions; runtimeSettings: ChatRuntimeSettingsActions; threadActions: ChatThreadActions; - threadRename: ThreadRenameController; reconnectActions: ChatReconnectActions; - goals: ChatThreadGoalActions; - history: ThreadHistoryController; + goals: GoalActions; + history: HistoryController; }, ) { const { plugin, state, render, messages, composerView, runtime, thread, liveState, status, lifecycle, client, scroll } = context; diff --git a/src/features/chat/conversation/pending-requests/controller.ts b/src/features/chat/conversation/pending-requests/controller.ts index af0396ad..b50153e8 100644 --- a/src/features/chat/conversation/pending-requests/controller.ts +++ b/src/features/chat/conversation/pending-requests/controller.ts @@ -3,7 +3,7 @@ import type { ApprovalAction, PendingApproval } from "../../protocol/requests/ap import type { ChatInboundController } from "../../protocol/inbound/controller"; import { pendingRequestFocusSignature } from "./signatures"; import { pendingRequestBlockSnapshot, type PendingRequestBlockSnapshot } from "./snapshot"; -import type { PendingRequestMessageActions } from "../../ui/pending-request-message"; +import type { PendingRequestBlockActions } from "../../ui/pending-request-block"; import { answersForPendingUserInput, type PendingUserInput } from "../../protocol/requests/user-input"; export interface PendingRequestControllerHost { @@ -16,7 +16,7 @@ export interface PendingRequestControllerHost { export class PendingRequestController { private lastFocusSignature = ""; - private readonly messageActions: PendingRequestMessageActions = { + private readonly blockActions: PendingRequestBlockActions = { resolveApproval: (approval, action) => { this.resolveApproval(approval, action); }, @@ -40,8 +40,8 @@ export class PendingRequestController { return pendingRequestBlockSnapshot(this.host.stateStore.getState()); } - actions(): PendingRequestMessageActions { - return this.messageActions; + actions(): PendingRequestBlockActions { + return this.blockActions; } resolveApproval(approval: PendingApproval, action: ApprovalAction): void { diff --git a/src/features/chat/conversation/turns/turn-submission.ts b/src/features/chat/conversation/turns/turn-submission.ts index e9f0413e..cb55013e 100644 --- a/src/features/chat/conversation/turns/turn-submission.ts +++ b/src/features/chat/conversation/turns/turn-submission.ts @@ -1,7 +1,7 @@ import type { PendingTurnStart } from "../../state/reducer"; import type { DisplayFileMention, DisplayItem, MessageDisplayItem } from "../../display/types"; -import { fileMentionsFromInput, userMessageDisplayText } from "../../protocol/display-items"; -import { attachHookRunsToTurn } from "../../display/hooks"; +import { fileMentionsFromInput, userMessageDisplayText } from "../../display/items/user-message"; +import { attachHookRunsToTurn } from "../../state/transcript-updates"; import type { CodexInput } from "../../../../app-server/request-input"; export interface LocalUserMessageParams { diff --git a/src/features/chat/display/paths.ts b/src/features/chat/display/details/path-labels.ts similarity index 100% rename from src/features/chat/display/paths.ts rename to src/features/chat/display/details/path-labels.ts diff --git a/src/features/chat/display/permission-rows.ts b/src/features/chat/display/details/permission-rows.ts similarity index 98% rename from src/features/chat/display/permission-rows.ts rename to src/features/chat/display/details/permission-rows.ts index 64e4c32d..af2ca154 100644 --- a/src/features/chat/display/permission-rows.ts +++ b/src/features/chat/display/details/permission-rows.ts @@ -1,4 +1,4 @@ -import { jsonPreview } from "../../../utils"; +import { jsonPreview } from "../../../../utils"; interface DetailRow { key: string; diff --git a/src/features/chat/display/tool-format.ts b/src/features/chat/display/details/tool-details.ts similarity index 97% rename from src/features/chat/display/tool-format.ts rename to src/features/chat/display/details/tool-details.ts index 791d270c..0ea348f7 100644 --- a/src/features/chat/display/tool-format.ts +++ b/src/features/chat/display/details/tool-details.ts @@ -1,5 +1,5 @@ -import type { DisplayDetailMetaRow, DisplayDetailSection } from "./types"; -import { jsonPreview, truncate } from "../../../utils"; +import type { DisplayDetailMetaRow, DisplayDetailSection } from "../types"; +import { jsonPreview, truncate } from "../../../../utils"; const TOOL_SUMMARY_LIMIT = 140; diff --git a/src/features/chat/display/action-candidates.ts b/src/features/chat/display/item-actions.ts similarity index 63% rename from src/features/chat/display/action-candidates.ts rename to src/features/chat/display/item-actions.ts index 1b33021c..0065f572 100644 --- a/src/features/chat/display/action-candidates.ts +++ b/src/features/chat/display/item-actions.ts @@ -1,21 +1,14 @@ -import { - chatTurnBusy, - type ChatActiveThreadState, - type ChatRuntimeState, - type ChatTranscriptState, - type ChatTurnState, -} from "../state/reducer"; +import { isCompletedTurnOutcomeMessage } from "./predicates"; import type { DisplayItem, MessageDisplayItem } from "./types"; -import { isCompletedTurnOutcomeMessage } from "./turn-outcome-message"; export interface ForkCandidate { - itemId: string; + displayItemId: string; turnId: string; } export interface RollbackCandidate { turnId: string; - itemId: string; + displayItemId: string; text: string; } @@ -23,13 +16,13 @@ export function forkCandidatesFromItems(items: readonly DisplayItem[]): readonly const turnOutcomeItemsByTurn = new Map(); for (const item of items) { if (!item.turnId || !isCompletedTurnOutcomeMessage(item)) continue; - turnOutcomeItemsByTurn.set(item.turnId, { itemId: item.id, turnId: item.turnId }); + turnOutcomeItemsByTurn.set(item.turnId, { displayItemId: item.id, turnId: item.turnId }); } return [...turnOutcomeItemsByTurn.values()]; } export function isForkCandidateItem(item: DisplayItem, candidates: readonly ForkCandidate[]): boolean { - return candidates.some((candidate) => item.id === candidate.itemId && item.turnId === candidate.turnId); + return candidates.some((candidate) => item.id === candidate.displayItemId && item.turnId === candidate.turnId); } export function turnsAfterTurnId(items: readonly DisplayItem[], turnId: string): number | null { @@ -47,28 +40,18 @@ export function rollbackCandidateFromItems(items: readonly DisplayItem[]): Rollb return { turnId: lastTurnId, - itemId: userMessage.id, + displayItemId: userMessage.id, text: userMessage.text, }; } export function isRollbackCandidateItem(item: DisplayItem, candidate: RollbackCandidate | null): boolean { return Boolean( - candidate && item.kind === "message" && item.role === "user" && item.id === candidate.itemId && item.turnId === candidate.turnId, - ); -} - -export function implementPlanCandidateFromState(state: { - activeThread: Pick; - turn: ChatTurnState; - runtime: Pick; - transcript: Pick; -}): DisplayItem | null { - if (!state.activeThread.id || chatTurnBusy(state) || state.runtime.selectedCollaborationMode !== "plan") { - return null; - } - return ( - [...state.transcript.displayItems].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null + candidate && + item.kind === "message" && + item.role === "user" && + item.id === candidate.displayItemId && + item.turnId === candidate.turnId, ); } diff --git a/src/features/chat/display/agent.ts b/src/features/chat/display/items/agent.ts similarity index 57% rename from src/features/chat/display/agent.ts rename to src/features/chat/display/items/agent.ts index 6fb999e3..145eb5da 100644 --- a/src/features/chat/display/agent.ts +++ b/src/features/chat/display/items/agent.ts @@ -1,8 +1,6 @@ -import type { AgentDisplayItem, AgentRunSummary, AgentRunSummaryAgent, AgentStateDisplay, DisplayItem, ExecutionState } from "./types"; -import { definedProp, truncate } from "../../../utils"; +import type { AgentDisplayItem, AgentStateDisplay, ExecutionState } from "../types"; +import { definedProp } from "../../../../utils"; -const ACTIVE_AGENT_PREVIEW_LIMIT = 96; -type AgentRunState = "running" | "completed" | "failed"; type DisplayExecutionState = Exclude; type ExecutionStateByStatus = Readonly>; @@ -51,7 +49,7 @@ export function agentDisplayItem(item: DisplayCollabAgentToolCall, turnId?: stri role: "tool", text: `${agentActivitySummaryLabel(item.tool)}\nstatus: ${item.status}${receiverText}${promptText}`, ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, tool: item.tool, status: item.status, senderThreadId: item.senderThreadId, @@ -73,73 +71,6 @@ export function agentActivitySummaryLabel(tool: string): string { return `Agent ${tool}`; } -export function agentActivityMetaLabel(tool: string): string { - if (tool === "spawnAgent") return "spawn"; - if (tool === "sendInput") return "send input"; - if (tool === "resumeAgent") return "resume"; - if (tool === "wait") return "wait"; - if (tool === "closeAgent") return "close"; - return tool; -} - -export function activeAgentRunSummary(items: readonly DisplayItem[], activeTurnId: string | null): AgentRunSummary | null { - if (!activeTurnId) return null; - - const agentStatuses = new Map(); - for (const item of items) { - if (item.kind !== "agent" || item.turnId !== activeTurnId) continue; - if (item.agents.length > 0) { - for (const agent of item.agents) { - agentStatuses.set(agent.threadId, agent); - } - } else { - for (const threadId of item.receiverThreadIds) { - agentStatuses.set(threadId, { threadId, status: item.status, message: null }); - } - } - } - - if (agentStatuses.size === 0) return null; - - const summary = { running: 0, completed: 0, failed: 0, agents: [] as AgentRunSummaryAgent[], additionalAgents: 0 }; - const agents = [...agentStatuses.values()]; - for (const agent of agents) { - const state = agentRunState(agent.status); - summary[state] += 1; - } - - if (summary.running === 0 && summary.failed === 0) return null; - - summary.agents = agents - .filter((agent) => agentRunState(agent.status) === "running") - .sort((a, b) => a.threadId.localeCompare(b.threadId)) - .map((agent) => ({ - threadId: agent.threadId, - status: agent.status, - messagePreview: agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT), - })); - - return summary; -} - -export function agentRunSummaryLabel(summary: AgentRunSummary): string { - const parts: string[] = []; - if (summary.failed > 0) parts.push(`${String(summary.failed)} failed`); - if (summary.running > 0) parts.push(`${String(summary.running)} running`); - if (summary.completed > 0) parts.push(`${String(summary.completed)} done`); - return `Agents ${parts.join(", ")}`; -} - -export function agentMessagePreview(message: string | null, maxLength: number): string | null { - if (!message) return null; - const firstLine = message - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0); - if (!firstLine) return null; - return truncate(firstLine.replace(/\s+/g, " "), maxLength); -} - function agentStatesDisplay(states: DisplayCollabAgentToolCall["agentsStates"]): AgentStateDisplay[] { return Object.entries(states) .map(([threadId, state]) => ({ @@ -163,10 +94,6 @@ function collabAgentExecutionState(tool: string, status: string, receiverThreadI return null; } -function agentRunState(status: string): AgentRunState { - return collabAgentStateExecutionState(status) ?? "running"; -} - export function collabAgentStateExecutionState(status: string): ExecutionState { return executionStateFromStatus(status, AGENT_STATES); } diff --git a/src/features/chat/display/goal-messages.ts b/src/features/chat/display/items/goal.ts similarity index 91% rename from src/features/chat/display/goal-messages.ts rename to src/features/chat/display/items/goal.ts index e254f0bf..fb6d6b58 100644 --- a/src/features/chat/display/goal-messages.ts +++ b/src/features/chat/display/items/goal.ts @@ -1,6 +1,6 @@ -import type { ThreadGoal, ThreadGoalStatus } from "../../../app-server/thread-goal"; -import { truncate } from "../../../utils"; -import type { GoalDisplayItem } from "./types"; +import type { ThreadGoal, ThreadGoalStatus } from "../../../../app-server/thread-goal"; +import { truncate } from "../../../../utils"; +import type { GoalDisplayItem } from "../types"; const GOAL_SUMMARY_LIMIT = 140; diff --git a/src/features/chat/display/hooks.ts b/src/features/chat/display/items/hook-run.ts similarity index 55% rename from src/features/chat/display/hooks.ts rename to src/features/chat/display/items/hook-run.ts index 84ccace5..5bfa98d0 100644 --- a/src/features/chat/display/hooks.ts +++ b/src/features/chat/display/items/hook-run.ts @@ -1,5 +1,5 @@ -import { definedProp } from "../../../utils"; -import type { DisplayDetailMetaRow, DisplayDetailSection, DisplayItem, HookDisplayItem } from "./types"; +import { definedProp } from "../../../../utils"; +import type { DisplayDetailMetaRow, DisplayDetailSection, HookDisplayItem } from "../types"; interface DisplayHookRun { id: string; @@ -28,7 +28,7 @@ export function hookRunDisplayItem(run: DisplayHookRun, turnId: string | null, s text: hookSummary(run.eventName, run.statusMessage), toolLabel: "hook", ...definedProp("turnId", turnId), - itemId: displayId, + sourceItemId: displayId, status, details, output: "", @@ -44,31 +44,6 @@ function hookEventName(eventName: string | null | undefined): string { return trimmed && trimmed.length > 0 ? trimmed : "Hook"; } -export function attachHookRunsToTurn( - items: readonly DisplayItem[], - turnId: string, - hookItemIds: readonly string[], - afterItemId?: string | null, -): DisplayItem[] { - const hookIdSet = new Set(hookItemIds); - const attachedHooks = items.filter((item) => hookIdSet.has(item.id)).map((item) => ({ ...item, turnId })); - if (attachedHooks.length === 0) return [...items]; - - const withoutAttachedHooks = items.filter((item) => !hookIdSet.has(item.id)); - const anchorItemId = afterItemId ?? lastUserMessageAnchorId(withoutAttachedHooks, turnId); - if (!anchorItemId) return [...withoutAttachedHooks, ...attachedHooks]; - const insertAfterIndex = withoutAttachedHooks.findIndex((item) => item.id === anchorItemId); - if (insertAfterIndex === -1) return [...withoutAttachedHooks, ...attachedHooks]; - return [...withoutAttachedHooks.slice(0, insertAfterIndex + 1), ...attachedHooks, ...withoutAttachedHooks.slice(insertAfterIndex + 1)]; -} - -function lastUserMessageAnchorId(items: readonly DisplayItem[], turnId: string): string | null { - const anchor = [...items] - .reverse() - .find((item) => item.kind === "message" && item.role === "user" && (!item.turnId || item.turnId === turnId)); - return anchor?.id ?? null; -} - function hookSummary(eventName: string | null | undefined, statusMessage: string | null | undefined): string { const message = statusMessage?.trim(); const event = hookEventName(eventName); diff --git a/src/features/chat/display/items/proposed-plan.ts b/src/features/chat/display/items/proposed-plan.ts new file mode 100644 index 00000000..bb494869 --- /dev/null +++ b/src/features/chat/display/items/proposed-plan.ts @@ -0,0 +1,6 @@ +export function normalizeProposedPlanMarkdown(text: string): string { + return text + .replace(/^\s*\s*\n?/i, "") + .replace(/\n?\s*<\/proposed_plan>\s*$/i, "") + .trim(); +} diff --git a/src/features/chat/display/review.ts b/src/features/chat/display/items/review-result.ts similarity index 97% rename from src/features/chat/display/review.ts rename to src/features/chat/display/items/review-result.ts index d3f89380..31b5ec13 100644 --- a/src/features/chat/display/review.ts +++ b/src/features/chat/display/items/review-result.ts @@ -1,6 +1,6 @@ -import { permissionRows } from "./permission-rows"; -import type { DisplayItem, ExecutionState } from "./types"; -import { pathsRelativeToRoot } from "./paths"; +import { permissionRows } from "../details/permission-rows"; +import type { DisplayItem, ExecutionState } from "../types"; +import { pathsRelativeToRoot } from "../details/path-labels"; type DisplayExecutionState = Exclude; type ExecutionStateByStatus = Readonly>; diff --git a/src/features/chat/display/system.ts b/src/features/chat/display/items/system.ts similarity index 78% rename from src/features/chat/display/system.ts rename to src/features/chat/display/items/system.ts index 505f5645..f04484f3 100644 --- a/src/features/chat/display/system.ts +++ b/src/features/chat/display/items/system.ts @@ -1,5 +1,5 @@ -import type { DisplayItem } from "./types"; -import type { DisplayDetailSection } from "./types"; +import type { DisplayItem } from "../types"; +import type { DisplayDetailSection } from "../types"; export function createSystemItem(id: string, text: string): DisplayItem { return { diff --git a/src/features/chat/display/plan.ts b/src/features/chat/display/items/task-progress.ts similarity index 75% rename from src/features/chat/display/plan.ts rename to src/features/chat/display/items/task-progress.ts index ef625d94..16c1ebf6 100644 --- a/src/features/chat/display/plan.ts +++ b/src/features/chat/display/items/task-progress.ts @@ -1,4 +1,4 @@ -import type { DisplayItem, ExecutionState } from "./types"; +import type { DisplayItem, ExecutionState } from "../types"; type DisplayExecutionState = Exclude; type ExecutionStateByStatus = Readonly>; @@ -16,26 +16,13 @@ export interface TaskPlanStep { status: TaskStepStatus; } -export function taskStatusMarker(status: TaskStepStatus): string { - if (status === "completed") return "[x]"; - if (status === "inProgress") return "[>]"; - return "[ ]"; -} - export function taskProgressExecutionState(status: string): ExecutionState { return executionStateFromStatus(status, TASK_STATES); } -export function normalizeProposedPlanMarkdown(text: string): string { - return text - .replace(/^\s*\s*\n?/i, "") - .replace(/\n?\s*<\/proposed_plan>\s*$/i, "") - .trim(); -} - -export function planProgressDisplayItem(turnId: string, explanation: string | null, plan: readonly TaskPlanStep[]): DisplayItem { +export function taskProgressDisplayItem(turnId: string, explanation: string | null, plan: readonly TaskPlanStep[]): DisplayItem { const trimmedExplanation = explanation?.trim(); - const lines = plan.map((step) => `${taskStatusMarker(step.status)} ${step.step}`); + const lines = plan.map((step) => `${taskProgressTextMarker(step.status)} ${step.step}`); const body = [trimmedExplanation, ...lines].filter((line): line is string => Boolean(line && line.length > 0)).join("\n"); const status = plan.some((step) => step.status === "inProgress" || step.status === "pending") ? "inProgress" : "completed"; return { @@ -44,7 +31,7 @@ export function planProgressDisplayItem(turnId: string, explanation: string | nu role: "tool", text: body.length > 0 ? body : "Plan updated", turnId, - itemId: `plan-progress-${turnId}`, + sourceItemId: `plan-progress-${turnId}`, explanation: trimmedExplanation !== undefined && trimmedExplanation.length > 0 ? trimmedExplanation : null, steps: plan.map((step) => ({ step: step.step, status: step.status })), status, @@ -52,6 +39,12 @@ export function planProgressDisplayItem(turnId: string, explanation: string | nu }; } +function taskProgressTextMarker(status: TaskStepStatus): string { + if (status === "completed") return "[x]"; + if (status === "inProgress") return "[>]"; + return "[ ]"; +} + function executionStateFromStatus(status: string, states: ExecutionStateByStatus): ExecutionState { return states[status] ?? null; } diff --git a/src/features/chat/display/items/user-message.ts b/src/features/chat/display/items/user-message.ts new file mode 100644 index 00000000..fe20e08e --- /dev/null +++ b/src/features/chat/display/items/user-message.ts @@ -0,0 +1,109 @@ +import type { CodexInput, CodexInputItem } from "../../../../app-server/request-input"; +import type { DisplayFileMention } from "../types"; + +type TextRange = [number, number]; + +export function fileMentionsFromInput(input: readonly CodexInputItem[]): DisplayFileMention[] { + const seen = new Set(); + const mentions: DisplayFileMention[] = []; + for (const item of input) { + if (item.type !== "mention" || seen.has(item.path)) continue; + seen.add(item.path); + mentions.push({ name: item.name, path: item.path }); + } + return mentions; +} + +export function userMessageDisplayText(text: string, input: CodexInput): string { + const names = resolvedSkillNames(input); + if (names.length === 0) return text; + + const pattern = new RegExp(`(^|[\\s([{])\\$(${names.map(escapeRegExp).join("|")})(?=$|[\\s\\])}.,;!?])`, "gi"); + const codeRanges = markdownCodeRanges(text); + return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => { + const dollarIndex = offset + prefix.length; + return isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`; + }); +} + +function resolvedSkillNames(input: readonly CodexInputItem[]): string[] { + const seen = new Set(); + const names: string[] = []; + for (const item of input) { + if (item.type !== "skill") continue; + const key = item.name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + names.push(item.name); + } + return names.sort((a, b) => b.length - a.length); +} + +function markdownCodeSpan(text: string): string { + if (!text.includes("`")) return `\`${text}\``; + const longestRun = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length)); + const delimiter = "`".repeat(longestRun + 1); + return `${delimiter} ${text} ${delimiter}`; +} + +function markdownCodeRanges(text: string): TextRange[] { + return [...markdownFenceRanges(text), ...markdownInlineCodeRanges(text)].sort((a, b) => a[0] - b[0]); +} + +function markdownFenceRanges(text: string): TextRange[] { + const ranges: TextRange[] = []; + let active: { marker: string; start: number } | null = null; + let offset = 0; + for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) { + const value = line[0]; + if (value.length === 0) break; + const fence = /^(?: {0,3})(`{3,}|~{3,})/.exec(value); + if (fence) { + const marker = fence[1]; + if (!marker) continue; + if (!active) { + active = { marker, start: offset }; + } else if (marker.startsWith(active.marker.charAt(0)) && marker.length >= active.marker.length) { + ranges.push([active.start, offset + value.length]); + active = null; + } + } + offset += value.length; + } + if (active) ranges.push([active.start, text.length]); + return ranges; +} + +function markdownInlineCodeRanges(text: string): TextRange[] { + const ranges: TextRange[] = []; + const fenceRanges = markdownFenceRanges(text); + let index = 0; + while (index < text.length) { + if (isIndexInRanges(index, fenceRanges) || text[index] !== "`") { + index += 1; + continue; + } + const match = /`+/.exec(text.slice(index)); + if (!match) { + index += 1; + continue; + } + const delimiter = match[0]; + const end = text.indexOf(delimiter, index + delimiter.length); + if (end < 0) { + index += delimiter.length; + continue; + } + ranges.push([index, end + delimiter.length]); + index = end + delimiter.length; + } + return ranges; +} + +function isIndexInRanges(index: number, ranges: readonly TextRange[]): boolean { + return ranges.some(([start, end]) => index >= start && index < end); +} + +function escapeRegExp(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} diff --git a/src/features/chat/display/turn-outcome-message.ts b/src/features/chat/display/predicates.ts similarity index 100% rename from src/features/chat/display/turn-outcome-message.ts rename to src/features/chat/display/predicates.ts diff --git a/src/features/chat/display/diagnostics.ts b/src/features/chat/display/status/diagnostics.ts similarity index 97% rename from src/features/chat/display/diagnostics.ts rename to src/features/chat/display/status/diagnostics.ts index 749195b8..a207e447 100644 --- a/src/features/chat/display/diagnostics.ts +++ b/src/features/chat/display/status/diagnostics.ts @@ -1,12 +1,12 @@ -import { DIAGNOSTIC_PROBE_METHODS, appServerIdentity, appServerPlatform } from "../../../app-server/diagnostics"; -import { CLIENT_VERSION } from "../../../constants"; +import { DIAGNOSTIC_PROBE_METHODS, appServerIdentity, appServerPlatform } from "../../../../app-server/diagnostics"; +import { CLIENT_VERSION } from "../../../../constants"; import type { Diagnostics, InitializeDiagnostics, DiagnosticProbeResult, McpServerDiagnostic, McpServerStatusSummary, -} from "../../../app-server/diagnostics"; +} from "../../../../app-server/diagnostics"; interface DiagnosticRow { label: string; diff --git a/src/features/chat/display/runtime-status.ts b/src/features/chat/display/status/runtime.ts similarity index 95% rename from src/features/chat/display/runtime-status.ts rename to src/features/chat/display/status/runtime.ts index 4c09de02..fa0405ef 100644 --- a/src/features/chat/display/runtime-status.ts +++ b/src/features/chat/display/status/runtime.ts @@ -1,9 +1,8 @@ -import type { RateLimitWindow, SpendControlLimitSnapshot, ThreadTokenUsage } from "../../../app-server/runtime-metrics"; -import type { RuntimeConfigSnapshot } from "../../../app-server/runtime-config"; -import { jsonPreview } from "../../../utils"; -import { sortedModelMetadata } from "../../../domain/catalog/metadata"; -import { defaultEffortForModelMetadata } from "../../../domain/catalog/metadata"; -import type { ChatState } from "../state/reducer"; +import type { RateLimitWindow, SpendControlLimitSnapshot, ThreadTokenUsage } from "../../../../app-server/runtime-metrics"; +import type { RuntimeConfigSnapshot } from "../../../../app-server/runtime-config"; +import { jsonPreview } from "../../../../utils"; +import { sortedModelMetadata } from "../../../../domain/catalog/metadata"; +import { defaultEffortForModelMetadata } from "../../../../domain/catalog/metadata"; import { currentApprovalsReviewer, currentApprovalPolicy, @@ -14,9 +13,9 @@ import { runtimeConfigOrDefault, serviceTierLabel, supportedReasoningEfforts, -} from "../runtime/effective"; -import type { RuntimeSnapshot } from "../runtime/snapshot"; -import { collaborationModeLabel, pendingRuntimeSettingLabel } from "../runtime/settings"; +} from "../../runtime/effective"; +import type { RuntimeSnapshot } from "../../runtime/snapshot"; +import { collaborationModeLabel, pendingRuntimeSettingLabel } from "../../runtime/settings"; export interface ContextSummary { label: string; @@ -47,21 +46,21 @@ export interface RuntimeConfigSection { } export interface StatusSummaryLinesInput { - activeThreadId: ChatState["activeThread"]["id"]; + activeThreadId: RuntimeSnapshot["activeThreadId"]; snapshot: RuntimeSnapshot; nowMs: number; } export interface ModelStatusLinesInput { - runtimeConfig: ChatState["connection"]["runtimeConfig"]; - requestedModel: ChatState["runtime"]["requestedModel"]; + runtimeConfig: RuntimeSnapshot["runtimeConfig"]; + requestedModel: RuntimeSnapshot["requestedModel"]; snapshot: RuntimeSnapshot; collaborationModeLabel: string; } export interface EffortStatusLinesInput { - runtimeConfig: ChatState["connection"]["runtimeConfig"]; - requestedReasoningEffort: ChatState["runtime"]["requestedReasoningEffort"]; + runtimeConfig: RuntimeSnapshot["runtimeConfig"]; + requestedReasoningEffort: RuntimeSnapshot["requestedReasoningEffort"]; snapshot: RuntimeSnapshot; } diff --git a/src/features/chat/display/stream/agent-summary.ts b/src/features/chat/display/stream/agent-summary.ts new file mode 100644 index 00000000..c2cf4f63 --- /dev/null +++ b/src/features/chat/display/stream/agent-summary.ts @@ -0,0 +1,77 @@ +import { truncate } from "../../../../utils"; +import { collabAgentStateExecutionState } from "../items/agent"; +import type { AgentRunSummary, AgentRunSummaryAgent, AgentStateDisplay, DisplayItem } from "../types"; + +const ACTIVE_AGENT_PREVIEW_LIMIT = 96; +type AgentRunState = "running" | "completed" | "failed"; + +export function agentActivityMetaLabel(tool: string): string { + if (tool === "spawnAgent") return "spawn"; + if (tool === "sendInput") return "send input"; + if (tool === "resumeAgent") return "resume"; + if (tool === "wait") return "wait"; + if (tool === "closeAgent") return "close"; + return tool; +} + +export function activeAgentRunSummary(items: readonly DisplayItem[], activeTurnId: string | null): AgentRunSummary | null { + if (!activeTurnId) return null; + + const agentStatuses = new Map(); + for (const item of items) { + if (item.kind !== "agent" || item.turnId !== activeTurnId) continue; + if (item.agents.length > 0) { + for (const agent of item.agents) { + agentStatuses.set(agent.threadId, agent); + } + } else { + for (const threadId of item.receiverThreadIds) { + agentStatuses.set(threadId, { threadId, status: item.status, message: null }); + } + } + } + + if (agentStatuses.size === 0) return null; + + const summary = { running: 0, completed: 0, failed: 0, agents: [] as AgentRunSummaryAgent[], additionalAgents: 0 }; + const agents = [...agentStatuses.values()]; + for (const agent of agents) { + const state = agentRunState(agent.status); + summary[state] += 1; + } + + if (summary.running === 0 && summary.failed === 0) return null; + + summary.agents = agents + .filter((agent) => agentRunState(agent.status) === "running") + .sort((a, b) => a.threadId.localeCompare(b.threadId)) + .map((agent) => ({ + threadId: agent.threadId, + status: agent.status, + messagePreview: agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT), + })); + + return summary; +} + +export function agentRunSummaryLabel(summary: AgentRunSummary): string { + const parts: string[] = []; + if (summary.failed > 0) parts.push(`${String(summary.failed)} failed`); + if (summary.running > 0) parts.push(`${String(summary.running)} running`); + if (summary.completed > 0) parts.push(`${String(summary.completed)} done`); + return `Agents ${parts.join(", ")}`; +} + +export function agentMessagePreview(message: string | null, maxLength: number): string | null { + if (!message) return null; + const firstLine = message + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (!firstLine) return null; + return truncate(firstLine.replace(/\s+/g, " "), maxLength); +} + +function agentRunState(status: string): AgentRunState { + return collabAgentStateExecutionState(status) ?? "running"; +} diff --git a/src/features/chat/display/blocks.ts b/src/features/chat/display/stream/blocks.ts similarity index 96% rename from src/features/chat/display/blocks.ts rename to src/features/chat/display/stream/blocks.ts index 32ed7c56..75ade26f 100644 --- a/src/features/chat/display/blocks.ts +++ b/src/features/chat/display/stream/blocks.ts @@ -1,6 +1,6 @@ -import type { DisplayBlock, DisplayItem, DisplayKind } from "./types"; -import { isCompletedTurnOutcomeMessage } from "./turn-outcome-message"; -import { pathRelativeToRoot } from "./paths"; +import type { DisplayBlock, DisplayItem, DisplayKind } from "../types"; +import { isCompletedTurnOutcomeMessage } from "../predicates"; +import { pathRelativeToRoot } from "../details/path-labels"; const STEERING_ACTIVITY_LABEL = "steer"; const STEERING_ACTIVITY_KIND = "userSteered"; @@ -79,7 +79,7 @@ function steeringActivityItem(item: DisplayItem, turnId: string): DisplayItem { role: "tool", text: item.text, turnId, - ...(item.itemId ? { itemId: item.itemId } : {}), + ...(item.sourceItemId ? { sourceItemId: item.sourceItemId } : {}), activityKind: STEERING_ACTIVITY_KIND, toolLabel: STEERING_ACTIVITY_LABEL, }; diff --git a/src/features/chat/protocol/display-items.ts b/src/features/chat/display/turn-items.ts similarity index 81% rename from src/features/chat/protocol/display-items.ts rename to src/features/chat/display/turn-items.ts index 26f2ce4d..e161e1c6 100644 --- a/src/features/chat/protocol/display-items.ts +++ b/src/features/chat/display/turn-items.ts @@ -1,12 +1,12 @@ -import type { DisplayDetailSection, DisplayFileChange, DisplayFileMention, DisplayItem, ExecutionState } from "../display/types"; -import type { CodexInput, CodexInputItem } from "../../../app-server/request-input"; +import type { DisplayDetailSection, DisplayFileChange, DisplayItem, ExecutionState } from "./types"; import type { AppServerFileUpdateChange, AppServerThreadItem, AppServerTurn } from "../../../app-server/turn-model"; import { definedProp, truncate } from "../../../utils"; import { referencedThreadDisplayFromPrompt } from "../../../domain/threads/reference"; import { appServerUserItemText } from "../../../app-server/turn-model"; -import { agentDisplayItem } from "../display/agent"; -import { pathRelativeToRoot } from "../display/paths"; -import { normalizeProposedPlanMarkdown } from "../display/plan"; +import { agentDisplayItem } from "./items/agent"; +import { pathRelativeToRoot } from "./details/path-labels"; +import { normalizeProposedPlanMarkdown } from "./items/proposed-plan"; +import { fileMentionsFromInput, userMessageDisplayText } from "./items/user-message"; import { bodyDetail, compactToolSummary, @@ -15,7 +15,7 @@ import { jsonTargetLabel, metaDetail, statusQualifier, -} from "../display/tool-format"; +} from "./details/tool-details"; type UserMessageItem = Extract; type AgentMessageItem = Extract; @@ -34,7 +34,6 @@ type ReviewModeItem = | Extract | Extract; type ContextCompactionItem = Extract; -type TextRange = [number, number]; type DisplayExecutionState = Exclude; type ExecutionStateByStatus = Readonly>; @@ -124,7 +123,7 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display referencedThread: referencedThread.reference, ...definedProp("turnId", turnId), ...definedProp("clientId", item.clientId), - itemId: item.id, + sourceItemId: item.id, ...(mentionedFiles.length > 0 ? { mentionedFiles } : {}), }; } @@ -137,116 +136,11 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display copyText: text, ...definedProp("turnId", turnId), ...definedProp("clientId", item.clientId), - itemId: item.id, + sourceItemId: item.id, ...(mentionedFiles.length > 0 ? { mentionedFiles } : {}), }; } -export function fileMentionsFromInput(input: readonly CodexInputItem[]): DisplayFileMention[] { - const seen = new Set(); - const mentions: DisplayFileMention[] = []; - for (const item of input) { - if (item.type !== "mention" || seen.has(item.path)) continue; - seen.add(item.path); - mentions.push({ name: item.name, path: item.path }); - } - return mentions; -} - -export function userMessageDisplayText(text: string, input: CodexInput): string { - const names = resolvedSkillNames(input); - if (names.length === 0) return text; - - const pattern = new RegExp(`(^|[\\s([{])\\$(${names.map(escapeRegExp).join("|")})(?=$|[\\s\\])}.,;!?])`, "gi"); - const codeRanges = markdownCodeRanges(text); - return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => { - const dollarIndex = offset + prefix.length; - return isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`; - }); -} - -function resolvedSkillNames(input: readonly CodexInputItem[]): string[] { - const seen = new Set(); - const names: string[] = []; - for (const item of input) { - if (item.type !== "skill") continue; - const key = item.name.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - names.push(item.name); - } - return names.sort((a, b) => b.length - a.length); -} - -function markdownCodeSpan(text: string): string { - if (!text.includes("`")) return `\`${text}\``; - const longestRun = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length)); - const delimiter = "`".repeat(longestRun + 1); - return `${delimiter} ${text} ${delimiter}`; -} - -function markdownCodeRanges(text: string): TextRange[] { - return [...markdownFenceRanges(text), ...markdownInlineCodeRanges(text)].sort((a, b) => a[0] - b[0]); -} - -function markdownFenceRanges(text: string): TextRange[] { - const ranges: TextRange[] = []; - let active: { marker: string; start: number } | null = null; - let offset = 0; - for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) { - const value = line[0]; - if (value.length === 0) break; - const fence = /^(?: {0,3})(`{3,}|~{3,})/.exec(value); - if (fence) { - const marker = fence[1]; - if (!marker) continue; - if (!active) { - active = { marker, start: offset }; - } else if (marker.startsWith(active.marker.charAt(0)) && marker.length >= active.marker.length) { - ranges.push([active.start, offset + value.length]); - active = null; - } - } - offset += value.length; - } - if (active) ranges.push([active.start, text.length]); - return ranges; -} - -function markdownInlineCodeRanges(text: string): TextRange[] { - const ranges: TextRange[] = []; - const fenceRanges = markdownFenceRanges(text); - let index = 0; - while (index < text.length) { - if (isIndexInRanges(index, fenceRanges) || text[index] !== "`") { - index += 1; - continue; - } - const match = /`+/.exec(text.slice(index)); - if (!match) { - index += 1; - continue; - } - const delimiter = match[0]; - const end = text.indexOf(delimiter, index + delimiter.length); - if (end < 0) { - index += delimiter.length; - continue; - } - ranges.push([index, end + delimiter.length]); - index = end + delimiter.length; - } - return ranges; -} - -function isIndexInRanges(index: number, ranges: readonly TextRange[]): boolean { - return ranges.some(([start, end]) => index >= start && index < end); -} - -function escapeRegExp(value: string): string { - return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); -} - function agentMessageDisplayItem(item: AgentMessageItem, turnId?: string): DisplayItem { return { id: item.id, @@ -256,7 +150,7 @@ function agentMessageDisplayItem(item: AgentMessageItem, turnId?: string): Displ text: item.text, copyText: item.text, ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, messageState: "completed", }; } @@ -271,7 +165,7 @@ function planDisplayItem(item: PlanItem, turnId?: string): DisplayItem { text, copyText: text, ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, messageState: "completed", }; } @@ -283,7 +177,7 @@ function hookPromptDisplayItem(item: HookPromptItem, turnId?: string): DisplayIt role: "tool", text: item.fragments.map((fragment) => fragment.text).join("\n\n") || "Hook prompt", ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, }; } @@ -294,7 +188,7 @@ function reasoningDisplayItem(item: ReasoningItem, turnId?: string): DisplayItem role: "tool", text: reasoningText(item), ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, }; } @@ -309,7 +203,7 @@ function mcpToolCallDisplayItem(item: McpToolCallItem, turnId?: string): Display text: compactToolSummary(null, target, statusQualifier(item.status, failure)), toolLabel: name, ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, status: item.status, details: jsonDetails([ ["Arguments JSON", item.arguments], @@ -332,7 +226,7 @@ function dynamicToolCallDisplayItem(item: DynamicToolCallItem, turnId?: string): text: compactToolSummary(null, target, statusQualifier(item.status, failure)), toolLabel: name, ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, status: item.status, details: jsonDetails([ ["Arguments JSON", item.arguments], @@ -351,7 +245,7 @@ function webSearchDisplayItem(item: WebSearchItem, turnId?: string): DisplayItem text: webSearchSummary(item), toolLabel: "web search", ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, details: webSearchDetails(item), output: "", }; @@ -366,7 +260,7 @@ function imageViewDisplayItem(item: ImageViewItem, turnId?: string): DisplayItem toolLabel: "imageView", summaryPath: true, ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, }; } @@ -380,7 +274,7 @@ function imageGenerationDisplayItem(item: ImageGenerationItem, turnId?: string): toolLabel: "imageGeneration", summaryPath: Boolean(item.savedPath), ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, status: item.status, details: [ ...bodyDetail("Saved path", item.savedPath), @@ -400,7 +294,7 @@ function reviewModeDisplayItem(item: ReviewModeItem, turnId?: string): DisplayIt text: item.type === "enteredReviewMode" ? "Entered review mode" : "Exited review mode", toolLabel: item.type, ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, output: item.review, }; } @@ -412,7 +306,7 @@ function contextCompactionDisplayItem(item: ContextCompactionItem, turnId?: stri role: "tool", text: "Context compaction", ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, }; } @@ -571,7 +465,7 @@ function commandDisplayItem(item: CommandExecutionItem, turnId?: string): Displa actionLabel: commandActionLabel(item), text: compactToolSummary(null, target, qualifier), ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, command: item.command, cwd: item.cwd, status: item.status, @@ -591,7 +485,7 @@ function fileChangeDisplayItem(item: FileChangeItem, turnId?: string): DisplayIt role: "tool", text: compactToolSummary(null, fileChangeTargetLabel(changes), qualifier), ...definedProp("turnId", turnId), - itemId: item.id, + sourceItemId: item.id, status: item.status, changes, executionState: patchApplyExecutionState(item.status), diff --git a/src/features/chat/display/types.ts b/src/features/chat/display/types.ts index aee8ad3a..51579aeb 100644 --- a/src/features/chat/display/types.ts +++ b/src/features/chat/display/types.ts @@ -25,7 +25,7 @@ interface DisplayBase { role: DisplayRole; text: string; turnId?: string; - itemId?: string; + sourceItemId?: string; executionState?: ExecutionState; } diff --git a/src/features/chat/panel/composition.ts b/src/features/chat/panel/composition.ts index a9377529..45a8b886 100644 --- a/src/features/chat/panel/composition.ts +++ b/src/features/chat/panel/composition.ts @@ -5,7 +5,7 @@ import type { CodexPanelSettings } from "../../../settings/model"; import type { ChatServerMetadataActions } from "../connection/server-actions/metadata"; import type { ChatServerThreadActions } from "../connection/server-actions/threads"; import type { ChatComposerController } from "../conversation/composer/controller"; -import type { ChatThreadActions } from "../threads/thread-actions"; +import type { ChatThreadActions } from "../threads/actions"; import { scheduleAppServerWarmup } from "../connection/app-server-warmup"; import { closeChatView, openChatView, type ChatViewLifecycleHost } from "./view-lifecycle"; import { createToolbarArchiveConfirmState, ToolbarPanelController } from "./regions/toolbar"; diff --git a/src/features/chat/panel/regions/composer.ts b/src/features/chat/panel/regions/composer.ts index 5509903d..64ef0b5f 100644 --- a/src/features/chat/panel/regions/composer.ts +++ b/src/features/chat/panel/regions/composer.ts @@ -7,7 +7,7 @@ import { supportedReasoningEfforts, } from "../../runtime/effective"; import { compactReasoningEffortLabel } from "../../conversation/turns/runtime-overrides"; -import { contextSummary } from "../../display/runtime-status"; +import { contextSummary } from "../../display/status/runtime"; import { sortedModelMetadata } from "../../../../domain/catalog/metadata"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import type { RuntimeSnapshot } from "../../runtime/snapshot"; diff --git a/src/features/chat/panel/regions/toolbar.ts b/src/features/chat/panel/regions/toolbar.ts index 9b4ceb57..cd901972 100644 --- a/src/features/chat/panel/regions/toolbar.ts +++ b/src/features/chat/panel/regions/toolbar.ts @@ -1,8 +1,8 @@ import type { Thread } from "../../../../domain/threads/model"; import { getThreadTitle } from "../../../../domain/threads/model"; -import type { ChatThreadActions } from "../../threads/thread-actions"; -import { runtimeConfigSections, rateLimitSummary } from "../../display/runtime-status"; -import { connectionDiagnosticSections } from "../../display/diagnostics"; +import type { ChatThreadActions } from "../../threads/actions"; +import { runtimeConfigSections, rateLimitSummary } from "../../display/status/runtime"; +import { connectionDiagnosticSections } from "../../display/status/diagnostics"; import type { RuntimeSnapshot } from "../../runtime/snapshot"; import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer"; import type { ChatPanelShellState } from "../../ui/shell"; diff --git a/src/features/chat/protocol/inbound/controller.ts b/src/features/chat/protocol/inbound/controller.ts index 2800b9db..75741532 100644 --- a/src/features/chat/protocol/inbound/controller.ts +++ b/src/features/chat/protocol/inbound/controller.ts @@ -3,7 +3,7 @@ import type { McpServerStartupStatus } from "../../../../app-server/diagnostics" import type { ThreadConversationSummary } from "../../../../domain/threads/transcript"; import { classifyAppServerLog } from "./app-server-logs"; import { activeTurnId, type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer"; -import { createStructuredSystemItem, createSystemItem } from "../../display/system"; +import { createStructuredSystemItem, createSystemItem } from "../../display/items/system"; import type { DisplayDetailSection } from "../../display/types"; import { approvalResponse, type ApprovalAction, type PendingApproval } from "../requests/approval"; import { userInputResponse, type PendingUserInput } from "../requests/user-input"; @@ -107,11 +107,11 @@ export class ChatInboundController { } addSystemMessage(text: string): void { - this.dispatch({ type: "transcript/system-message-added", item: createSystemItem(this.localItemId("system"), text) }); + this.dispatch({ type: "transcript/system-item-added", item: createSystemItem(this.localItemId("system"), text) }); } addStructuredSystemMessage(text: string, details: DisplayDetailSection[]): void { - this.dispatch({ type: "transcript/system-message-added", item: createStructuredSystemItem(this.localItemId("system"), text, details) }); + this.dispatch({ type: "transcript/system-item-added", item: createStructuredSystemItem(this.localItemId("system"), text, details) }); } addDedupedSystemMessage(text: string): void { diff --git a/src/features/chat/protocol/inbound/notification-plan.ts b/src/features/chat/protocol/inbound/notification-plan.ts index 97406c10..dd1aa8ef 100644 --- a/src/features/chat/protocol/inbound/notification-plan.ts +++ b/src/features/chat/protocol/inbound/notification-plan.ts @@ -11,7 +11,7 @@ import type { ServerNotification } from "../../../../app-server/types"; import type { ThreadConversationSummary } from "../../../../domain/threads/transcript"; import { jsonPreview } from "../../../../utils"; import { activeTurnId, pendingTurnStart as pendingTurnStartForState, type ChatAction, type ChatState } from "../../state/reducer"; -import { createAutoReviewResultItem, createReviewResultItem } from "../../display/review"; +import { createAutoReviewResultItem, createReviewResultItem } from "../../display/items/review-result"; import { appendAssistantDelta, appendItemOutput, @@ -20,13 +20,14 @@ import { appendToolOutput, completeReasoningItems, upsertDisplayItem, -} from "../../display/stream-updates"; -import { displayItemFromThreadItem, displayItemsFromTurns, normalizeFileChanges, shouldSuppressLifecycleItem } from "../display-items"; -import { planProgressDisplayItem } from "../../display/plan"; -import { createSystemItem } from "../../display/system"; +} from "../../state/transcript-updates"; +import { displayItemFromThreadItem, displayItemsFromTurns, normalizeFileChanges, shouldSuppressLifecycleItem } from "../../display/turn-items"; +import { taskProgressDisplayItem } from "../../display/items/task-progress"; +import { createSystemItem } from "../../display/items/system"; import type { DisplayItem, DisplayKind, MessageDisplayItem } from "../../display/types"; -import { goalChangeItem } from "../../display/goal-messages"; -import { attachHookRunsToTurn, hookRunDisplayItem } from "../../display/hooks"; +import { goalChangeItem } from "../../display/items/goal"; +import { hookRunDisplayItem } from "../../display/items/hook-run"; +import { attachHookRunsToTurn } from "../../state/transcript-updates"; import { routeServerNotification, type DiagnosticStatusNotificationMethod, @@ -135,7 +136,7 @@ const STREAM_UPDATE_PLANNERS = { "turn/plan/updated": (_state, notification) => actionPlan({ type: "transcript/item-upserted", - item: planProgressDisplayItem(notification.params.turnId, notification.params.explanation, notification.params.plan), + item: taskProgressDisplayItem(notification.params.turnId, notification.params.explanation, notification.params.plan), }), "item/reasoning/summaryTextDelta": (state, notification) => appendToolTextPlan(state, notification.params.itemId, notification.params.turnId, "reasoning", notification.params.delta, "reasoning"), @@ -420,7 +421,7 @@ function fileChangePlan(itemId: string, turnId: string, changes: AppServerFileUp role: "tool", text: `File change ${status}`, turnId, - itemId, + sourceItemId: itemId, status, changes: normalizeFileChanges(changes), }, @@ -553,7 +554,7 @@ function isString(value: string | null | undefined): value is string { } function systemMessagePlan(message: { id: string; text: string }): ChatNotificationPlan { - return actionPlan({ type: "transcript/system-message-added", item: createSystemItem(message.id, message.text) }); + return actionPlan({ type: "transcript/system-item-added", item: createSystemItem(message.id, message.text) }); } function actionPlan(action: ChatAction): ChatNotificationPlan { diff --git a/src/features/chat/protocol/requests/approval.ts b/src/features/chat/protocol/requests/approval.ts index 97777b73..c00b451b 100644 --- a/src/features/chat/protocol/requests/approval.ts +++ b/src/features/chat/protocol/requests/approval.ts @@ -1,5 +1,5 @@ import { jsonPreview } from "../../../../utils"; -import { permissionRows } from "../../display/permission-rows"; +import { permissionRows } from "../../display/details/permission-rows"; import type { RequestId } from "../../../../app-server/types"; type SimpleApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel"; diff --git a/src/features/chat/state/reducer.ts b/src/features/chat/state/reducer.ts index a0655abb..673db3aa 100644 --- a/src/features/chat/state/reducer.ts +++ b/src/features/chat/state/reducer.ts @@ -28,7 +28,7 @@ import { import type { RequestedServiceTier } from "../runtime/settings"; import type { RequestId } from "../../../app-server/types"; import type { ComposerSuggestion } from "../conversation/composer/suggestions"; -import { upsertDisplayItem } from "../display/stream-updates"; +import { upsertDisplayItem } from "./transcript-updates"; import type { DisplayItem } from "../display/types"; import type { ActiveThreadResumedAction, diff --git a/src/features/chat/state/selectors.ts b/src/features/chat/state/selectors.ts index a567af48..8964f9c3 100644 --- a/src/features/chat/state/selectors.ts +++ b/src/features/chat/state/selectors.ts @@ -1,8 +1,7 @@ import { activeTurnId as selectActiveTurnId, chatTurnBusy, pendingTurnStart } from "./reducer"; -import type { ChatState, PendingTurnStart } from "./reducer"; +import type { ChatActiveThreadState, ChatRuntimeState, ChatState, ChatTranscriptState, ChatTurnState, PendingTurnStart } from "./reducer"; import type { Thread } from "../../../domain/threads/model"; import type { DisplayItem } from "../display/types"; -import { implementPlanCandidateFromState } from "../display/action-candidates"; export interface SubmissionStateSnapshot { activeThreadId: string | null; @@ -43,3 +42,17 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh export function canImplementPlan(state: ChatState, item: DisplayItem): boolean { return item.id === implementPlanCandidateFromState(state)?.id; } + +export function implementPlanCandidateFromState(state: { + activeThread: Pick; + turn: ChatTurnState; + runtime: Pick; + transcript: Pick; +}): DisplayItem | null { + if (!state.activeThread.id || chatTurnBusy(state) || state.runtime.selectedCollaborationMode !== "plan") { + return null; + } + return ( + [...state.transcript.displayItems].reverse().find((item) => item.kind === "message" && item.messageKind === "proposedPlan") ?? null + ); +} diff --git a/src/features/chat/display/stream-updates.ts b/src/features/chat/state/transcript-updates.ts similarity index 67% rename from src/features/chat/display/stream-updates.ts rename to src/features/chat/state/transcript-updates.ts index 573cbb2a..646558be 100644 --- a/src/features/chat/display/stream-updates.ts +++ b/src/features/chat/state/transcript-updates.ts @@ -1,6 +1,6 @@ -import type { AssistantAuthoredMessageDisplayItem, DisplayFileChange, DisplayItem, DisplayKind } from "./types"; -import { isAssistantAuthoredMessage } from "./turn-outcome-message"; -import { normalizeProposedPlanMarkdown } from "./plan"; +import { normalizeProposedPlanMarkdown } from "../display/items/proposed-plan"; +import { isAssistantAuthoredMessage } from "../display/predicates"; +import type { AssistantAuthoredMessageDisplayItem, DisplayFileChange, DisplayItem, DisplayKind } from "../display/types"; export function upsertDisplayItem(items: readonly DisplayItem[], next: DisplayItem): DisplayItem[] { const index = items.findIndex((item) => item.id === next.id); @@ -29,8 +29,8 @@ function mergeChanges(previous: DisplayItem, next: DisplayItem): DisplayFileChan return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges; } -export function appendAssistantDelta(items: readonly DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] { - const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.messageKind === "assistantResponse"); +export function appendAssistantDelta(items: readonly DisplayItem[], sourceItemId: string, turnId: string, delta: string): DisplayItem[] { + const index = items.findIndex((item) => item.sourceItemId === sourceItemId && item.kind === "message" && item.messageKind === "assistantResponse"); if (index !== -1) { return items.map((item, itemIndex) => itemIndex === index && item.kind === "message" && item.messageKind === "assistantResponse" @@ -47,14 +47,14 @@ export function appendAssistantDelta(items: readonly DisplayItem[], itemId: stri return [ ...items, { - id: itemId, + id: sourceItemId, kind: "message", messageKind: "assistantResponse", role: "assistant", text: delta, copyText: delta, turnId, - itemId, + sourceItemId, messageState: "streaming", }, ]; @@ -72,8 +72,8 @@ export function completeReasoningItems(items: readonly DisplayItem[], turnId: st ); } -export function appendPlanDelta(items: readonly DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] { - const index = items.findIndex((item) => item.itemId === itemId && isAssistantAuthoredMessage(item)); +export function appendPlanDelta(items: readonly DisplayItem[], sourceItemId: string, turnId: string, delta: string): DisplayItem[] { + const index = items.findIndex((item) => item.sourceItemId === sourceItemId && isAssistantAuthoredMessage(item)); if (index !== -1) { return items.map((item, itemIndex) => itemIndex === index && isAssistantAuthoredMessage(item) ? appendPlanDeltaToMessage(item, turnId, delta) : item, @@ -83,14 +83,14 @@ export function appendPlanDelta(items: readonly DisplayItem[], itemId: string, t return [ ...items, { - id: itemId, + id: sourceItemId, kind: "message", messageKind: "proposedPlan", role: "assistant", text, copyText: text, turnId, - itemId, + sourceItemId, messageState: "streaming", }, ]; @@ -110,37 +110,37 @@ function appendPlanDeltaToMessage(item: AssistantAuthoredMessageDisplayItem, tur export function appendItemText( items: readonly DisplayItem[], - itemId: string, + sourceItemId: string, turnId: string, label: string, delta: string, kind: Extract = "tool", ): DisplayItem[] { - const index = items.findIndex((item) => item.itemId === itemId); + const index = items.findIndex((item) => item.sourceItemId === sourceItemId); if (index !== -1) { return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${item.text}${delta}` } : item)); } return [ ...items, { - id: itemId, + id: sourceItemId, kind, role: "tool", text: `${label}: ${delta}`, turnId, - itemId, + sourceItemId, }, ]; } export function appendToolOutput( items: readonly DisplayItem[], - itemId: string, + sourceItemId: string, turnId: string, delta: string, fallbackLabel: string, ): DisplayItem[] { - const index = items.findIndex((item) => item.itemId === itemId); + const index = items.findIndex((item) => item.sourceItemId === sourceItemId); if (index !== -1) { return items.map((item, itemIndex) => itemIndex === index && (item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning") @@ -151,13 +151,13 @@ export function appendToolOutput( return [ ...items, { - id: itemId, + id: sourceItemId, kind: "tool", role: "tool", text: "details", toolLabel: fallbackLabel, turnId, - itemId, + sourceItemId, output: delta, }, ]; @@ -165,13 +165,13 @@ export function appendToolOutput( export function appendItemOutput( items: readonly DisplayItem[], - itemId: string, + sourceItemId: string, turnId: string, delta: string, kind: "command" | "fileChange", fallbackText: string, ): DisplayItem[] { - const index = items.findIndex((item) => item.itemId === itemId); + const index = items.findIndex((item) => item.sourceItemId === sourceItemId); if (index !== -1) { return items.map((item, itemIndex) => itemIndex === index && (item.kind === "command" || item.kind === "fileChange") @@ -182,12 +182,12 @@ export function appendItemOutput( return [ ...items, { - id: itemId, + id: sourceItemId, kind, role: "tool", text: fallbackText, turnId, - itemId, + sourceItemId, output: delta, ...(kind === "fileChange" ? { @@ -204,3 +204,28 @@ export function appendItemOutput( }, ] as DisplayItem[]; } + +export function attachHookRunsToTurn( + items: readonly DisplayItem[], + turnId: string, + hookItemIds: readonly string[], + afterItemId?: string | null, +): DisplayItem[] { + const hookIdSet = new Set(hookItemIds); + const attachedHooks = items.filter((item) => hookIdSet.has(item.id)).map((item) => ({ ...item, turnId })); + if (attachedHooks.length === 0) return [...items]; + + const withoutAttachedHooks = items.filter((item) => !hookIdSet.has(item.id)); + const anchorItemId = afterItemId ?? lastUserMessageAnchorId(withoutAttachedHooks, turnId); + if (!anchorItemId) return [...withoutAttachedHooks, ...attachedHooks]; + const insertAfterIndex = withoutAttachedHooks.findIndex((item) => item.id === anchorItemId); + if (insertAfterIndex === -1) return [...withoutAttachedHooks, ...attachedHooks]; + return [...withoutAttachedHooks.slice(0, insertAfterIndex + 1), ...attachedHooks, ...withoutAttachedHooks.slice(insertAfterIndex + 1)]; +} + +function lastUserMessageAnchorId(items: readonly DisplayItem[], turnId: string): string | null { + const anchor = [...items] + .reverse() + .find((item) => item.kind === "message" && item.role === "user" && (!item.turnId || item.turnId === turnId)); + return anchor?.id ?? null; +} diff --git a/src/features/chat/state/transcript.ts b/src/features/chat/state/transcript.ts index 00efb3c6..e6f9dedd 100644 --- a/src/features/chat/state/transcript.ts +++ b/src/features/chat/state/transcript.ts @@ -1,4 +1,4 @@ -import { upsertDisplayItem } from "../display/stream-updates"; +import { upsertDisplayItem } from "./transcript-updates"; import type { DisplayItem } from "../display/types"; export interface ChatTranscriptState { @@ -11,7 +11,7 @@ export interface ChatTranscriptState { export type TranscriptAction = | { type: "transcript/item-added"; item: DisplayItem } - | { type: "transcript/system-message-added"; item: DisplayItem } + | { type: "transcript/system-item-added"; item: DisplayItem } | { type: "transcript/deduped-log-added"; text: string; item: DisplayItem } | { type: "transcript/history-loading-set"; loading: boolean } | { @@ -36,7 +36,7 @@ export function initialChatTranscriptState(): ChatTranscriptState { export function reduceTranscriptSlice(state: ChatTranscriptState, action: TranscriptAction): ChatTranscriptState { switch (action.type) { case "transcript/item-added": - case "transcript/system-message-added": + case "transcript/system-item-added": return patchObject(state, { displayItems: [...state.displayItems, action.item] }); case "transcript/deduped-log-added": if (state.reportedLogs.has(action.text)) return state; diff --git a/src/features/chat/threads/thread-action-context.ts b/src/features/chat/threads/action-context.ts similarity index 100% rename from src/features/chat/threads/thread-action-context.ts rename to src/features/chat/threads/action-context.ts diff --git a/src/features/chat/threads/thread-actions.ts b/src/features/chat/threads/actions.ts similarity index 70% rename from src/features/chat/threads/thread-actions.ts rename to src/features/chat/threads/actions.ts index a482ff46..9563deb9 100644 --- a/src/features/chat/threads/thread-actions.ts +++ b/src/features/chat/threads/actions.ts @@ -1,11 +1,11 @@ -import { archiveThread } from "./thread-archive-actions"; -import { compactThread } from "./thread-compact-actions"; -import type { ChatThreadActionsHost } from "./thread-action-context"; -import { forkThread, forkThreadFromTurn } from "./thread-fork-actions"; -import { renameThread } from "./thread-rename-actions"; -import { rollbackThread } from "./thread-rollback-actions"; +import { archiveThread } from "./archive-actions"; +import { compactThread } from "./compact-actions"; +import type { ChatThreadActionsHost } from "./action-context"; +import { forkThread, forkThreadFromTurn } from "./fork-actions"; +import { renameThread } from "./rename-actions"; +import { rollbackThread } from "./rollback-actions"; -export type { ChatThreadActionsHost } from "./thread-action-context"; +export type { ChatThreadActionsHost } from "./action-context"; export interface ChatThreadActions { compactThread: (threadId: string) => Promise; diff --git a/src/features/chat/threads/thread-archive-actions.ts b/src/features/chat/threads/archive-actions.ts similarity index 93% rename from src/features/chat/threads/thread-archive-actions.ts rename to src/features/chat/threads/archive-actions.ts index 83f25fc4..d9f10982 100644 --- a/src/features/chat/threads/thread-archive-actions.ts +++ b/src/features/chat/threads/archive-actions.ts @@ -4,8 +4,8 @@ import { threadFromAppServerThread } from "../../../app-server/thread-model"; import { transcriptEntriesFromAppServerTurn } from "../../../app-server/turn-model"; import { exportArchivedThreadMarkdown } from "../../../domain/threads/export"; import { chatTurnBusy } from "../state/reducer"; -import type { ChatThreadActionsHost } from "./thread-action-context"; -import { threadActionState } from "./thread-action-context"; +import type { ChatThreadActionsHost } from "./action-context"; +import { threadActionState } from "./action-context"; export async function archiveThread( host: ChatThreadActionsHost, diff --git a/src/features/chat/threads/thread-compact-actions.ts b/src/features/chat/threads/compact-actions.ts similarity index 86% rename from src/features/chat/threads/thread-compact-actions.ts rename to src/features/chat/threads/compact-actions.ts index 5c4594c8..0235f1c4 100644 --- a/src/features/chat/threads/thread-compact-actions.ts +++ b/src/features/chat/threads/compact-actions.ts @@ -1,5 +1,5 @@ -import type { ChatThreadActionsHost } from "./thread-action-context"; -import { threadActionState, threadActionStillTargetsOriginalPanel } from "./thread-action-context"; +import type { ChatThreadActionsHost } from "./action-context"; +import { threadActionState, threadActionStillTargetsOriginalPanel } from "./action-context"; export async function compactThread(host: ChatThreadActionsHost, threadId: string): Promise { await host.ensureConnected(); diff --git a/src/features/chat/threads/composition.ts b/src/features/chat/threads/composition.ts index 277b614e..f86abcdc 100644 --- a/src/features/chat/threads/composition.ts +++ b/src/features/chat/threads/composition.ts @@ -2,14 +2,14 @@ import type { ConnectionManager } from "../../../app-server/connection-manager"; import type { AppServerClient } from "../../../app-server/client"; import { recoverRolloutTokenUsage } from "../../../app-server/rollout-token-usage"; import type { ArchiveExportAdapter } from "../../../domain/threads/export"; -import { createChatThreadActions } from "./thread-actions"; -import { createChatThreadGoalActions } from "./thread-goal-actions"; -import { ThreadHistoryController } from "./thread-history-controller"; -import { createThreadIdentitySync } from "./thread-identity-sync"; -import { ThreadRenameController } from "./thread-rename-controller"; -import { ThreadResumeController } from "./thread-resume-controller"; -import { createThreadSelectionActions } from "./thread-selection-actions"; -import { RestoredThreadController } from "./restored-thread-controller"; +import { createChatThreadActions } from "./actions"; +import { createGoalActions } from "./goal-actions"; +import { HistoryController } from "./history-controller"; +import { createIdentitySync } from "./identity-sync"; +import { RenameController } from "./rename-controller"; +import { ResumeController } from "./resume-controller"; +import { createSelectionActions } from "./selection-actions"; +import { RestorationController } from "./restoration-controller"; import type { ChatStateStore } from "../state/reducer"; import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle"; import type { CodexPanelSettings } from "../../../settings/model"; @@ -76,7 +76,7 @@ export function createThreadControllerGroup( const currentClient = client.getClient; const { deferredTasks, resumeWork } = lifecycle; - const threadRename = new ThreadRenameController({ + const rename = new RenameController({ stateStore, vaultPath: plugin.vaultPath, settings: () => plugin.settings, @@ -87,9 +87,9 @@ export function createThreadControllerGroup( notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin), }); const resetThreadTurnPresence = (hadTurns: boolean) => { - threadRename.resetThreadTurnPresence(hadTurns); + rename.resetThreadTurnPresence(hadTurns); }; - const history = new ThreadHistoryController({ + const history = new HistoryController({ stateStore, currentClient, render: render.now, @@ -102,7 +102,7 @@ export function createThreadControllerGroup( resumeWork.invalidate(); history.invalidate(); }; - const threadActions = createChatThreadActions({ + const actions = createChatThreadActions({ stateStore, vaultPath: plugin.vaultPath, settings: () => plugin.settings, @@ -125,7 +125,7 @@ export function createThreadControllerGroup( plugin.refreshSharedThreadListFromOpenSurface(); }, }); - const goals = createChatThreadGoalActions({ + const goals = createGoalActions({ stateStore, currentClient, ensureConnected: client.ensureConnected, @@ -136,22 +136,22 @@ export function createThreadControllerGroup( render: render.now, refreshLiveState: liveState.refresh, }); - let threadResume: ThreadResumeController | null = null; - const restoredThread = new RestoredThreadController({ + let resume: ResumeController | null = null; + const restoration = new RestorationController({ deferredTasks, opened: lifecycle.getOpened, - resumeThread: (threadId) => requireThreadController(threadResume, "thread resume controller").resumeThread(threadId), + resumeThread: (threadId) => requireThreadController(resume, "resume controller").resumeThread(threadId), invalidateResumeWork, stateStore, setStatus: status.set, refreshTabHeader: thread.refreshTabHeader, }); - threadResume = new ThreadResumeController({ + resume = new ResumeController({ stateStore, vaultPath: plugin.vaultPath, resumeWork, history, - restoredThread, + restoration, currentClient, ensureConnected: client.ensureConnected, closing: lifecycle.getClosing, @@ -168,9 +168,9 @@ export function createThreadControllerGroup( return response?.dataBase64 ?? ""; }), }); - const threadIdentity = createThreadIdentitySync({ + const identity = createIdentitySync({ stateStore, - restoredThread, + restoration, invalidateResumeWork, clearDeferredRestoredThreadHydration: lifecycle.clearDeferredRestoredThreadHydration, resetThreadTurnPresence, @@ -182,12 +182,12 @@ export function createThreadControllerGroup( return { history, - threadActions, + actions, goals, - restoredThread, - threadResume: requireThreadController(threadResume, "thread resume controller"), - threadIdentity, - threadRename, + restoration, + resume: requireThreadController(resume, "resume controller"), + identity, + rename, invalidateResumeWork, }; } @@ -221,7 +221,7 @@ export function createThreadSelectionActionGroup( const { plugin, thread, status } = context; const stateStore = context.state.stateStore; - const threadSelection = createThreadSelectionActions({ + const selection = createSelectionActions({ stateStore, closeForThreadSelection: () => { refs.closeForThreadSelection(); @@ -231,5 +231,5 @@ export function createThreadSelectionActionGroup( addSystemMessage: status.addSystemMessage, }); - return { threadSelection }; + return { selection }; } diff --git a/src/features/chat/threads/thread-fork-actions.ts b/src/features/chat/threads/fork-actions.ts similarity index 92% rename from src/features/chat/threads/thread-fork-actions.ts rename to src/features/chat/threads/fork-actions.ts index 2b182276..02e3fa5d 100644 --- a/src/features/chat/threads/thread-fork-actions.ts +++ b/src/features/chat/threads/fork-actions.ts @@ -1,9 +1,9 @@ import { inheritedForkThreadName } from "../../../domain/threads/model"; import { chatTurnBusy } from "../state/reducer"; -import { turnsAfterTurnId } from "../display/action-candidates"; -import { archiveThreadOnServer } from "./thread-archive-actions"; -import type { ChatThreadActionsHost } from "./thread-action-context"; -import { threadActionState, threadActionStillTargetsOriginalPanel } from "./thread-action-context"; +import { turnsAfterTurnId } from "../display/item-actions"; +import { archiveThreadOnServer } from "./archive-actions"; +import type { ChatThreadActionsHost } from "./action-context"; +import { threadActionState, threadActionStillTargetsOriginalPanel } from "./action-context"; export function forkThread(host: ChatThreadActionsHost, threadId: string): Promise { return forkThreadFromTurn(host, threadId, null, false); diff --git a/src/features/chat/threads/thread-goal-actions.ts b/src/features/chat/threads/goal-actions.ts similarity index 81% rename from src/features/chat/threads/thread-goal-actions.ts rename to src/features/chat/threads/goal-actions.ts index d4cb4965..677c065b 100644 --- a/src/features/chat/threads/thread-goal-actions.ts +++ b/src/features/chat/threads/goal-actions.ts @@ -8,9 +8,9 @@ import { } from "../../../app-server/thread-goal"; import type { ChatStateStore } from "../state/reducer"; import type { GoalDisplayItem } from "../display/types"; -import { goalChangeItem } from "../display/goal-messages"; +import { goalChangeItem } from "../display/items/goal"; -export interface ChatThreadGoalActionsHost { +export interface GoalActionsHost { stateStore: ChatStateStore; currentClient: () => AppServerClient | null; ensureConnected: () => Promise; @@ -20,7 +20,7 @@ export interface ChatThreadGoalActionsHost { refreshLiveState: () => void; } -export interface ChatThreadGoalActions { +export interface GoalActions { activeGoal: () => ThreadGoal | null; syncThreadGoal: (threadId: string) => Promise; setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise; @@ -28,7 +28,7 @@ export interface ChatThreadGoalActions { clear: (threadId: string) => Promise; } -export function createChatThreadGoalActions(host: ChatThreadGoalActionsHost): ChatThreadGoalActions { +export function createGoalActions(host: GoalActionsHost): GoalActions { return { activeGoal: () => host.stateStore.getState().activeThread.goal, syncThreadGoal: (threadId) => syncThreadGoal(host, threadId), @@ -38,7 +38,7 @@ export function createChatThreadGoalActions(host: ChatThreadGoalActionsHost): Ch }; } -async function syncThreadGoal(host: ChatThreadGoalActionsHost, threadId: string): Promise { +async function syncThreadGoal(host: GoalActionsHost, threadId: string): Promise { const client = host.currentClient(); if (!client) return; try { @@ -50,7 +50,7 @@ async function syncThreadGoal(host: ChatThreadGoalActionsHost, threadId: string) } async function setObjective( - host: ChatThreadGoalActionsHost, + host: GoalActionsHost, threadId: string, objective: string, tokenBudget: number | null, @@ -73,11 +73,11 @@ async function setObjective( return applied; } -function setGoalStatus(host: ChatThreadGoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise { +function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise { return setGoal(host, threadId, { status }); } -async function clearGoal(host: ChatThreadGoalActionsHost, threadId: string): Promise { +async function clearGoal(host: GoalActionsHost, threadId: string): Promise { await host.ensureConnected(); const client = host.currentClient(); if (!client) return false; @@ -91,7 +91,7 @@ async function clearGoal(host: ChatThreadGoalActionsHost, threadId: string): Pro } } -async function setGoal(host: ChatThreadGoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise { +async function setGoal(host: GoalActionsHost, threadId: string, params: ThreadGoalUpdate): Promise { await host.ensureConnected(); const client = host.currentClient(); if (!client) return false; @@ -105,7 +105,7 @@ async function setGoal(host: ChatThreadGoalActionsHost, threadId: string, params } function applyGoalIfActive( - host: ChatThreadGoalActionsHost, + host: GoalActionsHost, threadId: string, goal: ThreadGoal | null, options: { reportChange: boolean }, @@ -120,7 +120,7 @@ function applyGoalIfActive( return true; } -async function recordGoalUserMessage(host: ChatThreadGoalActionsHost, threadId: string, objective: string): Promise { +async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, objective: string): Promise { const client = host.currentClient(); if (!client) return; try { @@ -130,7 +130,7 @@ async function recordGoalUserMessage(host: ChatThreadGoalActionsHost, threadId: } } -function addThreadScopedSystemMessage(host: ChatThreadGoalActionsHost, threadId: string, text: string): void { +function addThreadScopedSystemMessage(host: GoalActionsHost, threadId: string, text: string): void { if (host.stateStore.getState().activeThread.id !== threadId) return; host.addSystemMessage(text); } diff --git a/src/features/chat/threads/thread-history-controller.ts b/src/features/chat/threads/history-controller.ts similarity index 95% rename from src/features/chat/threads/thread-history-controller.ts rename to src/features/chat/threads/history-controller.ts index ba6fef74..bb77843d 100644 --- a/src/features/chat/threads/thread-history-controller.ts +++ b/src/features/chat/threads/history-controller.ts @@ -1,9 +1,9 @@ import type { AppServerClient } from "../../../app-server/client"; import type { ThreadTurnsPage } from "../../../app-server/turn-history"; import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer"; -import { displayItemsFromTurns } from "../protocol/display-items"; +import { displayItemsFromTurns } from "../display/turn-items"; -export interface ThreadHistoryControllerHost { +export interface HistoryControllerHost { stateStore: ChatStateStore; currentClient: () => AppServerClient | null; render: () => void; @@ -20,10 +20,10 @@ type ThreadHistoryLoadLifecycleEvent = | { type: "finished"; load: ActiveThreadHistoryLoad } | { type: "invalidated" }; -export class ThreadHistoryController { +export class HistoryController { private lifecycle: ThreadHistoryLoadLifecycleState = { kind: "idle" }; - constructor(private readonly host: ThreadHistoryControllerHost) {} + constructor(private readonly host: HistoryControllerHost) {} private get state(): ChatState { return this.host.stateStore.getState(); diff --git a/src/features/chat/threads/thread-identity-sync.ts b/src/features/chat/threads/identity-sync.ts similarity index 72% rename from src/features/chat/threads/thread-identity-sync.ts rename to src/features/chat/threads/identity-sync.ts index 35d6e7ff..524e8190 100644 --- a/src/features/chat/threads/thread-identity-sync.ts +++ b/src/features/chat/threads/identity-sync.ts @@ -1,10 +1,10 @@ -import type { RestoredThreadController } from "./restored-thread-controller"; +import type { RestorationController } from "./restoration-controller"; import { activeThreadId, listedThreads } from "../state/selectors"; import type { ChatStateStore } from "../state/reducer"; -export interface ThreadIdentitySyncHost { +export interface IdentitySyncHost { stateStore: ChatStateStore; - restoredThread: RestoredThreadController; + restoration: RestorationController; invalidateResumeWork: () => void; clearDeferredRestoredThreadHydration: () => void; resetThreadTurnPresence: (hadTurns: boolean) => void; @@ -14,13 +14,13 @@ export interface ThreadIdentitySyncHost { render: () => void; } -export interface ThreadIdentitySync { +export interface IdentitySync { clearActiveThreadContext: () => void; notifyThreadArchived: (threadId: string) => void; notifyThreadRenamed: (threadId: string, name: string | null) => void; } -export function createThreadIdentitySync(host: ThreadIdentitySyncHost): ThreadIdentitySync { +export function createIdentitySync(host: IdentitySyncHost): IdentitySync { return { clearActiveThreadContext: () => { clearActiveThreadContext(host); @@ -34,9 +34,9 @@ export function createThreadIdentitySync(host: ThreadIdentitySyncHost): ThreadId }; } -function clearActiveThreadContext(host: ThreadIdentitySyncHost): void { +function clearActiveThreadContext(host: IdentitySyncHost): void { host.invalidateResumeWork(); - host.restoredThread.clear(); + host.restoration.clear(); host.clearDeferredRestoredThreadHydration(); host.stateStore.dispatch({ type: "active-thread/cleared" }); host.resetThreadTurnPresence(false); @@ -44,13 +44,13 @@ function clearActiveThreadContext(host: ThreadIdentitySyncHost): void { host.refreshLiveState(); } -function notifyThreadArchived(host: ThreadIdentitySyncHost, threadId: string): void { +function notifyThreadArchived(host: IdentitySyncHost, threadId: string): void { if (activeThreadId(host.stateStore.getState()) !== threadId) return; clearActiveThreadContext(host); host.render(); } -function notifyThreadRenamed(host: ThreadIdentitySyncHost, threadId: string, name: string | null): void { +function notifyThreadRenamed(host: IdentitySyncHost, threadId: string, name: string | null): void { let changed = false; const renamedThreads = listedThreads(host.stateStore.getState()).map((thread) => { if (thread.id !== threadId) return thread; @@ -58,12 +58,12 @@ function notifyThreadRenamed(host: ThreadIdentitySyncHost, threadId: string, nam return { ...thread, name }; }); host.stateStore.dispatch({ type: "thread-list/applied", threads: renamedThreads }); - const restoredThread = host.restoredThread.placeholder(); + const restoredThread = host.restoration.placeholder(); if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { - host.restoredThread.rename(threadId, name); + host.restoration.rename(threadId, name); changed = true; } - const activeThreadChanged = activeThreadId(host.stateStore.getState()) === threadId || host.restoredThread.isPending(threadId); + const activeThreadChanged = activeThreadId(host.stateStore.getState()) === threadId || host.restoration.isPending(threadId); if (!changed && !activeThreadChanged) return; if (activeThreadChanged) { host.notifyActiveThreadIdentityChanged(); diff --git a/src/features/chat/threads/thread-naming.ts b/src/features/chat/threads/naming.ts similarity index 95% rename from src/features/chat/threads/thread-naming.ts rename to src/features/chat/threads/naming.ts index ab1bbe64..6e4bcad0 100644 --- a/src/features/chat/threads/thread-naming.ts +++ b/src/features/chat/threads/naming.ts @@ -1,6 +1,6 @@ import type { ThreadNamingContext } from "../../../domain/threads/naming"; import { truncate } from "../../../utils"; -import { isCompletedTurnOutcomeMessage } from "../display/turn-outcome-message"; +import { isCompletedTurnOutcomeMessage } from "../display/predicates"; import type { DisplayItem } from "../display/types"; const MAX_CONTEXT_CHARS = 4_000; diff --git a/src/features/chat/threads/thread-rename-actions.ts b/src/features/chat/threads/rename-actions.ts similarity index 100% rename from src/features/chat/threads/thread-rename-actions.ts rename to src/features/chat/threads/rename-actions.ts diff --git a/src/features/chat/threads/thread-rename-controller.ts b/src/features/chat/threads/rename-controller.ts similarity index 97% rename from src/features/chat/threads/thread-rename-controller.ts rename to src/features/chat/threads/rename-controller.ts index 2f7eb660..07fe67ad 100644 --- a/src/features/chat/threads/thread-rename-controller.ts +++ b/src/features/chat/threads/rename-controller.ts @@ -12,10 +12,10 @@ import type { CodexPanelSettings } from "../../../settings/model"; import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer"; import { generateThreadTitleWithCodex } from "../../../app-server/thread-title-generation"; import { completedConversationSummaryFromAppServerTurn } from "../../../app-server/turn-model"; -import { firstNamingContextFromDisplayItems, namingContextFromDisplayItems } from "./thread-naming"; -import { renameConnectedThread } from "./thread-rename-actions"; +import { firstNamingContextFromDisplayItems, namingContextFromDisplayItems } from "./naming"; +import { renameConnectedThread } from "./rename-actions"; -export interface ThreadRenameEditState { +export interface RenameEditState { draft: string; generating: boolean; } @@ -34,7 +34,7 @@ type RenameLifecycleEvent = | { type: "generation-finished"; threadId: string; generatingState: RenameGeneratingState } | { type: "cleared" }; -export interface ThreadRenameControllerHost { +export interface RenameControllerHost { stateStore: ChatStateStore; vaultPath: string; settings: () => CodexPanelSettings; @@ -46,7 +46,7 @@ export interface ThreadRenameControllerHost { generateThreadTitle?: (context: ThreadNamingContext) => Promise; } -export class ThreadRenameController { +export class RenameController { private activeThreadHadTurns = false; private readonly autoNameAttemptedThreadIds = new Set(); private readonly autoNameInFlightThreadIds = new Set(); @@ -54,7 +54,7 @@ export class ThreadRenameController { private nextRenameGenerationId = 1; private renameState: RenameLifecycleState = { kind: "idle" }; - constructor(private readonly host: ThreadRenameControllerHost) {} + constructor(private readonly host: RenameControllerHost) {} private get state(): ChatState { return this.host.stateStore.getState(); @@ -68,7 +68,7 @@ export class ThreadRenameController { this.activeThreadHadTurns = hadTurns; } - editState(threadId: string): ThreadRenameEditState | null { + editState(threadId: string): RenameEditState | null { if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId) return null; return { draft: this.renameState.draft, diff --git a/src/features/chat/threads/restored-thread-controller.ts b/src/features/chat/threads/restoration-controller.ts similarity index 94% rename from src/features/chat/threads/restored-thread-controller.ts rename to src/features/chat/threads/restoration-controller.ts index 0452b80f..2fbe4b10 100644 --- a/src/features/chat/threads/restored-thread-controller.ts +++ b/src/features/chat/threads/restoration-controller.ts @@ -7,7 +7,7 @@ import { type ChatViewDeferredTasks, } from "../lifecycle"; -export interface RestoredThreadControllerHost { +export interface RestorationControllerHost { deferredTasks: ChatViewDeferredTasks; opened: () => boolean; resumeThread: (threadId: string) => Promise; @@ -17,10 +17,10 @@ export interface RestoredThreadControllerHost { refreshTabHeader: () => void; } -export class RestoredThreadController { +export class RestorationController { private lifecycle: RestoredThreadLifecycleState = { kind: "idle" }; - constructor(private readonly host: RestoredThreadControllerHost) {} + constructor(private readonly host: RestorationControllerHost) {} placeholder(): RestoredThreadPlaceholderState | null { return this.lifecycle.kind === "placeholder" ? this.lifecycle : null; diff --git a/src/features/chat/threads/thread-resume-controller.ts b/src/features/chat/threads/resume-controller.ts similarity index 88% rename from src/features/chat/threads/thread-resume-controller.ts rename to src/features/chat/threads/resume-controller.ts index d7c9c921..8024940f 100644 --- a/src/features/chat/threads/thread-resume-controller.ts +++ b/src/features/chat/threads/resume-controller.ts @@ -2,17 +2,17 @@ import type { AppServerClient } from "../../../app-server/client"; import type { ThreadTokenUsage } from "../../../app-server/runtime-metrics"; import { activeThreadId, canSwitchToThread, displayItemsEmpty, listedThreads } from "../state/selectors"; import type { ChatStateStore } from "../state/reducer"; -import type { RestoredThreadController } from "./restored-thread-controller"; -import { resumedThreadActionFromAppServerResponse } from "./thread-resume"; -import type { ThreadHistoryController } from "./thread-history-controller"; +import type { RestorationController } from "./restoration-controller"; +import { resumedThreadActionFromAppServerResponse } from "./resume"; +import type { HistoryController } from "./history-controller"; import type { ChatResumeWorkTracker, ActiveChatResume } from "../lifecycle"; -export interface ThreadResumeControllerHost { +export interface ResumeControllerHost { stateStore: ChatStateStore; vaultPath: string; resumeWork: ChatResumeWorkTracker; - history: ThreadHistoryController; - restoredThread: RestoredThreadController; + history: HistoryController; + restoration: RestorationController; currentClient: () => AppServerClient | null; ensureConnected: () => Promise; closing: () => boolean; @@ -26,8 +26,8 @@ export interface ThreadResumeControllerHost { recoverTokenUsageFromRollout?: (path: string) => Promise; } -export class ThreadResumeController { - constructor(private readonly host: ThreadResumeControllerHost) {} +export class ResumeController { + constructor(private readonly host: ResumeControllerHost) {} async resumeThread(threadId: string): Promise { if (!canSwitchToThread(this.host.stateStore.getState(), threadId)) { @@ -72,7 +72,7 @@ export class ThreadResumeController { listedThreads: listedThreads(this.host.stateStore.getState()), }), ); - this.host.restoredThread.clear(); + this.host.restoration.clear(); this.host.clearDeferredRestoredThreadHydration(); this.host.resetThreadTurnPresence(false); this.host.notifyActiveThreadIdentityChanged(); diff --git a/src/features/chat/threads/thread-resume.ts b/src/features/chat/threads/resume.ts similarity index 100% rename from src/features/chat/threads/thread-resume.ts rename to src/features/chat/threads/resume.ts diff --git a/src/features/chat/threads/thread-rollback-actions.ts b/src/features/chat/threads/rollback-actions.ts similarity index 85% rename from src/features/chat/threads/thread-rollback-actions.ts rename to src/features/chat/threads/rollback-actions.ts index 7c3e8e0e..f846f0ee 100644 --- a/src/features/chat/threads/thread-rollback-actions.ts +++ b/src/features/chat/threads/rollback-actions.ts @@ -1,10 +1,10 @@ import { threadFromAppServerThread } from "../../../app-server/thread-model"; -import { rollbackCandidateFromItems } from "../display/action-candidates"; -import { displayItemsFromTurns } from "../protocol/display-items"; +import { rollbackCandidateFromItems } from "../display/item-actions"; +import { displayItemsFromTurns } from "../display/turn-items"; import { chatTurnBusy } from "../state/reducer"; -import type { ChatThreadActionsHost } from "./thread-action-context"; -import { threadActionDispatch, threadActionState, threadActionStillTargetsPanel } from "./thread-action-context"; -import { resumedThreadActionFromActiveRuntime } from "./thread-resume"; +import type { ChatThreadActionsHost } from "./action-context"; +import { threadActionDispatch, threadActionState, threadActionStillTargetsPanel } from "./action-context"; +import { resumedThreadActionFromActiveRuntime } from "./resume"; export async function rollbackThread(host: ChatThreadActionsHost, threadId: string): Promise { if (chatTurnBusy(threadActionState(host))) { diff --git a/src/features/chat/threads/thread-selection-actions.ts b/src/features/chat/threads/selection-actions.ts similarity index 85% rename from src/features/chat/threads/thread-selection-actions.ts rename to src/features/chat/threads/selection-actions.ts index 2e627034..bca77379 100644 --- a/src/features/chat/threads/thread-selection-actions.ts +++ b/src/features/chat/threads/selection-actions.ts @@ -1,7 +1,7 @@ import { canSwitchToThread } from "../state/selectors"; import type { ChatStateStore } from "../state/reducer"; -export interface ThreadSelectionActionsHost { +export interface SelectionActionsHost { stateStore: ChatStateStore; closeForThreadSelection: () => void; focusThreadInOpenView: (threadId: string) => Promise; @@ -9,12 +9,12 @@ export interface ThreadSelectionActionsHost { addSystemMessage: (text: string) => void; } -export interface ThreadSelectionActions { +export interface SelectionActions { selectThread(threadId: string): Promise; selectThreadFromToolbar(threadId: string): Promise; } -export function createThreadSelectionActions(host: ThreadSelectionActionsHost): ThreadSelectionActions { +export function createSelectionActions(host: SelectionActionsHost): SelectionActions { const selectThread = async (threadId: string): Promise => { if (!canSwitchToThread(host.stateStore.getState(), threadId)) { host.addSystemMessage("Finish or interrupt the current turn before switching threads."); diff --git a/src/features/chat/ui/message-stream/context-model.ts b/src/features/chat/ui/message-stream/context-model.ts index fe362a92..8e08bc6a 100644 --- a/src/features/chat/ui/message-stream/context-model.ts +++ b/src/features/chat/ui/message-stream/context-model.ts @@ -1,13 +1,13 @@ import type { ChatState } from "../../state/reducer"; import { chatTurnBusy } from "../../state/reducer"; import type { DisplayItem } from "../../display/types"; +import { implementPlanCandidateFromState } from "../../state/selectors"; import { forkCandidatesFromItems, - implementPlanCandidateFromState, isForkCandidateItem, isRollbackCandidateItem, rollbackCandidateFromItems, -} from "../../display/action-candidates"; +} from "../../display/item-actions"; import type { MessageStreamContext } from "./context"; import type { ChatMessageStreamContextPort } from "./ports"; diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index 22c7775d..53b8d177 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -3,7 +3,7 @@ import type { ComponentChild as UiNode } from "preact"; import type { ChatTurnLifecycleState } from "../../state/reducer"; import type { PendingRequestBlockSnapshot } from "../../conversation/pending-requests/snapshot"; import type { DisplayItem } from "../../display/types"; -import type { PendingRequestMessageActions } from "../pending-request-message"; +import type { PendingRequestBlockActions } from "../pending-request-block"; import type { ChatTurnDiffViewState } from "../../turn-diff/model"; export interface MessageStreamBlock { @@ -11,18 +11,18 @@ export interface MessageStreamBlock { node: UiNode; } -export type RenderableTextItem = Extract; +export type TextDisplayItem = Extract; -export interface MessageDetailStateContext { +export interface TextItemDetailStateContext { openDetails: ReadonlySet; onDetailsToggle?: (key: string, open: boolean) => void; } -export interface MessageContentContext extends MessageDetailStateContext { +export interface TextItemContentContext extends TextItemDetailStateContext { renderMarkdown: (parent: HTMLElement, text: string) => void; } -export interface MessageActionContext extends MessageDetailStateContext { +export interface TextItemActionContext extends TextItemDetailStateContext { turnLifecycle: ChatTurnLifecycleState; copyText?: (text: string) => void; canImplementPlanItem?: (item: DisplayItem) => boolean; @@ -33,7 +33,7 @@ export interface MessageActionContext extends MessageDetailStateContext { onForkItem?: (item: DisplayItem, archiveSource: boolean) => void; } -export interface MessageMetadataContext extends MessageDetailStateContext { +export interface TextItemMetadataContext extends TextItemDetailStateContext { activeThreadId: string | null; workspaceRoot?: string | null; openTurnDiff?: (state: ChatTurnDiffViewState) => void; @@ -51,13 +51,13 @@ interface MessageStreamLayoutContext { pendingRequests?: PendingRequestBlockContext; } -export interface MessageItemContext extends MessageContentContext, MessageActionContext, MessageMetadataContext {} +export interface TextItemContext extends TextItemContentContext, TextItemActionContext, TextItemMetadataContext {} -export interface MessageStreamContext extends MessageStreamLayoutContext, MessageItemContext {} +export interface MessageStreamContext extends MessageStreamLayoutContext, TextItemContext {} export interface PendingRequestBlockContext { signature: string; snapshot: () => PendingRequestBlockSnapshot; - actions: () => PendingRequestMessageActions; + actions: () => PendingRequestBlockActions; consumeAutoFocus: () => boolean; } diff --git a/src/features/chat/ui/message-stream/ports.ts b/src/features/chat/ui/message-stream/ports.ts index a54f1d05..79b11d6c 100644 --- a/src/features/chat/ui/message-stream/ports.ts +++ b/src/features/chat/ui/message-stream/ports.ts @@ -2,7 +2,7 @@ import type { ChatAction, ChatState } from "../../state/reducer"; import type { PendingRequestBlockSnapshot } from "../../conversation/pending-requests/snapshot"; import type { DisplayItem } from "../../display/types"; import type { ChatTurnDiffViewState } from "../../turn-diff/model"; -import type { PendingRequestMessageActions } from "../pending-request-message"; +import type { PendingRequestBlockActions } from "../pending-request-block"; export interface ChatMessageStreamActionPort { rollbackThread: (threadId: string) => void; @@ -14,7 +14,7 @@ export interface ChatMessageStreamActionPort { export interface ChatMessageStreamRequestPort { pendingSignature: () => string; pendingSnapshot: () => PendingRequestBlockSnapshot; - pendingActions: () => PendingRequestMessageActions; + pendingActions: () => PendingRequestBlockActions; consumePendingAutoFocus: () => boolean; } diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/message-stream/stream-blocks.tsx index 2381fd5c..69c44fce 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/message-stream/stream-blocks.tsx @@ -2,21 +2,21 @@ import { Fragment, type ComponentChild as UiNode } from "preact"; import { useLayoutEffect, useState } from "preact/hooks"; import { activeTurnId } from "../../state/reducer"; -import { displayBlocksForItems } from "../../display/blocks"; -import type { ToolResultDisplayItem } from "../../display/tool-view"; +import { displayBlocksForItems } from "../../display/stream/blocks"; +import type { ToolResultDisplayItem } from "../tool-result-view"; import type { DisplayBlock, DisplayItem } from "../../display/types"; import { userInputDraftKey, userInputOtherDraftKey } from "../../protocol/requests/user-input"; -import { pendingRequestMessageNode } from "../pending-request-message"; +import { pendingRequestBlockNode } from "../pending-request-block"; import { toolResultNode } from "../tool-result"; import { activeAgentRunSummaryBlock, agentRunSummaryNode, workItemNode, type WorkItemDisplayItem } from "../work-items"; -import type { MessageStreamBlock, MessageStreamContext, RenderableTextItem } from "./context"; -import { messageItemNode } from "./message-item"; +import type { MessageStreamBlock, MessageStreamContext, TextDisplayItem } from "./context"; +import { textItemNode } from "./text-item"; function messageStreamActiveTurnId(context: Pick): string | null { return activeTurnId({ lifecycle: context.turnLifecycle }); } -function isRenderableTextItem(item: DisplayItem): item is RenderableTextItem { +function isTextDisplayItem(item: DisplayItem): item is TextDisplayItem { return item.kind === "message" || item.kind === "system" || item.kind === "userInputResult"; } @@ -37,7 +37,7 @@ function isRenderableWorkItem(item: DisplayItem): item is WorkItemDisplayItem { } function displayItemNode(item: DisplayItem, context: MessageStreamContext): UiNode { - if (isRenderableTextItem(item)) return messageItemNode(item, context); + if (isTextDisplayItem(item)) return textItemNode(item, context); if (isRenderableToolResultItem(item)) return toolResultNode(item, context); if (isRenderableWorkItem(item)) return workItemNode(item, context); } @@ -89,7 +89,7 @@ function bottomLiveBlocks(context: MessageStreamContext, activeTurn: string | nu const snapshot = context.pendingRequests.snapshot(); blocks.push({ key: "pending-requests", - node: pendingRequestMessageNode( + node: pendingRequestBlockNode( snapshot.approvals, snapshot.pendingUserInputs, { diff --git a/src/features/chat/ui/message-stream/message-actions.tsx b/src/features/chat/ui/message-stream/text-item-actions.tsx similarity index 90% rename from src/features/chat/ui/message-stream/message-actions.tsx rename to src/features/chat/ui/message-stream/text-item-actions.tsx index 7fb5c0b8..7e37eb32 100644 --- a/src/features/chat/ui/message-stream/message-actions.tsx +++ b/src/features/chat/ui/message-stream/text-item-actions.tsx @@ -4,9 +4,9 @@ import { useEffect, useRef } from "preact/hooks"; import { activeTurnId } from "../../state/reducer"; import type { DisplayItem } from "../../display/types"; import { IconButton } from "../../../../shared/ui/components"; -import type { MessageActionContext, RenderableTextItem } from "./context"; +import type { TextItemActionContext, TextDisplayItem } from "./context"; -export function MessageRole({ item, context }: { item: RenderableTextItem; context: MessageActionContext }): UiNode { +export function TextItemHeader({ item, context }: { item: TextDisplayItem; context: TextItemActionContext }): UiNode { const forkActionsKey = `message:fork-actions:${item.id}`; const forkActionsOpen = context.openDetails.has(forkActionsKey); const roleRef = useRef(null); @@ -27,7 +27,7 @@ export function MessageRole({ item, context }: { item: RenderableTextItem; conte const copyAction = item.kind === "message" && context.copyText && isMessageCopyActionVisible(item, context) && !forkActionsOpen ? ( - {displayRoleLabel(item)} {forkActionsOpen && context.canForkItem?.(item) ? ( - ) : null} {context.canImplementPlanItem?.(item) ? ( - ) : null} {context.canRollbackItem?.(item) ? ( - ): boolean { +function isMessageCopyActionVisible(item: DisplayItem, context: Pick): boolean { if (item.kind !== "message" || item.copyText === undefined) return false; const activeTurn = activeTurnId({ lifecycle: context.turnLifecycle }); return !(activeTurn && item.role === "assistant" && item.turnId === activeTurn); diff --git a/src/features/chat/ui/message-stream/message-metadata.tsx b/src/features/chat/ui/message-stream/text-item-metadata.tsx similarity index 93% rename from src/features/chat/ui/message-stream/message-metadata.tsx rename to src/features/chat/ui/message-stream/text-item-metadata.tsx index d029c432..e049a3f4 100644 --- a/src/features/chat/ui/message-stream/message-metadata.tsx +++ b/src/features/chat/ui/message-stream/text-item-metadata.tsx @@ -2,7 +2,7 @@ import { Fragment, type ComponentChild as UiNode } from "preact"; import type { DisplayDetailSection, DisplayItem } from "../../display/types"; import { IconButton } from "../../../../shared/ui/components"; -import type { MessageDetailStateContext, MessageMetadataContext } from "./context"; +import type { TextItemDetailStateContext, TextItemMetadataContext } from "./context"; export function ReferencedThread({ item }: { item: Extract }): UiNode { const reference = item.referencedThread; @@ -26,7 +26,7 @@ export function EditedFiles({ context, }: { item: Extract; - context: MessageMetadataContext; + context: TextItemMetadataContext; }): UiNode { const editedFiles = item.editedFiles ?? []; const label = editedFiles.length === 1 ? "Edited 1 file" : `Edited ${String(editedFiles.length)} files`; @@ -76,7 +76,7 @@ export function MentionedFiles({ context, }: { item: Extract; - context: MessageDetailStateContext; + context: TextItemDetailStateContext; }): UiNode { const mentionedFiles = item.mentionedFiles ?? []; const label = mentionedFiles.length === 1 ? "Mentioned 1 file" : `Mentioned ${String(mentionedFiles.length)} files`; @@ -115,14 +115,14 @@ export function AutoReviewSummaries({ summaries }: { summaries: string[] }): UiN ); } -export function MessageDetails({ - itemId, +export function TextItemDetails({ + displayItemId, details, context, }: { - itemId: string; + displayItemId: string; details: DisplayDetailSection[]; - context: MessageDetailStateContext; + context: TextItemDetailStateContext; }): UiNode { return ( <> @@ -130,7 +130,7 @@ export function MessageDetails({ @@ -184,7 +184,7 @@ function RememberedDetails({ detailsClassName: string; detailsKey: string; summary: string; - context: MessageDetailStateContext; + context: TextItemDetailStateContext; children: UiNode; }): UiNode { const details = ( diff --git a/src/features/chat/ui/message-stream/message-item.tsx b/src/features/chat/ui/message-stream/text-item.tsx similarity index 80% rename from src/features/chat/ui/message-stream/message-item.tsx rename to src/features/chat/ui/message-stream/text-item.tsx index 11125090..838ba590 100644 --- a/src/features/chat/ui/message-stream/message-item.tsx +++ b/src/features/chat/ui/message-stream/text-item.tsx @@ -3,26 +3,26 @@ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import type { DisplayItem, ExecutionState } from "../../display/types"; import { MESSAGE_CONTENT_RENDERED_EVENT } from "../message-content-events"; -import type { MessageContentContext, MessageItemContext, RenderableTextItem } from "./context"; -import { MessageRole } from "./message-actions"; -import { AutoReviewSummaries, EditedFiles, MentionedFiles, MessageDetails, ReferencedThread, SystemDetails } from "./message-metadata"; +import type { TextItemContentContext, TextItemContext, TextDisplayItem } from "./context"; +import { TextItemHeader } from "./text-item-actions"; +import { AutoReviewSummaries, EditedFiles, MentionedFiles, TextItemDetails, ReferencedThread, SystemDetails } from "./text-item-metadata"; const USER_MESSAGE_COLLAPSE_HEIGHT_PX = 360; -export function messageItemNode(item: RenderableTextItem, context: MessageItemContext): UiNode { - return ; +export function textItemNode(item: TextDisplayItem, context: TextItemContext): UiNode { + return ; } -function MessageItem({ item, context }: { item: RenderableTextItem; context: MessageItemContext }): UiNode { +function TextItem({ item, context }: { item: TextDisplayItem; context: TextItemContext }): UiNode { const collapsible = isCollapsibleUserMessage(item); const details = "details" in item ? item.details : undefined; return ( -
- +
+ {collapsible ? ( - + ) : ( - + )} {item.kind === "message" && item.editedFiles && item.editedFiles.length > 0 ? : null} {item.kind === "message" && item.referencedThread ? : null} @@ -35,14 +35,14 @@ function MessageItem({ item, context }: { item: RenderableTextItem; context: Mes {item.kind === "system" && item.details && item.details.length > 0 ? ( ) : details && details.length > 0 ? ( - + ) : null}
); } -function CollapsibleMessageContent({ item, context }: { item: RenderableTextItem; context: MessageContentContext }): UiNode { - const key = `message:${item.id}:expanded`; +function CollapsibleTextItemContent({ item, context }: { item: TextDisplayItem; context: TextItemContentContext }): UiNode { + const key = `text-item:${item.id}:expanded`; const renderModeKey = contentRenderMode(item); const collapseRef = useRef(null); const contentRef = useRef(null); @@ -93,7 +93,7 @@ function CollapsibleMessageContent({ item, context }: { item: RenderableTextItem .filter(Boolean) .join(" ")} > - +