From a79d517e8a7586aa43d517845fbf8b98bcefc89b Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 14 Jun 2026 06:04:03 +0900 Subject: [PATCH] Consolidate chat thread operations and display copy boundaries --- src/domain/threads/model.ts | 32 +- src/domain/threads/reference.ts | 23 +- .../chat/connection/connection-controller.ts | 17 +- src/features/chat/connection/messages.ts | 9 + .../chat/connection/reconnect-actions.ts | 3 +- src/features/chat/conversation/composition.ts | 4 +- .../message-stream/composition.ts | 4 +- .../pending-requests/approval-view.ts | 151 ++++++++ .../conversation/pending-requests/messages.ts | 19 + .../pending-requests/result-items.ts | 10 +- .../pending-requests/view-model.ts | 16 +- .../turns/composer-submit-actions.ts | 7 +- .../chat/conversation/turns/composition.ts | 4 +- .../chat/conversation/turns/messages.ts | 27 ++ .../turns/optimistic-turn-start.ts | 2 +- .../turns/slash-command-execution.ts | 28 +- .../turns/slash-command-handler.ts | 5 +- .../turns/turn-submission-controller.ts | 13 +- .../chat/display/details/permission-rows.ts | 2 +- src/features/chat/display/item-actions.ts | 74 ---- src/features/chat/display/item-selectors.ts | 38 ++ .../{user-message.ts => message-content.ts} | 7 + .../chat/display/items/proposed-plan.ts | 6 - src/features/chat/display/items/streaming.ts | 82 +++++ src/features/chat/display/predicates.ts | 9 - src/features/chat/display/status/runtime.ts | 24 +- src/features/chat/display/stream/blocks.ts | 2 +- src/features/chat/display/turn-items.ts | 9 +- src/features/chat/display/types.ts | 4 +- src/features/chat/panel/surface/composer.ts | 2 +- .../chat/panel/surface/create-surface.ts | 8 +- .../panel/surface/message-stream-presenter.ts | 19 +- .../chat/panel/surface/toolbar-surface.ts | 11 +- src/features/chat/panel/toolbar-actions.ts | 4 +- .../chat/protocol/inbound/controller.ts | 17 +- .../protocol/inbound/notification-plan.ts | 39 +- .../chat/protocol/server-requests/approval.ts | 155 +------- src/features/chat/runtime/effective.ts | 11 - src/features/chat/runtime/messages.ts | 67 ++++ src/features/chat/runtime/pending-settings.ts | 10 - src/features/chat/runtime/settings-actions.ts | 30 +- src/features/chat/runtime/settings-copy.ts | 23 -- src/features/chat/session.ts | 11 +- .../chat/state/message-stream-updates.ts | 56 +-- src/features/chat/state/message-stream.ts | 126 +++++-- src/features/chat/state/reducer.ts | 5 +- src/features/chat/threads/action-context.ts | 49 --- src/features/chat/threads/action-parts.ts | 69 ---- src/features/chat/threads/archive-actions.ts | 47 --- src/features/chat/threads/compact-actions.ts | 17 - src/features/chat/threads/composition.ts | 6 +- src/features/chat/threads/fork-actions.ts | 71 ---- src/features/chat/threads/goal-actions.ts | 3 +- src/features/chat/threads/messages.ts | 65 ++++ src/features/chat/threads/naming-parts.ts | 6 +- src/features/chat/threads/rename-actions.ts | 38 -- ...troller.ts => rename-editor-controller.ts} | 12 +- .../chat/threads/restoration-controller.ts | 3 +- .../chat/threads/resume-controller.ts | 5 +- src/features/chat/threads/rollback-actions.ts | 54 --- .../chat/threads/selection-actions.ts | 3 +- .../chat/threads/thread-management-actions.ts | 335 ++++++++++++++++++ src/features/chat/threads/title-context.ts | 2 +- src/features/chat/threads/title-display.ts | 10 + .../thread-export/archive-markdown.ts | 4 +- src/features/threads-view/session.ts | 3 +- src/features/threads-view/state.ts | 4 +- src/settings/archived-thread-title.ts | 19 + src/settings/dynamic-sections.ts | 2 +- src/settings/tab.ts | 2 +- tests/domain/threads/reference.test.ts | 11 +- tests/domain/threads/threads.test.ts | 16 +- .../turns/slash-command-handler.test.ts | 41 +++ .../chat/display/display-model.test.ts | 2 +- .../chat/display/item-actions.test.ts | 127 ------- .../chat/display/item-selectors.test.ts | 82 +++++ .../chat/panel/surface/toolbar.test.ts | 6 +- .../chat/protocol/inbound/controller.test.ts | 2 +- .../protocol/server-requests/approval.test.ts | 10 +- .../state/message-stream-selectors.test.ts | 92 +++++ ...st.ts => rename-editor-controller.test.ts} | 15 +- ...t.ts => thread-management-actions.test.ts} | 49 +-- .../chat/threads/title-display.test.ts | 25 ++ .../chat/ui/toolbar-archive-state.test.tsx | 4 +- tests/features/threads-view/state.test.ts | 19 + tests/features/threads-view/view.test.ts | 2 +- tests/runtime/override-commands.test.ts | 3 +- tests/runtime/runtime-settings.test.ts | 12 +- tests/settings/settings-tab.test.ts | 3 +- 89 files changed, 1458 insertions(+), 1117 deletions(-) create mode 100644 src/features/chat/connection/messages.ts create mode 100644 src/features/chat/conversation/pending-requests/approval-view.ts create mode 100644 src/features/chat/conversation/pending-requests/messages.ts create mode 100644 src/features/chat/conversation/turns/messages.ts delete mode 100644 src/features/chat/display/item-actions.ts create mode 100644 src/features/chat/display/item-selectors.ts rename src/features/chat/display/items/{user-message.ts => message-content.ts} (95%) delete mode 100644 src/features/chat/display/items/proposed-plan.ts create mode 100644 src/features/chat/display/items/streaming.ts delete mode 100644 src/features/chat/display/predicates.ts create mode 100644 src/features/chat/runtime/messages.ts delete mode 100644 src/features/chat/runtime/settings-copy.ts delete mode 100644 src/features/chat/threads/action-context.ts delete mode 100644 src/features/chat/threads/action-parts.ts delete mode 100644 src/features/chat/threads/archive-actions.ts delete mode 100644 src/features/chat/threads/compact-actions.ts delete mode 100644 src/features/chat/threads/fork-actions.ts create mode 100644 src/features/chat/threads/messages.ts delete mode 100644 src/features/chat/threads/rename-actions.ts rename src/features/chat/threads/{rename-controller.ts => rename-editor-controller.ts} (93%) delete mode 100644 src/features/chat/threads/rollback-actions.ts create mode 100644 src/features/chat/threads/thread-management-actions.ts create mode 100644 src/features/chat/threads/title-display.ts create mode 100644 src/settings/archived-thread-title.ts delete mode 100644 tests/features/chat/display/item-actions.test.ts create mode 100644 tests/features/chat/display/item-selectors.test.ts create mode 100644 tests/features/chat/state/message-stream-selectors.test.ts rename tests/features/chat/threads/{rename-controller.test.ts => rename-editor-controller.test.ts} (91%) rename tests/features/chat/threads/{actions.test.ts => thread-management-actions.test.ts} (92%) create mode 100644 tests/features/chat/threads/title-display.test.ts diff --git a/src/domain/threads/model.ts b/src/domain/threads/model.ts index c9e70281..fc0473fe 100644 --- a/src/domain/threads/model.ts +++ b/src/domain/threads/model.ts @@ -1,8 +1,3 @@ -import { shortThreadId } from "../../utils"; - -const MAX_THREAD_DISPLAY_TITLE_LENGTH = 96; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - export interface Thread { id: string; preview: string; @@ -20,16 +15,12 @@ export function getThreadTitle(thread: Thread): string { } export function explicitThreadName(thread: Thread): string | null { - const name = typeof thread.name === "string" ? normalizeTitle(thread.name) : ""; - return name.length > 0 ? name : null; + return normalizeExplicitThreadName(thread.name); } -export function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string { - if (!activeThreadId) return "Codex"; - - const thread = threads.find((item) => item.id === activeThreadId); - const title = thread ? fullThreadTitle(thread) : (fallbackTitle ?? shortThreadId(activeThreadId)); - return title ? `Codex: ${title}` : "Codex"; +export function normalizeExplicitThreadName(value: string | null | undefined): string | null { + const name = typeof value === "string" ? normalizeTitle(value) : ""; + return name.length > 0 ? name : null; } export function inheritedForkThreadName(threadId: string, threads: readonly Thread[]): string | null { @@ -43,21 +34,6 @@ export function upsertThread(threads: readonly Thread[], thread: Thread): Thread return threads.map((item, itemIndex) => (itemIndex === index ? { ...item, ...thread } : item)); } -export function archivedThreadDisplayTitle(thread: Thread): string { - const title = fullThreadTitle(thread); - if (!title || title === thread.id || UUID_PATTERN.test(title)) return "Untitled archived thread"; - return truncateTitle(title, MAX_THREAD_DISPLAY_TITLE_LENGTH); -} - -function fullThreadTitle(thread: Thread): string { - return normalizeTitle(getThreadTitle(thread)); -} - function normalizeTitle(value: string): string { return value.replace(/\s+/g, " ").trim(); } - -function truncateTitle(value: string, maxLength: number): string { - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 3).trimEnd()}...`; -} diff --git a/src/domain/threads/reference.ts b/src/domain/threads/reference.ts index 6e02afab..d77c0c11 100644 --- a/src/domain/threads/reference.ts +++ b/src/domain/threads/reference.ts @@ -1,10 +1,9 @@ -import { shortThreadId } from "../../utils"; import { getThreadTitle, type Thread } from "./model"; import type { ThreadConversationSummary } from "./transcript"; export const REFERENCED_THREAD_TURN_LIMIT = 20; -export interface ReferencedThreadDisplay { +export interface ReferencedThreadMetadata { threadId: string; title: string; includedTurns: number; @@ -13,18 +12,17 @@ export interface ReferencedThreadDisplay { interface ReferencedThreadEnvelope { version: 1; - reference: ReferencedThreadDisplay; + reference: ReferencedThreadMetadata; visibleText: string; } export interface ReferencedThreadPromptBundle { prompt: string; - referencedThread: ReferencedThreadDisplay; - status: string; + referencedThread: ReferencedThreadMetadata; } export function referencedThreadPrompt(thread: Thread, turns: readonly ThreadConversationSummary[], userRequest: string): string { - const reference = referencedThreadDisplay(thread, turns.length); + const reference = referencedThreadMetadata(thread, turns.length); const envelope = referencedThreadEnvelope(reference, userRequest); return [ @@ -46,11 +44,7 @@ export function referencedThreadPrompt(thread: Thread, turns: readonly ThreadCon ].join("\n"); } -function referencedThreadStatus(thread: Thread, count: number): string { - return `Referencing ${shortThreadId(thread.id)} (${String(count)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`; -} - -function referencedThreadDisplay(thread: Thread, count: number): ReferencedThreadDisplay { +function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata { return { threadId: thread.id, title: getThreadTitle(thread), @@ -67,12 +61,11 @@ export function referencedThreadPromptBundle( const prompt = referencedThreadPrompt(thread, [...turns], userRequest); return { prompt, - referencedThread: referencedThreadDisplay(thread, turns.length), - status: referencedThreadStatus(thread, turns.length), + referencedThread: referencedThreadMetadata(thread, turns.length), }; } -export function referencedThreadDisplayFromPrompt(text: string): { text: string; reference: ReferencedThreadDisplay } | null { +export function referencedThreadMetadataFromPrompt(text: string): { text: string; reference: ReferencedThreadMetadata } | null { const envelope = referencedThreadEnvelopeFromPrompt(text); return envelope ? { text: envelope.visibleText, reference: envelope.reference } : null; } @@ -88,7 +81,7 @@ interface ReferencedThreadEnvelopeMetadata { turnLimit: number; } -function referencedThreadEnvelope(reference: ReferencedThreadDisplay, visibleText: string): ReferencedThreadEnvelope { +function referencedThreadEnvelope(reference: ReferencedThreadMetadata, visibleText: string): ReferencedThreadEnvelope { return { version: 1, reference, diff --git a/src/features/chat/connection/connection-controller.ts b/src/features/chat/connection/connection-controller.ts index 99ac14a4..5fa697b6 100644 --- a/src/features/chat/connection/connection-controller.ts +++ b/src/features/chat/connection/connection-controller.ts @@ -3,6 +3,13 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import type { ServerInitialization } from "../../../domain/server/initialization"; import type { ChatConnectionPhase, ChatStateStore } from "../state/reducer"; import type { ChatConnectionWorkTracker, ActiveChatConnection } from "../lifecycle"; +import { + missingCommandConnectionErrorMessage, + STATUS_CONNECTED, + STATUS_CONNECTION_FAILED, + STATUS_CONNECTION_STARTING, + STATUS_CONNECTION_STOPPED, +} from "./messages"; export interface ChatConnectionAdapter { connect(): Promise; @@ -68,7 +75,7 @@ export class ChatConnectionController { this.invalidate(); this.host.invalidateResumeWork(); this.host.publishAppServerIdentity(null); - this.host.setStatus("Codex app-server stopped.", { kind: "disconnected", message: "Codex app-server stopped." }); + this.host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED }); this.host.stateStore.dispatch({ type: "connection/scoped-cleared" }); this.host.resetThreadTurnPresence(false); this.host.refreshLiveState(); @@ -109,7 +116,7 @@ export class ChatConnectionController { private async initializeConnection(connection: ActiveChatConnection): Promise { this.host.publishAppServerIdentity(null); - this.host.setStatus("Starting Codex app-server...", { kind: "connecting" }); + this.host.setStatus(STATUS_CONNECTION_STARTING, { kind: "connecting" }); try { const initialization = await this.host.connection.connect(); if (this.host.connectionWork.isStale(connection)) return; @@ -123,12 +130,12 @@ export class ChatConnectionController { if (this.host.connectionWork.isStale(connection)) return; this.host.scheduleDeferredDiagnostics(); this.host.refreshTabHeader(); - this.host.setStatus("Connected.", { kind: "connected" }); + this.host.setStatus(STATUS_CONNECTED, { kind: "connected" }); } catch (error) { if (this.host.connectionWork.isStale(connection)) return; if (error instanceof StaleConnectionError) return; const message = connectionErrorMessage(error, this.host.configuredCommand()); - this.host.setStatus("Connection failed.", { kind: "failed", message }); + this.host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message }); this.host.addSystemMessage(message); this.host.notifyConnectionFailed(); } @@ -138,7 +145,7 @@ export class ChatConnectionController { function connectionErrorMessage(error: unknown, configuredCommand: string): string { const message = error instanceof Error ? error.message : String(error); if (!isMissingCommandError(error)) return message; - return `Could not start Codex app-server because the configured command was not found: ${configuredCommand}. Check the Codex command path in settings. (${message})`; + return missingCommandConnectionErrorMessage(message, configuredCommand); } function isMissingCommandError(error: unknown): boolean { diff --git a/src/features/chat/connection/messages.ts b/src/features/chat/connection/messages.ts new file mode 100644 index 00000000..d1cb07e5 --- /dev/null +++ b/src/features/chat/connection/messages.ts @@ -0,0 +1,9 @@ +export const STATUS_RECONNECTING = "Reconnecting..."; +export const STATUS_CONNECTION_STOPPED = "Codex app-server stopped."; +export const STATUS_CONNECTION_STARTING = "Starting Codex app-server..."; +export const STATUS_CONNECTED = "Connected."; +export const STATUS_CONNECTION_FAILED = "Connection failed."; + +export function missingCommandConnectionErrorMessage(errorMessage: string, configuredCommand: string): string { + return `Could not start Codex app-server because the configured command was not found: ${configuredCommand}. Check the Codex command path in settings. (${errorMessage})`; +} diff --git a/src/features/chat/connection/reconnect-actions.ts b/src/features/chat/connection/reconnect-actions.ts index 9cd67d8d..9f7ffb77 100644 --- a/src/features/chat/connection/reconnect-actions.ts +++ b/src/features/chat/connection/reconnect-actions.ts @@ -1,5 +1,6 @@ import { activeThreadId } from "../state/selectors"; import type { ChatConnectionPhase, ChatStateStore } from "../state/reducer"; +import { STATUS_RECONNECTING } from "./messages"; export interface ChatReconnectActionsHost { stateStore: ChatStateStore; @@ -31,7 +32,7 @@ async function reconnectPanel(host: ChatReconnectActionsHost): Promise { host.clearDeferredDiagnostics(); host.reconnect(); host.stateStore.dispatch({ type: "turn/scoped-cleared" }); - host.setStatus("Reconnecting...", { kind: "connecting" }); + host.setStatus(STATUS_RECONNECTING, { kind: "connecting" }); await host.ensureConnected(); if (!threadId) return; diff --git a/src/features/chat/conversation/composition.ts b/src/features/chat/conversation/composition.ts index 8a24ab7c..4ca8a148 100644 --- a/src/features/chat/conversation/composition.ts +++ b/src/features/chat/conversation/composition.ts @@ -5,7 +5,7 @@ import { type ChatStateStore } from "../state/reducer"; import type { ChatReconnectActions } from "../connection/reconnect-actions"; import { PendingRequestController } from "./pending-requests/controller"; import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; -import type { ChatThreadActions } from "../threads/action-context"; +import type { ThreadManagementActions } from "../threads/thread-management-actions"; import type { GoalActions } from "../threads/goal-actions"; import type { HistoryController } from "../threads/history-controller"; import type { ChatInboundController } from "../protocol/inbound/controller"; @@ -76,7 +76,7 @@ export function createConversationParts( controller: ChatInboundController; serverThreads: ChatServerThreadActions; runtimeSettings: ChatRuntimeSettingsActions; - threadActions: ChatThreadActions; + threadActions: ThreadManagementActions; reconnectActions: ChatReconnectActions; goals: GoalActions; history: HistoryController; diff --git a/src/features/chat/conversation/message-stream/composition.ts b/src/features/chat/conversation/message-stream/composition.ts index 6d6ad6ef..35b4f3b1 100644 --- a/src/features/chat/conversation/message-stream/composition.ts +++ b/src/features/chat/conversation/message-stream/composition.ts @@ -5,7 +5,7 @@ import type { DisplayItem } from "../../display/types"; import { MessageStreamPresenter } from "../../panel/surface/message-stream-presenter"; import type { MessageStreamScrollBridge } from "../../panel/surface/message-stream-scroll"; import type { ChatStateStore } from "../../state/reducer"; -import type { ChatThreadActions } from "../../threads/action-context"; +import type { ThreadManagementActions } from "../../threads/thread-management-actions"; import type { HistoryController } from "../../threads/history-controller"; import type { ChatMessageScrollIntentState } from "../../ui/message-stream/scroll-intent-state"; import type { PendingRequestController } from "../pending-requests/controller"; @@ -31,7 +31,7 @@ export interface ConversationMessageStreamContext { export interface ConversationMessageStreamRefs { history: HistoryController; - threadActions: ChatThreadActions; + threadActions: ThreadManagementActions; pendingRequests: PendingRequestController; planImplementation: PlanImplementation; } diff --git a/src/features/chat/conversation/pending-requests/approval-view.ts b/src/features/chat/conversation/pending-requests/approval-view.ts new file mode 100644 index 00000000..c10e77f6 --- /dev/null +++ b/src/features/chat/conversation/pending-requests/approval-view.ts @@ -0,0 +1,151 @@ +import { jsonPreview } from "../../../../utils"; +import { permissionRows, type DisplayPermissionProfile } from "../../display/details/permission-rows"; +import { + approvalActionKind, + type ApprovalAction, + type CommandApprovalDecision, + type PendingApproval, +} from "../../protocol/server-requests/approval"; + +export interface ApprovalActionOption { + label: string; + action: ApprovalAction; + className: string; +} + +interface ApprovalSummaryParts { + reason: string | null; + target: string | null; + fallback: string; + lines: string[]; +} + +export function approvalTitle(approval: PendingApproval): string { + switch (approval.method) { + case "item/commandExecution/requestApproval": + return "Command approval"; + case "item/fileChange/requestApproval": + return "File change approval"; + case "item/permissions/requestApproval": + return "Permission approval"; + } +} + +export function approvalActionOptions(approval: PendingApproval): ApprovalActionOption[] { + if (approval.method !== "item/commandExecution/requestApproval") return defaultApprovalActionOptions(); + const decisions = approval.params.availableDecisions; + if (!decisions || decisions.length === 0) return defaultApprovalActionOptions(); + return decisions.map((decision) => ({ + label: commandDecisionLabel(decision), + action: { kind: "command-decision", decision }, + className: commandDecisionClassName(decision), + })); +} + +export function approvalSummary(approval: PendingApproval): string { + return approvalSummaryParts(approval).lines.join("\n"); +} + +export function approvalResultSummary(approval: PendingApproval): string { + const summary = approvalSummaryParts(approval); + return summary.reason ?? summary.target ?? summary.fallback; +} + +export function approvalDetails(approval: PendingApproval): { key: string; value: string }[] { + const rows: { key: string; value: string }[] = []; + addOptional(rows, "reason", approval.params.reason); + switch (approval.method) { + case "item/commandExecution/requestApproval": + addOptional(rows, "command", approval.params.command); + addOptional(rows, "cwd", approval.params.cwd); + addOptional(rows, "actions", approval.params.commandActions); + addOptional(rows, "future command rule", approval.params.proposedExecpolicyAmendment); + addOptional(rows, "future network rules", approval.params.proposedNetworkPolicyAmendments); + break; + case "item/fileChange/requestApproval": + addOptional(rows, "grant root", approval.params.grantRoot); + break; + case "item/permissions/requestApproval": + addOptional(rows, "cwd", approval.params.cwd); + addOptional(rows, "environment", approval.params.environmentId); + rows.push(...permissionRows(approval.params.permissions as DisplayPermissionProfile)); + break; + } + return rows; +} + +function approvalSummaryParts(approval: PendingApproval): ApprovalSummaryParts { + switch (approval.method) { + case "item/commandExecution/requestApproval": { + const reason = nonEmptyString(approval.params.reason); + const target = nonEmptyString(approval.params.command); + return summaryParts(reason, target, "Command execution requested."); + } + case "item/fileChange/requestApproval": { + const reason = nonEmptyString(approval.params.reason); + const grantRoot = nonEmptyString(approval.params.grantRoot); + return summaryParts(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"); + } + case "item/permissions/requestApproval": { + return summaryParts(approval.params.reason, `cwd: ${approval.params.cwd}`, "Permission change requested."); + } + } +} + +function summaryParts(reason: string | null, target: string | null, fallback: string, extra?: string | null): ApprovalSummaryParts { + const lines = [reason, target, extra].filter((value): value is string => Boolean(value)); + return { + reason, + target, + fallback, + lines: lines.length > 0 ? lines : [fallback], + }; +} + +function defaultApprovalActionOptions(): ApprovalActionOption[] { + return [ + { label: "Allow", action: "accept", className: "mod-cta" }, + { label: "Allow session", action: "accept-session", className: "" }, + { label: "Deny", action: "decline", className: "mod-warning" }, + { label: "Cancel", action: "cancel", className: "" }, + ]; +} + +function commandDecisionLabel(decision: CommandApprovalDecision): string { + if (decision === "accept") return "Allow"; + if (decision === "acceptForSession") return "Allow session"; + if (decision === "decline") return "Deny"; + if (decision === "cancel") return "Cancel"; + if ("acceptWithExecpolicyAmendment" in decision) return "Allow rule"; + if ("applyNetworkPolicyAmendment" in decision) { + return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "Allow network rule" : "Deny network rule"; + } + return "Choose"; +} + +function commandDecisionClassName(decision: CommandApprovalDecision): string { + const kind = approvalActionKind({ kind: "command-decision", decision }); + if (kind === "accept") return "mod-cta"; + if (kind === "decline") return "mod-warning"; + return ""; +} + +function addOptional(rows: { key: string; value: string }[], key: string, value: unknown): void { + if (value === null || value === undefined) return; + if (Array.isArray(value) && value.length === 0) return; + rows.push({ key, value: stringValue(value) }); +} + +function stringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); + if (Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean")) { + return value.join("\n"); + } + if (value === null || value === undefined) return fallback; + return jsonPreview(value); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} diff --git a/src/features/chat/conversation/pending-requests/messages.ts b/src/features/chat/conversation/pending-requests/messages.ts new file mode 100644 index 00000000..a8b09775 --- /dev/null +++ b/src/features/chat/conversation/pending-requests/messages.ts @@ -0,0 +1,19 @@ +export function cannotSendApprovalResponseMessage(): string { + return "Could not send approval response because Codex app-server is not connected."; +} + +export function cannotSendUserInputMessage(): string { + return "Could not send user input because Codex app-server is not connected."; +} + +export function cannotCancelUserInputMessage(): string { + return "Could not cancel user input because Codex app-server is not connected."; +} + +export function userCancelledInputRequestMessage(): string { + return "User cancelled input request."; +} + +export function cannotRejectServerRequestMessage(): string { + return "Could not reject app-server request because Codex app-server is not connected."; +} diff --git a/src/features/chat/conversation/pending-requests/result-items.ts b/src/features/chat/conversation/pending-requests/result-items.ts index 32005f69..e92733b5 100644 --- a/src/features/chat/conversation/pending-requests/result-items.ts +++ b/src/features/chat/conversation/pending-requests/result-items.ts @@ -1,11 +1,5 @@ -import { - approvalActionKind, - approvalDetails, - approvalResultSummary, - approvalTitle, - type ApprovalAction, - type PendingApproval, -} from "../../protocol/server-requests/approval"; +import { approvalActionKind, type ApprovalAction, type PendingApproval } from "../../protocol/server-requests/approval"; +import { approvalDetails, approvalResultSummary, approvalTitle } from "./approval-view"; import type { DisplayDetailSection, DisplayItem } from "../../display/types"; import type { PendingUserInput } from "../../protocol/server-requests/user-input"; import { definedProp } from "../../../../utils"; diff --git a/src/features/chat/conversation/pending-requests/view-model.ts b/src/features/chat/conversation/pending-requests/view-model.ts index d3e7c01e..a6ca762c 100644 --- a/src/features/chat/conversation/pending-requests/view-model.ts +++ b/src/features/chat/conversation/pending-requests/view-model.ts @@ -1,12 +1,6 @@ import type { RequestId } from "../../../../app-server/connection/rpc-messages"; -import { - approvalActionOptions, - approvalDetails, - approvalSummary, - approvalTitle, - type ApprovalAction, - type PendingApproval, -} from "../../protocol/server-requests/approval"; +import { approvalActionOptions, approvalDetails, approvalSummary, approvalTitle, type ApprovalActionOption } from "./approval-view"; +import { type ApprovalAction, type PendingApproval } from "../../protocol/server-requests/approval"; import { questionDefaultAnswer, userInputDraftKey, @@ -17,11 +11,7 @@ import { export type PendingRequestId = RequestId; type PendingRequestApprovalAction = ApprovalAction; -interface PendingRequestApprovalOption { - label: string; - className: string; - action: PendingRequestApprovalAction; -} +type PendingRequestApprovalOption = ApprovalActionOption; interface PendingRequestDetailRow { key: string; diff --git a/src/features/chat/conversation/turns/composer-submit-actions.ts b/src/features/chat/conversation/turns/composer-submit-actions.ts index e146503b..57406c1a 100644 --- a/src/features/chat/conversation/turns/composer-submit-actions.ts +++ b/src/features/chat/conversation/turns/composer-submit-actions.ts @@ -5,7 +5,8 @@ import type { ChatStateStore } from "../../state/reducer"; import { parseSlashCommand } from "../composer/suggestions"; import type { SlashCommandExecutionResult } from "./slash-command-execution"; import type { SlashCommandName } from "../composer/slash-commands"; -import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference"; +import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; +import { STATUS_INTERRUPT_REQUESTED } from "./messages"; export interface ComposerSubmitActionsHost { stateStore: ChatStateStore; @@ -17,7 +18,7 @@ export interface ComposerSubmitActionsHost { execute(command: SlashCommandName, args: string): Promise; }; turnSubmission: { - sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise; + sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata): Promise; }; connection: { ensureConnected: () => Promise; @@ -84,7 +85,7 @@ async function interruptTurn(host: ComposerSubmitActionsHost): Promise { if (!client || !state.activeThreadId || !turnId) return; try { await client.interruptTurn(state.activeThreadId, turnId); - host.status.setStatus("Interrupt requested."); + host.status.setStatus(STATUS_INTERRUPT_REQUESTED); } catch (error) { host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); } diff --git a/src/features/chat/conversation/turns/composition.ts b/src/features/chat/conversation/turns/composition.ts index 0501b2e2..ee56cb33 100644 --- a/src/features/chat/conversation/turns/composition.ts +++ b/src/features/chat/conversation/turns/composition.ts @@ -5,7 +5,7 @@ import type { ChatReconnectActions } from "../../connection/reconnect-actions"; import type { DisplayDetailSection } from "../../display/types"; import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions"; import type { ChatStateStore } from "../../state/reducer"; -import type { ChatThreadActions } from "../../threads/action-context"; +import type { ThreadManagementActions } from "../../threads/thread-management-actions"; import type { GoalActions } from "../../threads/goal-actions"; import { createComposerSubmitActions, type ComposerSubmitActions } from "./composer-submit-actions"; import { createPlanImplementation, type PlanImplementation } from "./plan-implementation"; @@ -51,7 +51,7 @@ export interface ConversationTurnActionsContext { export interface ConversationTurnActionsRefs { serverThreads: ChatServerThreadActions; runtimeSettings: ChatRuntimeSettingsActions; - threadActions: ChatThreadActions; + threadActions: ThreadManagementActions; reconnectActions: ChatReconnectActions; goals: GoalActions; } diff --git a/src/features/chat/conversation/turns/messages.ts b/src/features/chat/conversation/turns/messages.ts new file mode 100644 index 00000000..de27e925 --- /dev/null +++ b/src/features/chat/conversation/turns/messages.ts @@ -0,0 +1,27 @@ +import type { Thread } from "../../../../domain/threads/model"; +import { REFERENCED_THREAD_TURN_LIMIT } from "../../../../domain/threads/reference"; +import { shortThreadId } from "../../../../utils"; + +export const STATUS_TURN_RUNNING = "Turn running..."; +export const STATUS_INTERRUPT_REQUESTED = "Interrupt requested."; +export const STATUS_STEERED_CURRENT_TURN = "Steered current turn."; + +export function turnCompletedStatus(status: string): string { + return `Turn ${status}.`; +} + +export function currentTurnNotSteerableMessage(): string { + return "Current turn is not steerable yet."; +} + +export function currentThreadReferenceMessage(): string { + return "Use the current thread directly instead of referencing it."; +} + +export function referencedThreadStatus(thread: Thread, includedTurns: number): string { + return `Referencing ${shortThreadId(thread.id)} (${String(includedTurns)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`; +} + +export function referencedThreadUnreadableMessage(): string { + return "Referenced thread has no readable conversation turns."; +} diff --git a/src/features/chat/conversation/turns/optimistic-turn-start.ts b/src/features/chat/conversation/turns/optimistic-turn-start.ts index ff49a9a9..1c32ba38 100644 --- a/src/features/chat/conversation/turns/optimistic-turn-start.ts +++ b/src/features/chat/conversation/turns/optimistic-turn-start.ts @@ -1,6 +1,6 @@ import type { PendingTurnStart } from "../../state/reducer"; import type { DisplayFileMention, DisplayItem, MessageDisplayItem } from "../../display/types"; -import { fileMentionsFromInput, userMessageDisplayText } from "../../display/items/user-message"; +import { fileMentionsFromInput, userMessageDisplayText } from "../../display/items/message-content"; import { attachHookRunsToTurn } from "../../state/message-stream-updates"; import type { CodexInput } from "../../../../domain/chat/input"; diff --git a/src/features/chat/conversation/turns/slash-command-execution.ts b/src/features/chat/conversation/turns/slash-command-execution.ts index 8317e260..59ca0f6d 100644 --- a/src/features/chat/conversation/turns/slash-command-execution.ts +++ b/src/features/chat/conversation/turns/slash-command-execution.ts @@ -3,7 +3,7 @@ import type { ThreadGoal, ThreadGoalStatus } from "../../../../domain/threads/go import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import type { Thread } from "../../../../domain/threads/model"; import { getThreadTitle } from "../../../../domain/threads/model"; -import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference"; +import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; import { slashCommandDefinition, slashCommandHelpSections, @@ -13,7 +13,15 @@ import { type SlashCommandSubcommandDefinition, } from "../composer/slash-commands"; import type { DisplayDetailSection, DisplayDetailMetaRow } from "../../display/types"; -import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../runtime/settings-copy"; +import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../runtime/messages"; +import { currentThreadReferenceMessage } from "./messages"; +import { + finishBeforeArchivingThreadsMessage, + interruptBeforeRollbackMessage, + noActiveThreadToCompactMessage, + noActiveThreadToForkMessage, + noActiveThreadToRollbackMessage, +} from "../../threads/messages"; import { parseModelOverride, parseReasoningEffortOverride } from "./runtime-setting-commands"; export interface SlashCommandExecutionContext { @@ -54,13 +62,13 @@ export interface SlashCommandExecutionContext { export interface SlashCommandExecutionResult { sendText?: string; sendInput?: CodexInput; - referencedThread?: ReferencedThreadDisplay; + referencedThread?: ReferencedThreadMetadata; composerDraft?: string; } export interface ThreadReferenceInput { input: CodexInput; - referencedThread: ReferencedThreadDisplay; + referencedThread: ReferencedThreadMetadata; } export async function executeSlashCommand( @@ -106,7 +114,7 @@ export async function executeSlashCommand( return; } if (thread.thread.id === context.activeThreadId) { - context.addSystemMessage("Use the current thread directly instead of referencing it."); + context.addSystemMessage(currentThreadReferenceMessage()); return; } const reference = await context.referThread(thread.thread, parsed.message); @@ -116,7 +124,7 @@ export async function executeSlashCommand( if (command === "fork") { if (!context.activeThreadId) { - context.addSystemMessage("No active thread to fork."); + context.addSystemMessage(noActiveThreadToForkMessage()); return; } await context.forkThread(context.activeThreadId); @@ -125,11 +133,11 @@ export async function executeSlashCommand( if (command === "rollback") { if (!context.activeThreadId) { - context.addSystemMessage("No active thread to roll back."); + context.addSystemMessage(noActiveThreadToRollbackMessage()); return; } if (context.busy) { - context.addSystemMessage("Interrupt the current turn before rolling back."); + context.addSystemMessage(interruptBeforeRollbackMessage()); return; } await context.rollbackThread(context.activeThreadId); @@ -138,7 +146,7 @@ export async function executeSlashCommand( if (command === "compact") { if (!context.activeThreadId) { - context.addSystemMessage("No active thread to compact."); + context.addSystemMessage(noActiveThreadToCompactMessage()); return; } await context.compactThread(context.activeThreadId); @@ -147,7 +155,7 @@ export async function executeSlashCommand( if (command === "archive") { if (context.busy) { - context.addSystemMessage("Finish or interrupt the current turn before archiving threads."); + context.addSystemMessage(finishBeforeArchivingThreadsMessage()); return; } diff --git a/src/features/chat/conversation/turns/slash-command-handler.ts b/src/features/chat/conversation/turns/slash-command-handler.ts index d3300221..0d7d31c3 100644 --- a/src/features/chat/conversation/turns/slash-command-handler.ts +++ b/src/features/chat/conversation/turns/slash-command-handler.ts @@ -3,6 +3,7 @@ import { codexTextInputWithAttachments, type CodexInput } from "../../../../doma import { readReferencedThreadConversationSummaries } from "../../../../app-server/services/threads"; import { referencedThreadPromptBundle, REFERENCED_THREAD_TURN_LIMIT } from "../../../../domain/threads/reference"; import type { Thread } from "../../../../domain/threads/model"; +import { referencedThreadStatus, referencedThreadUnreadableMessage } from "./messages"; import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionContext, @@ -72,12 +73,12 @@ async function referencedThreadInput( try { const turns = await readReferencedThreadConversationSummaries(client, thread.id, REFERENCED_THREAD_TURN_LIMIT); if (turns.length === 0) { - host.addSystemMessage("Referenced thread has no readable conversation turns."); + host.addSystemMessage(referencedThreadUnreadableMessage()); return null; } const reference = referencedThreadPromptBundle(thread, turns, message); const messageInput = host.codexInput(message); - host.setStatus(reference.status); + host.setStatus(referencedThreadStatus(thread, turns.length)); return { input: codexTextInputWithAttachments(reference.prompt, messageInput), referencedThread: reference.referencedThread, diff --git a/src/features/chat/conversation/turns/turn-submission-controller.ts b/src/features/chat/conversation/turns/turn-submission-controller.ts index 0b5490c7..3fbe90ae 100644 --- a/src/features/chat/conversation/turns/turn-submission-controller.ts +++ b/src/features/chat/conversation/turns/turn-submission-controller.ts @@ -1,8 +1,9 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; import type { CodexInput } from "../../../../domain/chat/input"; -import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference"; +import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; import { submissionStateSnapshot } from "../../state/selectors"; import type { ChatStateStore } from "../../state/reducer"; +import { currentTurnNotSteerableMessage, STATUS_STEERED_CURRENT_TURN, STATUS_TURN_RUNNING } from "./messages"; import { acknowledgeOptimisticTurnStart, cleanupFailedTurnStart, @@ -31,7 +32,7 @@ export class TurnSubmissionController { constructor(private readonly host: TurnSubmissionControllerHost) {} - async sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise { + async sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata): Promise { if (!(await this.host.ensureRestoredThreadLoaded())) return; const client = this.host.currentClient(); if (!client) return; @@ -94,7 +95,7 @@ export class TurnSubmissionController { pendingTurnStart: pendingStart, }); this.host.stateStore.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems }); - this.host.setStatus("Turn running..."); + this.host.setStatus(STATUS_TURN_RUNNING); } } catch (error) { const failedState = submissionStateSnapshot(this.host.stateStore.getState()); @@ -115,13 +116,13 @@ export class TurnSubmissionController { client: AppServerClient, text: string, codexInputOverride?: CodexInput, - referencedThread?: ReferencedThreadDisplay, + referencedThread?: ReferencedThreadMetadata, ): Promise { const state = submissionStateSnapshot(this.host.stateStore.getState()); const threadId = state.activeThreadId; const expectedTurnId = state.activeTurnId; if (!threadId || !expectedTurnId) { - this.host.addSystemMessage("Current turn is not steerable yet."); + this.host.addSystemMessage(currentTurnNotSteerableMessage()); return; } @@ -142,7 +143,7 @@ export class TurnSubmissionController { codexInput, }), }); - this.host.setStatus("Steered current turn."); + this.host.setStatus(STATUS_STEERED_CURRENT_TURN); } catch (error) { if (!this.isCurrentTurn(threadId, expectedTurnId)) return; this.host.setDraft(text, { focus: true }); diff --git a/src/features/chat/display/details/permission-rows.ts b/src/features/chat/display/details/permission-rows.ts index af2ca154..72665e6f 100644 --- a/src/features/chat/display/details/permission-rows.ts +++ b/src/features/chat/display/details/permission-rows.ts @@ -5,7 +5,7 @@ interface DetailRow { value: string; } -interface DisplayPermissionProfile { +export interface DisplayPermissionProfile { network?: { enabled?: boolean | null } | null; fileSystem?: { entries?: readonly { path: DisplayFileSystemPath; access?: unknown }[] | null; diff --git a/src/features/chat/display/item-actions.ts b/src/features/chat/display/item-actions.ts deleted file mode 100644 index f15d5f2f..00000000 --- a/src/features/chat/display/item-actions.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { isCompletedTurnOutcomeMessage } from "./predicates"; -import type { DisplayItem, MessageDisplayItem } from "./types"; - -export interface ForkCandidate { - displayItemId: string; - turnId: string; -} - -export interface RollbackCandidate { - turnId: string; - displayItemId: string; - text: string; -} - -export function forkCandidatesFromItems(items: readonly DisplayItem[]): readonly ForkCandidate[] { - const turnOutcomeItemsByTurn = new Map(); - for (const item of items) { - if (!item.turnId || !isCompletedTurnOutcomeMessage(item)) continue; - 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.displayItemId && item.turnId === candidate.turnId); -} - -export function turnsAfterTurnId(items: readonly DisplayItem[], turnId: string): number | null { - const turnIds = orderedTurnIds(items); - const index = turnIds.indexOf(turnId); - return index === -1 ? null : turnIds.length - index - 1; -} - -export function rollbackCandidateFromItems(items: readonly DisplayItem[]): RollbackCandidate | null { - const lastTurnId = latestTurnId(items); - if (!lastTurnId) return null; - - const userMessage = items.find((item): item is MessageDisplayItem => isUserMessageForTurn(item, lastTurnId)); - if (!userMessage) return null; - - return { - turnId: lastTurnId, - 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.displayItemId && item.turnId === candidate.turnId, - ); -} - -function orderedTurnIds(items: readonly DisplayItem[]): string[] { - const turnIds: string[] = []; - const seen = new Set(); - for (const item of items) { - if (!item.turnId || seen.has(item.turnId)) continue; - seen.add(item.turnId); - turnIds.push(item.turnId); - } - return turnIds; -} - -function latestTurnId(items: readonly DisplayItem[]): string | null { - for (const item of [...items].reverse()) { - if (item.turnId) return item.turnId; - } - return null; -} - -function isUserMessageForTurn(item: DisplayItem, turnId: string): item is MessageDisplayItem { - return item.kind === "message" && item.role === "user" && item.turnId === turnId; -} diff --git a/src/features/chat/display/item-selectors.ts b/src/features/chat/display/item-selectors.ts new file mode 100644 index 00000000..c945362a --- /dev/null +++ b/src/features/chat/display/item-selectors.ts @@ -0,0 +1,38 @@ +import type { AssistantAuthoredMessageDisplayItem, DisplayItem } from "./types"; + +export interface ForkCandidate { + displayItemId: string; + turnId: string; +} + +interface RollbackCandidateItem { + turnId: string; + displayItemId: string; +} + +export function forkCandidatesFromItems(items: readonly DisplayItem[]): readonly ForkCandidate[] { + const turnOutcomeItemsByTurn = new Map(); + for (const item of items) { + if (!item.turnId || !isCompletedTurnOutcomeMessage(item)) continue; + turnOutcomeItemsByTurn.set(item.turnId, { displayItemId: item.id, turnId: item.turnId }); + } + return [...turnOutcomeItemsByTurn.values()]; +} + +export function isAssistantAuthoredMessage(item: DisplayItem): item is AssistantAuthoredMessageDisplayItem { + return item.kind === "message" && (item.messageKind === "assistantResponse" || item.messageKind === "proposedPlan"); +} + +export function isCompletedTurnOutcomeMessage(item: DisplayItem): boolean { + return isAssistantAuthoredMessage(item) && item.messageState === "completed"; +} + +export function isForkCandidateItem(item: DisplayItem, candidates: readonly ForkCandidate[]): boolean { + return candidates.some((candidate) => item.id === candidate.displayItemId && item.turnId === candidate.turnId); +} + +export function isRollbackCandidateItem(item: DisplayItem, candidate: RollbackCandidateItem | null): boolean { + return Boolean( + candidate && item.kind === "message" && item.role === "user" && item.id === candidate.displayItemId && item.turnId === candidate.turnId, + ); +} diff --git a/src/features/chat/display/items/user-message.ts b/src/features/chat/display/items/message-content.ts similarity index 95% rename from src/features/chat/display/items/user-message.ts rename to src/features/chat/display/items/message-content.ts index 922d39ce..db4761f4 100644 --- a/src/features/chat/display/items/user-message.ts +++ b/src/features/chat/display/items/message-content.ts @@ -14,6 +14,13 @@ export function fileMentionsFromInput(input: readonly CodexInputItem[]): Display return mentions; } +export function normalizeProposedPlanMarkdown(text: string): string { + return text + .replace(/^\s*\s*\n?/i, "") + .replace(/\n?\s*<\/proposed_plan>\s*$/i, "") + .trim(); +} + export function userMessageDisplayText(text: string, input: CodexInput): string { const names = resolvedSkillNames(input); if (names.length === 0) return text; diff --git a/src/features/chat/display/items/proposed-plan.ts b/src/features/chat/display/items/proposed-plan.ts deleted file mode 100644 index bb494869..00000000 --- a/src/features/chat/display/items/proposed-plan.ts +++ /dev/null @@ -1,6 +0,0 @@ -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/items/streaming.ts b/src/features/chat/display/items/streaming.ts new file mode 100644 index 00000000..04b69220 --- /dev/null +++ b/src/features/chat/display/items/streaming.ts @@ -0,0 +1,82 @@ +import type { FileUpdateChange } from "../../../../app-server/protocol/turn"; +import type { DisplayItem, DisplayKind } from "../types"; +import { normalizeFileChanges } from "../turn-items"; + +const STREAMED_TOOL_DETAILS_TEXT = "details"; +export const STREAMED_COMMAND_RUNNING_TEXT = "Command running"; +export const STREAMED_FILE_CHANGE_IN_PROGRESS_TEXT = "File change inProgress"; +export const STREAMED_MCP_PROGRESS_LABEL = "mcp progress"; +const UNKNOWN_STREAMED_COMMAND_CWD = "(unknown)"; + +export function streamedTextDisplayItem(params: { + id: string; + turnId: string; + label: string; + delta: string; + kind: Extract; +}): DisplayItem { + return { + id: params.id, + kind: params.kind, + role: "tool", + text: `${params.label}: ${params.delta}`, + turnId: params.turnId, + sourceItemId: params.id, + }; +} + +export function streamedToolOutputDisplayItem(params: { id: string; turnId: string; output: string; fallbackLabel: string }): DisplayItem { + return { + id: params.id, + kind: "tool", + role: "tool", + text: STREAMED_TOOL_DETAILS_TEXT, + toolLabel: params.fallbackLabel, + turnId: params.turnId, + sourceItemId: params.id, + output: params.output, + }; +} + +export function streamedItemOutputDisplayItem(params: { + id: string; + turnId: string; + output: string; + kind: "command" | "fileChange"; + fallbackText: string; +}): DisplayItem { + return { + id: params.id, + kind: params.kind, + role: "tool", + text: params.fallbackText, + turnId: params.turnId, + sourceItemId: params.id, + output: params.output, + ...(params.kind === "fileChange" + ? { + status: "inProgress", + changes: [], + executionState: "running", + } + : { + command: params.fallbackText, + cwd: UNKNOWN_STREAMED_COMMAND_CWD, + status: "running", + executionState: "running", + }), + } as DisplayItem; +} + +export function streamingFileChangeDisplayItem(itemId: string, turnId: string, changes: FileUpdateChange[], status: string): DisplayItem { + return { + id: itemId, + kind: "fileChange", + role: "tool", + text: `File change ${status}`, + turnId, + sourceItemId: itemId, + status, + changes: normalizeFileChanges(changes), + }; +} diff --git a/src/features/chat/display/predicates.ts b/src/features/chat/display/predicates.ts deleted file mode 100644 index cbafaa8c..00000000 --- a/src/features/chat/display/predicates.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AssistantAuthoredMessageDisplayItem, DisplayItem } from "./types"; - -export function isAssistantAuthoredMessage(item: DisplayItem): item is AssistantAuthoredMessageDisplayItem { - return item.kind === "message" && (item.messageKind === "assistantResponse" || item.messageKind === "proposedPlan"); -} - -export function isCompletedTurnOutcomeMessage(item: DisplayItem): boolean { - return isAssistantAuthoredMessage(item) && item.messageState === "completed"; -} diff --git a/src/features/chat/display/status/runtime.ts b/src/features/chat/display/status/runtime.ts index 2b3db43e..e97eaaf6 100644 --- a/src/features/chat/display/status/runtime.ts +++ b/src/features/chat/display/status/runtime.ts @@ -6,16 +6,22 @@ import { defaultEffortForModelMetadata } from "../../../../domain/catalog/metada import { currentApprovalsReviewer, currentApprovalPolicy, + currentServiceTier, currentModel, currentReasoningEffort, autoReviewActive, - fastModeLabel, + fastModeActive, runtimeConfigOrDefault, - serviceTierLabel, supportedReasoningEfforts, } from "../../runtime/effective"; import type { RuntimeSnapshot } from "../../runtime/snapshot"; -import { collaborationModeLabel, effectiveCollaborationMode, pendingRuntimeSettingLabel } from "../../runtime/pending-settings"; +import { effectiveCollaborationMode } from "../../runtime/pending-settings"; +import { + collaborationModeLabel, + fastModeLabel as formatFastModeLabel, + pendingRuntimeSettingLabel, + serviceTierLabel as formatServiceTierLabel, +} from "../../runtime/messages"; export interface ContextSummary { label: string; @@ -67,6 +73,18 @@ export interface EffortStatusLinesInput { const CODEX_DEFAULT_LABEL = "(Codex default)"; const NOT_REPORTED_LABEL = "(not reported)"; +export function serviceTierLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { + return formatServiceTierLabel(currentServiceTier(snapshot, config)); +} + +export function fastModeLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { + return formatFastModeLabel({ + requestedOff: snapshot.requestedServiceTier.kind === "set" && snapshot.requestedServiceTier.value === "off", + active: fastModeActive(snapshot, config), + serviceTier: currentServiceTier(snapshot, config), + }); +} + export function contextSummary(snapshot: RuntimeSnapshot): ContextSummary | null { const usage = snapshot.tokenUsage; const config = runtimeConfigOrDefault(snapshot.runtimeConfig); diff --git a/src/features/chat/display/stream/blocks.ts b/src/features/chat/display/stream/blocks.ts index 75ade26f..28e2a498 100644 --- a/src/features/chat/display/stream/blocks.ts +++ b/src/features/chat/display/stream/blocks.ts @@ -1,5 +1,5 @@ import type { DisplayBlock, DisplayItem, DisplayKind } from "../types"; -import { isCompletedTurnOutcomeMessage } from "../predicates"; +import { isCompletedTurnOutcomeMessage } from "../item-selectors"; import { pathRelativeToRoot } from "../details/path-labels"; const STEERING_ACTIVITY_LABEL = "steer"; diff --git a/src/features/chat/display/turn-items.ts b/src/features/chat/display/turn-items.ts index 2cd9aa67..c9e931b6 100644 --- a/src/features/chat/display/turn-items.ts +++ b/src/features/chat/display/turn-items.ts @@ -2,12 +2,11 @@ import type { DisplayDetailSection, DisplayFileChange, DisplayFileMention, Displ import type { HistoricalTurn } from "../../../domain/threads/history"; import type { FileUpdateChange, TurnItem } from "../../../app-server/protocol/turn"; import { definedProp, truncate } from "../../../utils"; -import { referencedThreadDisplayFromPrompt, type ReferencedThreadDisplay } from "../../../domain/threads/reference"; +import { referencedThreadMetadataFromPrompt, type ReferencedThreadMetadata } from "../../../domain/threads/reference"; import { turnUserItemText } from "../../../app-server/protocol/turn"; 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 { fileMentionsFromInput, normalizeProposedPlanMarkdown, userMessageDisplayText } from "./items/message-content"; import { bodyDetail, compactToolSummary, @@ -47,7 +46,7 @@ interface UserMessageDisplayData extends BaseDisplayData { referencedThread: { text: string; displayText: string; - reference: ReferencedThreadDisplay; + reference: ReferencedThreadMetadata; } | null; } @@ -160,7 +159,7 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display function userMessageDisplayDataFromItem(item: UserMessageItem): UserMessageDisplayData { const text = turnUserItemText(item); - const referencedThread = referencedThreadDisplayFromPrompt(text); + const referencedThread = referencedThreadMetadataFromPrompt(text); return { id: item.id, text, diff --git a/src/features/chat/display/types.ts b/src/features/chat/display/types.ts index 51579aeb..e4dc4b0a 100644 --- a/src/features/chat/display/types.ts +++ b/src/features/chat/display/types.ts @@ -1,4 +1,4 @@ -import type { ReferencedThreadDisplay } from "../../../domain/threads/reference"; +import type { ReferencedThreadMetadata } from "../../../domain/threads/reference"; export type DisplayKind = | "message" @@ -45,7 +45,7 @@ interface MessageDisplayBase extends DisplayBase { role: "user" | "assistant"; clientId?: string; copyText?: string; - referencedThread?: ReferencedThreadDisplay; + referencedThread?: ReferencedThreadMetadata; mentionedFiles?: DisplayFileMention[]; editedFiles?: string[]; turnDiff?: DisplayTurnDiff; diff --git a/src/features/chat/panel/surface/composer.ts b/src/features/chat/panel/surface/composer.ts index 2b57070d..0ea2f818 100644 --- a/src/features/chat/panel/surface/composer.ts +++ b/src/features/chat/panel/surface/composer.ts @@ -9,8 +9,8 @@ import { runtimeConfigOrDefault, supportedReasoningEfforts, } from "../../runtime/effective"; -import { compactReasoningEffortLabel } from "../../runtime/settings-copy"; import { contextSummary } from "../../display/status/runtime"; +import { compactReasoningEffortLabel } from "../../runtime/messages"; 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/surface/create-surface.ts b/src/features/chat/panel/surface/create-surface.ts index 20a38645..3a7c3c85 100644 --- a/src/features/chat/panel/surface/create-surface.ts +++ b/src/features/chat/panel/surface/create-surface.ts @@ -5,9 +5,9 @@ import type { ChatConnectionController } from "../../connection/connection-contr import type { ChatReconnectActions } from "../../connection/reconnect-actions"; import type { ChatInboundController } from "../../protocol/inbound/controller"; import type { ChatServerThreadActions } from "../../connection/server-actions/threads"; -import type { ChatThreadActions } from "../../threads/action-context"; +import type { ThreadManagementActions } from "../../threads/thread-management-actions"; import type { ToolbarPanelActions } from "../toolbar-actions"; -import type { RenameController } from "../../threads/rename-controller"; +import type { ThreadRenameEditorController } from "../../threads/rename-editor-controller"; import type { SelectionActions } from "../../threads/selection-actions"; import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions"; import type { GoalActions } from "../../threads/goal-actions"; @@ -29,9 +29,9 @@ export interface ChatPanelSurfaceDependencies { reconnectActions: ChatReconnectActions; inboundController: ChatInboundController; serverThreads: ChatServerThreadActions; - threadActions: ChatThreadActions; + threadActions: ThreadManagementActions; toolbarPanels: ToolbarPanelActions; - rename: RenameController; + rename: ThreadRenameEditorController; selection: SelectionActions; runtimeSettings: ChatRuntimeSettingsActions; goals: GoalActions; diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/message-stream-presenter.ts index d1bcfe5e..699049ac 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/message-stream-presenter.ts @@ -14,15 +14,14 @@ import { MarkdownMessageRenderer } from "../../ui/message-stream/markdown-render import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/viewport"; import type { DisplayItem } from "../../display/types"; import { implementPlanCandidateFromState } from "../../state/selectors"; -import { messageStreamActiveItems, messageStreamDisplayItems, messageStreamStableItems } from "../../state/message-stream"; +import { type ForkCandidate, forkCandidatesFromItems, isForkCandidateItem, isRollbackCandidateItem } from "../../display/item-selectors"; import { - type ForkCandidate, - forkCandidatesFromItems, - isForkCandidateItem, - isRollbackCandidateItem, - type RollbackCandidate, - rollbackCandidateFromItems, -} from "../../display/item-actions"; + messageStreamActiveItems, + messageStreamDisplayItems, + messageStreamRollbackCandidate, + messageStreamStableItems, + type MessageStreamRollbackCandidate, +} from "../../state/message-stream"; import { messageStreamBlocks } from "../../ui/message-stream/stream-blocks"; import type { MessageStreamContext } from "../../ui/message-stream/context"; import { messageStreamStateFromShellState, useChatPanelShellState, type ChatPanelMessageStreamShellState } from "../../ui/shell-state"; @@ -123,7 +122,7 @@ export interface MessageStreamStateProjection { disclosures: ChatDisclosureUiState; forkActionsItemId: string | null; implementPlanCandidate: DisplayItem | null; - rollbackCandidate: RollbackCandidate | null; + rollbackCandidate: MessageStreamRollbackCandidate | null; forkCandidates: readonly ForkCandidate[]; } @@ -233,7 +232,7 @@ export function messageStreamContextFromState( export function messageStreamStateProjection(state: ChatPanelMessageStreamShellState, vaultPath: string): MessageStreamStateProjection { const busy = chatTurnBusy(state); const displayItems = messageStreamDisplayItems(state.messageStream); - const rollbackCandidate = busy ? null : rollbackCandidateFromItems(displayItems); + const rollbackCandidate = busy ? null : messageStreamRollbackCandidate(state.messageStream); const forkCandidates = busy ? [] : forkCandidatesFromItems(displayItems); const implementPlanCandidate = implementPlanCandidateFromState(state); diff --git a/src/features/chat/panel/surface/toolbar-surface.ts b/src/features/chat/panel/surface/toolbar-surface.ts index a06d2893..18ea44a7 100644 --- a/src/features/chat/panel/surface/toolbar-surface.ts +++ b/src/features/chat/panel/surface/toolbar-surface.ts @@ -3,11 +3,12 @@ import type { ConnectionManager } from "../../../../app-server/connection/connec import type { ChatConnectionController } from "../../connection/connection-controller"; import type { ChatReconnectActions } from "../../connection/reconnect-actions"; import type { ChatInboundController } from "../../protocol/inbound/controller"; -import type { ChatThreadActions } from "../../threads/action-context"; +import type { ThreadManagementActions } from "../../threads/thread-management-actions"; import type { ToolbarPanelActions } from "../toolbar-actions"; -import type { RenameController } from "../../threads/rename-controller"; +import type { ThreadRenameEditorController } from "../../threads/rename-editor-controller"; import type { SelectionActions } from "../../threads/selection-actions"; import type { ChatStateStore } from "../../state/reducer"; +import { noActiveThreadToCompactMessage } from "../../threads/messages"; import type { ChatPanelToolbarSurface } from "./model"; export interface ChatPanelToolbarSurfaceHost { @@ -22,9 +23,9 @@ export interface ChatPanelToolbarSurfaceDependencies { connectionController: ChatConnectionController; reconnectActions: ChatReconnectActions; inboundController: ChatInboundController; - threadActions: ChatThreadActions; + threadActions: ThreadManagementActions; toolbarPanels: ToolbarPanelActions; - rename: RenameController; + rename: ThreadRenameEditorController; selection: SelectionActions; } @@ -110,7 +111,7 @@ async function compactConversation( ): Promise { const threadId = state.activeThread.id; if (!threadId) { - deps.inboundController.addSystemMessage("No active thread to compact."); + deps.inboundController.addSystemMessage(noActiveThreadToCompactMessage()); return; } await deps.threadActions.compactThread(threadId); diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index f21535f1..f326239f 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -1,9 +1,9 @@ -import type { ChatThreadActions } from "../threads/action-context"; +import type { ThreadManagementActions } from "../threads/thread-management-actions"; import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer"; export interface ToolbarPanelActionsHost { stateStore: ChatStateStore; - threadActions: ChatThreadActions; + threadActions: ThreadManagementActions; } export interface ToolbarPanelActions { diff --git a/src/features/chat/protocol/inbound/controller.ts b/src/features/chat/protocol/inbound/controller.ts index dee6caf5..62b8f323 100644 --- a/src/features/chat/protocol/inbound/controller.ts +++ b/src/features/chat/protocol/inbound/controller.ts @@ -7,6 +7,13 @@ import { createStructuredSystemItem, createSystemItem } from "../../display/item import type { DisplayDetailSection } from "../../display/types"; import { approvalResponse, type ApprovalAction, type PendingApproval } from "../server-requests/approval"; import { userInputResponse, type PendingUserInput } from "../server-requests/user-input"; +import { + cannotCancelUserInputMessage, + cannotRejectServerRequestMessage, + cannotSendApprovalResponseMessage, + cannotSendUserInputMessage, + userCancelledInputRequestMessage, +} from "../../conversation/pending-requests/messages"; import { createApprovalResultItem, createUserInputResultItem } from "../../conversation/pending-requests/result-items"; import { planChatNotification, type ChatNotificationEffect } from "./notification-plan"; import { routeServerRequest } from "./routing"; @@ -78,7 +85,7 @@ export class ChatInboundController { resolveApproval(approval: PendingApproval, action: ApprovalAction): void { if (!this.state.requests.approvals.includes(approval)) return; if (!this.actions.respondToServerRequest(approval.requestId, approvalResponse(approval, action))) { - this.addSystemMessage("Could not send approval response because Codex app-server is not connected."); + this.addSystemMessage(cannotSendApprovalResponseMessage()); return; } this.dispatch({ type: "request/resolved", requestId: approval.requestId, resultItem: createApprovalResultItem(approval, action) }); @@ -87,7 +94,7 @@ export class ChatInboundController { resolveUserInput(input: PendingUserInput, answers: Record): void { if (!this.state.requests.pendingUserInputs.includes(input)) return; if (!this.actions.respondToServerRequest(input.requestId, userInputResponse(input, answers))) { - this.addSystemMessage("Could not send user input because Codex app-server is not connected."); + this.addSystemMessage(cannotSendUserInputMessage()); return; } this.dispatch({ @@ -99,8 +106,8 @@ export class ChatInboundController { cancelUserInput(input: PendingUserInput): void { if (!this.state.requests.pendingUserInputs.includes(input)) return; - if (!this.actions.rejectServerRequest(input.requestId, -32000, "User cancelled input request.")) { - this.addSystemMessage("Could not cancel user input because Codex app-server is not connected."); + if (!this.actions.rejectServerRequest(input.requestId, -32000, userCancelledInputRequestMessage())) { + this.addSystemMessage(cannotCancelUserInputMessage()); return; } this.dispatch({ type: "request/resolved", requestId: input.requestId, resultItem: createUserInputResultItem(input, {}, "cancelled") }); @@ -149,7 +156,7 @@ export class ChatInboundController { private rejectServerRequest(request: ServerRequest, message: string): void { this.addSystemMessage(message); if (!this.actions.rejectServerRequest(request.id, -32601, message)) { - this.addSystemMessage("Could not reject app-server request because Codex app-server is not connected."); + this.addSystemMessage(cannotRejectServerRequestMessage()); } } diff --git a/src/features/chat/protocol/inbound/notification-plan.ts b/src/features/chat/protocol/inbound/notification-plan.ts index 4d7eec42..b5a6308e 100644 --- a/src/features/chat/protocol/inbound/notification-plan.ts +++ b/src/features/chat/protocol/inbound/notification-plan.ts @@ -8,22 +8,24 @@ import { type TurnRecord, } from "../../../../app-server/protocol/turn"; import type { ServerNotification } from "../../../../app-server/connection/rpc-messages"; +import { normalizeExplicitThreadName } from "../../../../domain/threads/model"; 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/items/review-result"; import { completeReasoningItems, upsertDisplayItem } from "../../state/message-stream-updates"; -import { - displayItemFromTurnItem, - displayItemsFromTurns, - normalizeFileChanges, - shouldSuppressLifecycleItem, -} from "../../display/turn-items"; +import { displayItemFromTurnItem, displayItemsFromTurns, 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/items/goal"; import { hookRunDisplayItem } from "../../display/items/hook-run"; +import { + STREAMED_COMMAND_RUNNING_TEXT, + STREAMED_FILE_CHANGE_IN_PROGRESS_TEXT, + STREAMED_MCP_PROGRESS_LABEL, + streamingFileChangeDisplayItem, +} from "../../display/items/streaming"; import { attachHookRunsToTurn } from "../../state/message-stream-updates"; import { messageStreamDisplayItems } from "../../state/message-stream"; import { @@ -58,6 +60,7 @@ export interface ChatNotificationPlan { export type LocalItemIdFactory = (prefix: string) => string; const EMPTY_PLAN: ChatNotificationPlan = { actions: [], effects: [] }; +const MESSAGE_CONTEXT_COMPACTED = "Context compacted."; type ServerNotificationPlanner = ( notification: Extract, @@ -105,7 +108,7 @@ const DIAGNOSTIC_STATUS_PLANNERS = { } satisfies ServerNotificationPlannerMap; const USER_VISIBLE_NOTICE_PLANNERS = { - "thread/compacted": (_notification, localItemId) => systemMessagePlan({ id: localItemId("system"), text: "Context compacted." }), + "thread/compacted": (_notification, localItemId) => systemMessagePlan({ id: localItemId("system"), text: MESSAGE_CONTEXT_COMPACTED }), "model/rerouted": jsonNoticePlan, deprecationNotice: jsonNoticePlan, error: jsonNoticePlan, @@ -153,7 +156,7 @@ const STREAM_UPDATE_PLANNERS = { turnId: notification.params.turnId, delta: notification.params.delta, kind: "command", - fallbackText: "Command running", + fallbackText: STREAMED_COMMAND_RUNNING_TEXT, }), "item/fileChange/patchUpdated": (_state, notification) => fileChangePlan(notification.params.itemId, notification.params.turnId, notification.params.changes, "inProgress"), @@ -164,7 +167,7 @@ const STREAM_UPDATE_PLANNERS = { turnId: notification.params.turnId, delta: notification.params.delta, kind: "fileChange", - fallbackText: "File change inProgress", + fallbackText: STREAMED_FILE_CHANGE_IN_PROGRESS_TEXT, }), "turn/diff/updated": (_state, notification) => actionPlan({ type: "message-stream/turn-diff-updated", turnId: notification.params.turnId, diff: notification.params.diff }), @@ -177,7 +180,7 @@ const STREAM_UPDATE_PLANNERS = { itemId: notification.params.itemId, turnId: notification.params.turnId, delta: notification.params.message, - fallbackLabel: "mcp progress", + fallbackLabel: STREAMED_MCP_PROGRESS_LABEL, }), "item/autoApprovalReview/started": autoApprovalReviewPlan, "item/autoApprovalReview/completed": autoApprovalReviewPlan, @@ -244,10 +247,7 @@ const THREAD_LIFECYCLE_PLANNERS = { }), "thread/unarchived": () => ({ actions: [], effects: [{ type: "refresh-threads" }] }), "thread/name/updated": (state, notification) => { - const name = - typeof notification.params.threadName === "string" && notification.params.threadName.trim() - ? notification.params.threadName.trim() - : null; + const name = normalizeExplicitThreadName(notification.params.threadName); return { actions: [ { @@ -411,16 +411,7 @@ function completedItemPlan(state: ChatState, item: TurnItem, turnId: string): Ch function fileChangePlan(itemId: string, turnId: string, changes: FileUpdateChange[], status: string): ChatNotificationPlan { return actionPlan({ type: "message-stream/item-upserted", - item: { - id: itemId, - kind: "fileChange", - role: "tool", - text: `File change ${status}`, - turnId, - sourceItemId: itemId, - status, - changes: normalizeFileChanges(changes), - }, + item: streamingFileChangeDisplayItem(itemId, turnId, changes, status), }); } diff --git a/src/features/chat/protocol/server-requests/approval.ts b/src/features/chat/protocol/server-requests/approval.ts index 193db1c5..a244a13c 100644 --- a/src/features/chat/protocol/server-requests/approval.ts +++ b/src/features/chat/protocol/server-requests/approval.ts @@ -1,5 +1,3 @@ -import { jsonPreview } from "../../../../utils"; -import { permissionRows } from "../../display/details/permission-rows"; import type { RequestId } from "../../../../app-server/connection/rpc-messages"; type SimpleApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel"; @@ -65,7 +63,15 @@ interface PermissionsApprovalParams { permissions: PermissionProfile; } -type PermissionProfile = Parameters[0]; +interface PermissionProfile { + network?: { enabled?: boolean | null } | null; + fileSystem?: { + entries?: readonly { path: unknown; access?: unknown }[] | null; + read?: unknown; + write?: unknown; + globScanMaxDepth?: unknown; + } | null; +} interface GrantedPermissionProfile { network?: PermissionProfile["network"]; fileSystem?: PermissionProfile["fileSystem"]; @@ -81,12 +87,6 @@ interface CommandApprovalDecisionAction { kind: "command-decision"; decision: CommandApprovalDecision; } -export interface ApprovalActionOption { - label: string; - action: ApprovalAction; - className: string; -} - type PendingApprovalFor = Request extends ApprovalRequest ? { requestId: RequestId; @@ -96,13 +96,6 @@ type PendingApprovalFor = Request extends Appro : never; export type PendingApproval = PendingApprovalFor; -interface ApprovalSummaryParts { - reason: string | null; - target: string | null; - fallback: string; - lines: string[]; -} - export function toPendingApproval(request: ApprovalRequestLike): PendingApproval | null { if (!isApprovalRequest(request)) return null; switch (request.method) { @@ -168,88 +161,6 @@ export function approvalResponse(approval: PendingApproval, action: ApprovalActi }; } -export function approvalTitle(approval: PendingApproval): string { - switch (approval.method) { - case "item/commandExecution/requestApproval": - return "Command approval"; - case "item/fileChange/requestApproval": - return "File change approval"; - case "item/permissions/requestApproval": - return "Permission approval"; - } -} - -export function approvalActionOptions(approval: PendingApproval): ApprovalActionOption[] { - if (approval.method !== "item/commandExecution/requestApproval") return defaultApprovalActionOptions(); - const decisions = approval.params.availableDecisions; - if (!decisions || decisions.length === 0) return defaultApprovalActionOptions(); - return decisions.map((decision) => ({ - label: commandDecisionLabel(decision), - action: { kind: "command-decision", decision }, - className: commandDecisionClassName(decision), - })); -} - -export function approvalSummary(approval: PendingApproval): string { - return approvalSummaryParts(approval).lines.join("\n"); -} - -export function approvalResultSummary(approval: PendingApproval): string { - const summary = approvalSummaryParts(approval); - return summary.reason ?? summary.target ?? summary.fallback; -} - -function approvalSummaryParts(approval: PendingApproval): ApprovalSummaryParts { - switch (approval.method) { - case "item/commandExecution/requestApproval": { - const reason = nonEmptyString(approval.params.reason); - const target = nonEmptyString(approval.params.command); - return summaryParts(reason, target, "Command execution requested."); - } - case "item/fileChange/requestApproval": { - const reason = nonEmptyString(approval.params.reason); - const grantRoot = nonEmptyString(approval.params.grantRoot); - return summaryParts(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"); - } - case "item/permissions/requestApproval": { - return summaryParts(approval.params.reason, `cwd: ${approval.params.cwd}`, "Permission change requested."); - } - } -} - -export function approvalDetails(approval: PendingApproval): { key: string; value: string }[] { - const rows: { key: string; value: string }[] = []; - addOptional(rows, "reason", approval.params.reason); - switch (approval.method) { - case "item/commandExecution/requestApproval": - addOptional(rows, "command", approval.params.command); - addOptional(rows, "cwd", approval.params.cwd); - addOptional(rows, "actions", approval.params.commandActions); - addOptional(rows, "future command rule", approval.params.proposedExecpolicyAmendment); - addOptional(rows, "future network rules", approval.params.proposedNetworkPolicyAmendments); - break; - case "item/fileChange/requestApproval": - addOptional(rows, "grant root", approval.params.grantRoot); - break; - case "item/permissions/requestApproval": - addOptional(rows, "cwd", approval.params.cwd); - addOptional(rows, "environment", approval.params.environmentId); - rows.push(...permissionRows(approval.params.permissions)); - break; - } - return rows; -} - -function summaryParts(reason: string | null, target: string | null, fallback: string, extra?: string | null): ApprovalSummaryParts { - const lines = [reason, target, extra].filter((value): value is string => Boolean(value)); - return { - reason, - target, - fallback, - lines: lines.length > 0 ? lines : [fallback], - }; -} - function commandDecision(action: ApprovalAction): CommandApprovalDecision { if (action === "accept") return "accept"; if (action === "accept-session") return "acceptForSession"; @@ -271,34 +182,6 @@ export function approvalActionKind(action: ApprovalAction): "accept" | "accept-s return "decline"; } -function defaultApprovalActionOptions(): ApprovalActionOption[] { - return [ - { label: "Allow", action: "accept", className: "mod-cta" }, - { label: "Allow session", action: "accept-session", className: "" }, - { label: "Deny", action: "decline", className: "mod-warning" }, - { label: "Cancel", action: "cancel", className: "" }, - ]; -} - -function commandDecisionLabel(decision: CommandApprovalDecision): string { - if (decision === "accept") return "Allow"; - if (decision === "acceptForSession") return "Allow session"; - if (decision === "decline") return "Deny"; - if (decision === "cancel") return "Cancel"; - if ("acceptWithExecpolicyAmendment" in decision) return "Allow rule"; - if ("applyNetworkPolicyAmendment" in decision) { - return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "Allow network rule" : "Deny network rule"; - } - return "Choose"; -} - -function commandDecisionClassName(decision: CommandApprovalDecision): string { - const kind = approvalActionKind({ kind: "command-decision", decision }); - if (kind === "accept") return "mod-cta"; - if (kind === "decline") return "mod-warning"; - return ""; -} - function isCommandDecisionAction(action: ApprovalAction): action is CommandApprovalDecisionAction { return typeof action === "object"; } @@ -316,23 +199,3 @@ function grantedPermissions(requested: PermissionsApprovalParams["permissions"]) if (requested.fileSystem) granted.fileSystem = requested.fileSystem; return granted; } - -function addOptional(rows: { key: string; value: string }[], key: string, value: unknown): void { - if (value === null || value === undefined) return; - if (Array.isArray(value) && value.length === 0) return; - rows.push({ key, value: stringValue(value) }); -} - -function stringValue(value: unknown, fallback = ""): string { - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); - if (Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean")) { - return value.join("\n"); - } - if (value === null || value === undefined) return fallback; - return jsonPreview(value); -} - -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} diff --git a/src/features/chat/runtime/effective.ts b/src/features/chat/runtime/effective.ts index b65e686b..1208edd7 100644 --- a/src/features/chat/runtime/effective.ts +++ b/src/features/chat/runtime/effective.ts @@ -51,10 +51,6 @@ export function supportedReasoningEfforts(snapshot: RuntimeSnapshot, config: Run return supportedEffortsForModelMetadata(findModelMetadataByIdOrName(snapshot.availableModels, model)); } -export function serviceTierLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { - return currentServiceTier(snapshot, config) ?? "(Codex default)"; -} - export function fastModeActive(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): boolean { return isFastServiceTier(currentServiceTier(snapshot, config), currentModelServiceTiers(snapshot, config)); } @@ -66,13 +62,6 @@ export function isFastServiceTier(value: string | null | undefined, serviceTiers return serviceTiers.some((tier) => tier.id === value && tier.name.trim().toLowerCase() === "fast"); } -export function fastModeLabel(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { - if (snapshot.requestedServiceTier.kind === "set" && snapshot.requestedServiceTier.value === "off") return "off"; - if (fastModeActive(snapshot, config)) return "on"; - const serviceTier = currentServiceTier(snapshot, config); - return serviceTier ? "off" : "Codex default"; -} - export function fastRuntimeServiceTierRequestValue(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string { return currentModelServiceTiers(snapshot, config).find((tier) => tier.name.trim().toLowerCase() === "fast")?.id ?? "fast"; } diff --git a/src/features/chat/runtime/messages.ts b/src/features/chat/runtime/messages.ts new file mode 100644 index 00000000..b14ed1bb --- /dev/null +++ b/src/features/chat/runtime/messages.ts @@ -0,0 +1,67 @@ +import type { ReasoningEffort } from "../../../domain/catalog/metadata"; +import type { CollaborationMode } from "./pending-settings"; +import type { TurnCollaborationModeWarning } from "./thread-settings-update"; + +const COLLABORATION_MODE_WARNING_MESSAGES: Record = { + "missing-model": "No effective model is available. Sending without a mode override.", +}; + +export function modelOverrideMessage(model: string | null): string { + return model === null ? "Model reset to default for subsequent turns." : `Model set to ${model} for subsequent turns.`; +} + +export function reasoningEffortOverrideMessage(effort: ReasoningEffort | null): string { + return effort === null + ? "Reasoning effort reset to default for subsequent turns." + : `Reasoning effort set to ${effort} for subsequent turns.`; +} + +export function compactModelLabel(model: string | null): string { + if (!model) return "default"; + const match = /^gpt-(.+)$/.exec(model); + return match?.[1] ?? model; +} + +export function compactReasoningEffortLabel(effort: ReasoningEffort | null): string { + if (!effort) return "default"; + if (effort === "minimal") return "min"; + return effort; +} + +export function collaborationModeLabel(mode: CollaborationMode): string { + return mode === "plan" ? "Plan" : "Default"; +} + +export function pendingRuntimeSettingLabel( + setting: { kind: "unchanged" } | { kind: "set"; value: unknown } | { kind: "resetToConfig" }, +): string { + if (setting.kind === "set") return String(setting.value); + if (setting.kind === "resetToConfig") return "(reset to config)"; + return "(none)"; +} + +export function serviceTierLabel(value: string | null): string { + return value ?? "(Codex default)"; +} + +export function fastModeLabel(input: { requestedOff: boolean; active: boolean; serviceTier: string | null }): string { + if (input.requestedOff) return "off"; + if (input.active) return "on"; + return input.serviceTier ? "off" : "Codex default"; +} + +export function fastModeToggleMessage(state: "enabled" | "disabled"): string { + return state === "enabled" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns."; +} + +export function collaborationModeToggleMessage(mode: CollaborationMode): string { + return mode === "plan" ? "Plan mode on for subsequent turns." : "Plan mode off for subsequent turns."; +} + +export function autoReviewToggleMessage(state: "enabled" | "disabled"): string { + return state === "enabled" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns."; +} + +export function collaborationModeWarningMessage(warning: TurnCollaborationModeWarning): string { + return COLLABORATION_MODE_WARNING_MESSAGES[warning]; +} diff --git a/src/features/chat/runtime/pending-settings.ts b/src/features/chat/runtime/pending-settings.ts index 8b83b28c..a4225594 100644 --- a/src/features/chat/runtime/pending-settings.ts +++ b/src/features/chat/runtime/pending-settings.ts @@ -22,13 +22,3 @@ export function nextCollaborationMode(mode: CollaborationMode): CollaborationMod export function effectiveCollaborationMode(mode: ActiveCollaborationMode): CollaborationMode { return mode ?? "default"; } - -export function collaborationModeLabel(mode: CollaborationMode): string { - return mode === "plan" ? "Plan" : "Default"; -} - -export function pendingRuntimeSettingLabel(setting: PendingRuntimeSetting): string { - if (setting.kind === "set") return String(setting.value); - if (setting.kind === "resetToConfig") return "(reset to config)"; - return "(none)"; -} diff --git a/src/features/chat/runtime/settings-actions.ts b/src/features/chat/runtime/settings-actions.ts index 9b26e60f..6afe51d9 100644 --- a/src/features/chat/runtime/settings-actions.ts +++ b/src/features/chat/runtime/settings-actions.ts @@ -7,17 +7,19 @@ import { autoReviewActive, fastModeActive, runtimeConfigOrDefault } from "./effe import { pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch, type PendingRuntimeSettingsPatch, - type TurnCollaborationModeWarning, } from "./thread-settings-update"; import type { RuntimeSnapshot } from "./snapshot"; import { nextCollaborationMode, type CollaborationMode, type RequestedServiceTier } from "./pending-settings"; -import { modelOverrideMessage, reasoningEffortOverrideMessage } from "./settings-copy"; +import { + autoReviewToggleMessage, + collaborationModeToggleMessage, + collaborationModeWarningMessage, + fastModeToggleMessage, + modelOverrideMessage, + reasoningEffortOverrideMessage, +} from "./messages"; import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer"; -const COLLABORATION_MODE_WARNING_MESSAGES: Record = { - "missing-model": "No effective model is available. Sending without a mode override.", -}; - interface ApplyPendingThreadSettingsResult { ok: boolean; collaborationModeApplied: boolean; @@ -196,18 +198,6 @@ function autoReviewReviewerForState(state: AutoReviewState): ApprovalsReviewer { return state === "enabled" ? "auto_review" : "user"; } -function fastModeToggleMessage(state: FastModeState): string { - return state === "enabled" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns."; -} - -function collaborationModeToggleMessage(mode: CollaborationMode): string { - return mode === "plan" ? "Plan mode on for subsequent turns." : "Plan mode off for subsequent turns."; -} - -function autoReviewToggleMessage(state: AutoReviewState): string { - return state === "enabled" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns."; -} - function pendingRuntimeSettingsPatch(host: RuntimeSettingsActionsHost): PendingRuntimeSettingsPatch { const { snapshot, config } = runtimeProjection(host); const { update, collaborationModeWarning } = buildPendingRuntimeSettingsPatch(snapshot, config); @@ -264,10 +254,6 @@ function runtimeProjection(host: RuntimeSettingsActionsHost): { }; } -function collaborationModeWarningMessage(warning: TurnCollaborationModeWarning): string { - return COLLABORATION_MODE_WARNING_MESSAGES[warning]; -} - function state(host: RuntimeSettingsActionsHost): ChatState { return host.stateStore.getState(); } diff --git a/src/features/chat/runtime/settings-copy.ts b/src/features/chat/runtime/settings-copy.ts deleted file mode 100644 index 7a613e0e..00000000 --- a/src/features/chat/runtime/settings-copy.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ReasoningEffort } from "../../../domain/catalog/metadata"; - -export function modelOverrideMessage(model: string | null): string { - return model === null ? "Model reset to default for subsequent turns." : `Model set to ${model} for subsequent turns.`; -} - -export function reasoningEffortOverrideMessage(effort: ReasoningEffort | null): string { - return effort === null - ? "Reasoning effort reset to default for subsequent turns." - : `Reasoning effort set to ${effort} for subsequent turns.`; -} - -export function compactModelLabel(model: string | null): string { - if (!model) return "default"; - const match = /^gpt-(.+)$/.exec(model); - return match?.[1] ?? model; -} - -export function compactReasoningEffortLabel(effort: ReasoningEffort | null): string { - if (!effort) return "default"; - if (effort === "minimal") return "min"; - return effort; -} diff --git a/src/features/chat/session.ts b/src/features/chat/session.ts index 1b2ea2d0..8dc7ebac 100644 --- a/src/features/chat/session.ts +++ b/src/features/chat/session.ts @@ -4,7 +4,7 @@ import { ConnectionManager, type ConnectionManagerHandlers } from "../../app-ser import type { AppServerClient } from "../../app-server/connection/client"; import type { ModelMetadata } from "../../domain/catalog/metadata"; import type { Thread } from "../../domain/threads/model"; -import { codexPanelDisplayTitle, getThreadTitle } from "../../domain/threads/model"; +import { getThreadTitle } from "../../domain/threads/model"; import type { SharedServerMetadata } from "../../domain/server/metadata"; import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot"; import type { ArchiveExportAdapter } from "../thread-export/archive-markdown"; @@ -19,6 +19,7 @@ import type { ChatComposerController } from "./conversation/composer/controller" import { createConversationParts } from "./conversation/composition"; import type { ComposerSubmitActions } from "./conversation/turns/composer-submit-actions"; import { createStructuredSystemItem, createSystemItem } from "./display/items/system"; +import { codexPanelDisplayTitle } from "./threads/title-display"; import type { DisplayDetailSection, DisplayItem } from "./display/types"; import { effortStatusLines as buildEffortStatusLines, @@ -39,7 +40,7 @@ import { connectionDiagnosticsModel } from "./panel/surface/toolbar"; import { openPanelTurnLifecycle } from "./panel/snapshot"; import { ChatInboundController } from "./protocol/inbound/controller"; import { rejectServerRequest, respondToServerRequest } from "./protocol/server-requests/responder"; -import { collaborationModeLabel as formatCollaborationModeLabel } from "./runtime/pending-settings"; +import { collaborationModeLabel as formatCollaborationModeLabel } from "./runtime/messages"; import { createChatRuntimeSettingsActions } from "./runtime/settings-actions"; import { runtimeSnapshotForChatState, type RuntimeSnapshot } from "./runtime/snapshot"; import { chatPanelComposerProjection } from "./panel/surface/composer"; @@ -57,7 +58,7 @@ import type { GoalActions } from "./threads/goal-actions"; import type { AutoTitleController } from "./threads/auto-title-controller"; import type { HistoryController } from "./threads/history-controller"; import type { IdentitySync } from "./threads/identity-sync"; -import type { RenameController } from "./threads/rename-controller"; +import type { ThreadRenameEditorController } from "./threads/rename-editor-controller"; import type { RestorationController } from "./threads/restoration-controller"; import type { ResumeController } from "./threads/resume-controller"; import type { SelectionActions } from "./threads/selection-actions"; @@ -101,7 +102,7 @@ interface ChatPanelSessionParts { resume: ResumeController; restoration: RestorationController; identity: IdentitySync; - rename: RenameController; + rename: ThreadRenameEditorController; }; toolbar: { panels: ToolbarPanelActions; @@ -392,7 +393,7 @@ export class ChatPanelSession { connection, }, ); - const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadParts; + const { history, managementActions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadParts; resumeRef.set(resume); restorationRef.set(restoration); const toolbarPanels = createToolbarPanelActions({ diff --git a/src/features/chat/state/message-stream-updates.ts b/src/features/chat/state/message-stream-updates.ts index fec14534..ccbccfa1 100644 --- a/src/features/chat/state/message-stream-updates.ts +++ b/src/features/chat/state/message-stream-updates.ts @@ -1,5 +1,6 @@ -import { normalizeProposedPlanMarkdown } from "../display/items/proposed-plan"; -import { isAssistantAuthoredMessage } from "../display/predicates"; +import { normalizeProposedPlanMarkdown } from "../display/items/message-content"; +import { isAssistantAuthoredMessage } from "../display/item-selectors"; +import { streamedItemOutputDisplayItem, streamedTextDisplayItem, streamedToolOutputDisplayItem } from "../display/items/streaming"; import type { AssistantAuthoredMessageDisplayItem, DisplayFileChange, DisplayItem, DisplayKind } from "../display/types"; export function upsertDisplayItem(items: readonly DisplayItem[], next: DisplayItem): DisplayItem[] { @@ -122,17 +123,7 @@ export function appendItemText( if (index !== -1) { return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${item.text}${delta}` } : item)); } - return [ - ...items, - { - id: sourceItemId, - kind, - role: "tool", - text: `${label}: ${delta}`, - turnId, - sourceItemId, - }, - ]; + return [...items, streamedTextDisplayItem({ id: sourceItemId, kind, label, delta, turnId })]; } export function appendToolOutput( @@ -150,19 +141,7 @@ export function appendToolOutput( : item, ); } - return [ - ...items, - { - id: sourceItemId, - kind: "tool", - role: "tool", - text: "details", - toolLabel: fallbackLabel, - turnId, - sourceItemId, - output: delta, - }, - ]; + return [...items, streamedToolOutputDisplayItem({ id: sourceItemId, turnId, output: delta, fallbackLabel })]; } export function appendItemOutput( @@ -181,30 +160,7 @@ export function appendItemOutput( : item, ); } - return [ - ...items, - { - id: sourceItemId, - kind, - role: "tool", - text: fallbackText, - turnId, - sourceItemId, - output: delta, - ...(kind === "fileChange" - ? { - status: "inProgress", - changes: [], - executionState: "running", - } - : { - command: fallbackText, - cwd: "(unknown)", - status: "running", - executionState: "running", - }), - }, - ] as DisplayItem[]; + return [...items, streamedItemOutputDisplayItem({ id: sourceItemId, kind, turnId, output: delta, fallbackText })] as DisplayItem[]; } export function attachHookRunsToTurn( diff --git a/src/features/chat/state/message-stream.ts b/src/features/chat/state/message-stream.ts index ba5538fc..769fb195 100644 --- a/src/features/chat/state/message-stream.ts +++ b/src/features/chat/state/message-stream.ts @@ -1,6 +1,7 @@ import { upsertDisplayItem } from "./message-stream-updates"; -import type { DisplayItem } from "../display/types"; -import { normalizeProposedPlanMarkdown } from "../display/items/proposed-plan"; +import type { DisplayItem, MessageDisplayItem } from "../display/types"; +import { normalizeProposedPlanMarkdown } from "../display/items/message-content"; +import { streamedItemOutputDisplayItem, streamedTextDisplayItem, streamedToolOutputDisplayItem } from "../display/items/streaming"; export interface ChatMessageStreamActiveSegment { turnId: string | null; @@ -18,6 +19,12 @@ export interface ChatMessageStreamState { reportedLogs: ReadonlySet; } +export interface MessageStreamRollbackCandidate { + turnId: string; + displayItemId: string; + text: string; +} + export type MessageStreamAction = | { type: "message-stream/item-added"; item: DisplayItem } | { type: "message-stream/system-item-added"; item: DisplayItem } @@ -86,6 +93,36 @@ export function messageStreamIsEmpty(state: Pick): string[] { + return orderedTurnIds(messageStreamDisplayItems(state)); +} + +export function messageStreamTurnsAfterTurnId( + state: Pick, + turnId: string, +): number | null { + const turnIds = messageStreamTurnIds(state); + const index = turnIds.indexOf(turnId); + return index === -1 ? null : turnIds.length - index - 1; +} + +export function messageStreamRollbackCandidate( + state: Pick, +): MessageStreamRollbackCandidate | null { + const items = messageStreamDisplayItems(state); + const lastTurnId = latestTurnId(items); + if (!lastTurnId) return null; + + const userMessage = items.find((item): item is MessageDisplayItem => isUserMessageForTurn(item, lastTurnId)); + if (!userMessage) return null; + + return { + turnId: lastTurnId, + displayItemId: userMessage.id, + text: userMessage.text, + }; +} + export function messageStreamWithDisplayItems( state: ChatMessageStreamState, items: readonly DisplayItem[], @@ -264,12 +301,13 @@ function appendItemTextToMessageStream( return replaceActiveSegmentItem(segment, index, (item) => ({ ...item, text: `${item.text}${delta}` })); } return appendActiveSegmentItem(segment, { - id: sourceItemId, - kind, - role: "tool", - text: `${label}: ${delta}`, - turnId, - sourceItemId, + ...streamedTextDisplayItem({ + id: sourceItemId, + kind, + label, + delta, + turnId, + }), }); }); } @@ -290,16 +328,15 @@ function appendToolOutputToMessageStream( : item, ); } - return appendActiveSegmentItem(segment, { - id: sourceItemId, - kind: "tool", - role: "tool", - text: "details", - toolLabel: fallbackLabel, - turnId, - sourceItemId, - output: delta, - }); + return appendActiveSegmentItem( + segment, + streamedToolOutputDisplayItem({ + id: sourceItemId, + turnId, + output: delta, + fallbackLabel, + }), + ); }); } @@ -318,27 +355,16 @@ function appendItemOutputToMessageStream( item.kind === "command" || item.kind === "fileChange" ? { ...item, output: `${item.output ?? ""}${delta}` } : item, ); } - return appendActiveSegmentItem(segment, { - id: sourceItemId, - kind, - role: "tool", - text: fallbackText, - turnId, - sourceItemId, - output: delta, - ...(kind === "fileChange" - ? { - status: "inProgress", - changes: [], - executionState: "running", - } - : { - command: fallbackText, - cwd: "(unknown)", - status: "running", - executionState: "running", - }), - } as DisplayItem); + return appendActiveSegmentItem( + segment, + streamedItemOutputDisplayItem({ + id: sourceItemId, + kind, + turnId, + output: delta, + fallbackText, + }), + ); }); } @@ -443,6 +469,28 @@ function updatedTurnDiffs(turnDiffs: ReadonlyMap, turnId: string return next; } +function orderedTurnIds(items: readonly DisplayItem[]): string[] { + const turnIds: string[] = []; + const seen = new Set(); + for (const item of items) { + if (!item.turnId || seen.has(item.turnId)) continue; + seen.add(item.turnId); + turnIds.push(item.turnId); + } + return turnIds; +} + +function latestTurnId(items: readonly DisplayItem[]): string | null { + for (const item of [...items].reverse()) { + if (item.turnId) return item.turnId; + } + return null; +} + +function isUserMessageForTurn(item: DisplayItem, turnId: string): item is MessageDisplayItem { + return item.kind === "message" && item.role === "user" && item.turnId === turnId; +} + function patchObject(current: T, patch: Partial): T { if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current; return { ...current, ...patch }; diff --git a/src/features/chat/state/reducer.ts b/src/features/chat/state/reducer.ts index a1c9cbef..bd808f68 100644 --- a/src/features/chat/state/reducer.ts +++ b/src/features/chat/state/reducer.ts @@ -69,6 +69,7 @@ import { type ChatTurnState, type PendingTurnStart, } from "../conversation/turns/turn-state"; +import { STATUS_TURN_RUNNING, turnCompletedStatus } from "../conversation/turns/messages"; export { activeTurnId, @@ -495,7 +496,7 @@ function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction return patchChatState(state, { activeThread: { ...state.activeThread, id: action.threadId }, turn: { lifecycle }, - connection: { ...state.connection, statusText: "Turn running..." }, + connection: { ...state.connection, statusText: STATUS_TURN_RUNNING }, messageStream: action.displayItems ? messageStreamWithActiveTurnItems(state.messageStream, action.turnId, action.displayItems) : messageStreamStartActiveSegment(state.messageStream, action.turnId, []), @@ -508,7 +509,7 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc return patchChatState(state, { turn: { lifecycle }, messageStream: messageStreamWithDisplayItems(state.messageStream, action.displayItems), - connection: { ...state.connection, statusText: `Turn ${action.status}.` }, + connection: { ...state.connection, statusText: turnCompletedStatus(action.status) }, }); } diff --git a/src/features/chat/threads/action-context.ts b/src/features/chat/threads/action-context.ts deleted file mode 100644 index 98f6e1bd..00000000 --- a/src/features/chat/threads/action-context.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { AppServerClient } from "../../../app-server/connection/client"; -import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown"; -import type { CodexPanelSettings } from "../../../settings/model"; -import type { ChatAction, ChatState, ChatStateStore } from "../state/reducer"; - -export interface ChatThreadActionsHost { - stateStore: ChatStateStore; - vaultPath: string; - settings: () => CodexPanelSettings; - archiveAdapter: () => ArchiveExportAdapter; - ensureConnected: () => Promise; - currentClient: () => AppServerClient | null; - addSystemMessage: (text: string) => void; - setStatus: (status: string) => void; - setComposerText: (text: string) => void; - openThreadInNewView: (threadId: string) => Promise; - openThreadInCurrentPanel: (threadId: string) => Promise; - notifyThreadArchived: (threadId: string) => void; - notifyThreadRenamed: (threadId: string, name: string) => void; - notifyActiveThreadIdentityChanged: () => void; - refreshThreads: () => Promise; - refreshSharedThreadListFromOpenSurface: () => void; -} - -export interface ChatThreadActions { - compactThread: (threadId: string) => Promise; - archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise; - forkThread: (threadId: string) => Promise; - forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise; - renameThread: (threadId: string, name: string) => Promise; - rollbackThread: (threadId: string) => Promise; -} - -export function threadActionState(host: ChatThreadActionsHost): ChatState { - return host.stateStore.getState(); -} - -export function threadActionDispatch(host: ChatThreadActionsHost, action: ChatAction): void { - host.stateStore.dispatch(action); -} - -export function threadActionStillTargetsPanel(state: ChatState, threadId: string): boolean { - return state.activeThread.id === threadId; -} - -export function threadActionStillTargetsOriginalPanel(state: ChatState, initialThreadId: string | null, threadId: string): boolean { - if (!initialThreadId) return true; - return initialThreadId === threadId && state.activeThread.id === threadId; -} diff --git a/src/features/chat/threads/action-parts.ts b/src/features/chat/threads/action-parts.ts deleted file mode 100644 index a26b1900..00000000 --- a/src/features/chat/threads/action-parts.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { AppServerClient } from "../../../app-server/connection/client"; -import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown"; -import type { ChatStateStore } from "../state/reducer"; -import type { CodexChatHost } from "../chat-host"; -import { archiveThread } from "./archive-actions"; -import { compactThread } from "./compact-actions"; -import { forkThread, forkThreadFromTurn } from "./fork-actions"; -import { renameThread } from "./rename-actions"; -import { rollbackThread } from "./rollback-actions"; -import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context"; - -export interface ThreadActionPartsContext { - obsidian: { - archiveAdapter: () => ArchiveExportAdapter; - }; - plugin: CodexChatHost; - stateStore: ChatStateStore; - client: { - currentClient: () => AppServerClient | null; - ensureConnected: () => Promise; - }; - status: { - set: (status: string) => void; - addSystemMessage: (text: string) => void; - }; - thread: { - selectThread: (threadId: string) => Promise; - refreshThreads: () => Promise; - notifyIdentityChanged: () => void; - }; - composer: { - setText: (text: string) => void; - }; -} - -export function createThreadActions(context: ThreadActionPartsContext): ChatThreadActions { - const { obsidian, plugin, stateStore, client, status, thread, composer } = context; - const host: ChatThreadActionsHost = { - stateStore, - vaultPath: plugin.vaultPath, - settings: () => plugin.settings, - archiveAdapter: obsidian.archiveAdapter, - ensureConnected: client.ensureConnected, - currentClient: client.currentClient, - addSystemMessage: status.addSystemMessage, - setStatus: status.set, - setComposerText: composer.setText, - openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId), - openThreadInCurrentPanel: thread.selectThread, - notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin), - notifyThreadRenamed: (threadId, name) => { - plugin.notifyThreadRenamed(threadId, name); - }, - notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, - refreshThreads: thread.refreshThreads, - refreshSharedThreadListFromOpenSurface: () => { - plugin.refreshSharedThreadListFromOpenSurface(); - }, - }; - - return { - compactThread: (threadId) => compactThread(host, threadId), - archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown), - forkThread: (threadId) => forkThread(host, threadId), - forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource), - renameThread: (threadId, name) => renameThread(host, threadId, name), - rollbackThread: (threadId) => rollbackThread(host, threadId), - }; -} diff --git a/src/features/chat/threads/archive-actions.ts b/src/features/chat/threads/archive-actions.ts deleted file mode 100644 index d6d642e1..00000000 --- a/src/features/chat/threads/archive-actions.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Notice } from "obsidian"; - -import { readThreadForArchiveExport } from "../../../app-server/services/threads"; -import { exportArchivedThreadMarkdown } from "../../thread-export/archive-markdown"; -import { chatTurnBusy } from "../state/reducer"; -import type { ChatThreadActionsHost } from "./action-context"; -import { threadActionState } from "./action-context"; - -export async function archiveThread( - host: ChatThreadActionsHost, - threadId: string, - saveMarkdown = host.settings().archiveExportEnabled, -): Promise { - if (await archiveThreadOnServer(host, threadId, saveMarkdown)) { - host.notifyThreadArchived(threadId); - } -} - -export async function archiveThreadOnServer( - host: ChatThreadActionsHost, - threadId: string, - saveMarkdown = host.settings().archiveExportEnabled, -): Promise { - if (chatTurnBusy(threadActionState(host))) { - host.addSystemMessage("Finish or interrupt the current turn before archiving threads."); - return false; - } - await host.ensureConnected(); - const client = host.currentClient(); - if (!client) return false; - try { - const settings = host.settings(); - if (saveMarkdown) { - const result = await exportArchivedThreadMarkdown( - await readThreadForArchiveExport(client, threadId), - { ...settings, vaultPath: host.vaultPath }, - host.archiveAdapter(), - ); - new Notice(`Saved archived thread to ${result.path}.`); - } - await client.archiveThread(threadId); - return true; - } catch (error) { - host.addSystemMessage(error instanceof Error ? error.message : String(error)); - return false; - } -} diff --git a/src/features/chat/threads/compact-actions.ts b/src/features/chat/threads/compact-actions.ts deleted file mode 100644 index 0235f1c4..00000000 --- a/src/features/chat/threads/compact-actions.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ChatThreadActionsHost } from "./action-context"; -import { threadActionState, threadActionStillTargetsOriginalPanel } from "./action-context"; - -export async function compactThread(host: ChatThreadActionsHost, threadId: string): Promise { - await host.ensureConnected(); - const client = host.currentClient(); - if (!client) return; - const initialActiveThreadId = threadActionState(host).activeThread.id; - try { - await client.compactThread(threadId); - if (!threadActionStillTargetsOriginalPanel(threadActionState(host), initialActiveThreadId, threadId)) return; - host.addSystemMessage("Compaction requested."); - host.setStatus("Compaction requested."); - } catch (error) { - host.addSystemMessage(error instanceof Error ? error.message : String(error)); - } -} diff --git a/src/features/chat/threads/composition.ts b/src/features/chat/threads/composition.ts index 875b33ab..c295b28c 100644 --- a/src/features/chat/threads/composition.ts +++ b/src/features/chat/threads/composition.ts @@ -7,7 +7,7 @@ import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle" import type { ChatStateStore } from "../state/reducer"; import type { CodexChatHost } from "../chat-host"; import { createThreadNamingParts } from "./naming-parts"; -import { createThreadActions } from "./action-parts"; +import { createThreadManagementActions } from "./thread-management-actions"; import { createThreadLifecycleParts } from "./lifecycle-parts"; interface ThreadPartsContext { @@ -79,7 +79,7 @@ export function createThreadParts( ); const { rename, autoTitle, resetThreadTurnPresence } = naming; - const actions = createThreadActions({ + const managementActions = createThreadManagementActions({ obsidian, plugin, stateStore, @@ -128,7 +128,7 @@ export function createThreadParts( return { history, - actions, + managementActions, goals, restoration, resume, diff --git a/src/features/chat/threads/fork-actions.ts b/src/features/chat/threads/fork-actions.ts deleted file mode 100644 index 28d574d2..00000000 --- a/src/features/chat/threads/fork-actions.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { inheritedForkThreadName } from "../../../domain/threads/model"; -import { chatTurnBusy } from "../state/reducer"; -import { turnsAfterTurnId } from "../display/item-actions"; -import { messageStreamDisplayItems } from "../state/message-stream"; -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); -} - -export async function forkThreadFromTurn( - host: ChatThreadActionsHost, - threadId: string, - turnId: string | null, - archiveSource: boolean, -): Promise { - if (chatTurnBusy(threadActionState(host))) { - host.addSystemMessage("Finish or interrupt the current turn before forking threads."); - return; - } - await host.ensureConnected(); - const client = host.currentClient(); - if (!client) return; - - const initialActiveThreadId = threadActionState(host).activeThread.id; - const turnsToDrop = turnId ? turnsAfterTurnId(messageStreamDisplayItems(threadActionState(host).messageStream), turnId) : 0; - if (turnsToDrop === null) { - host.addSystemMessage("Could not find the selected turn to fork."); - return; - } - - try { - const sourceName = inheritedForkThreadName(threadId, threadActionState(host).threadList.listedThreads); - const response = await client.forkThread(threadId, host.vaultPath); - const forkedThreadId = response.thread.id; - if (turnsToDrop > 0) { - await client.rollbackThread(forkedThreadId, turnsToDrop); - } - if (!threadActionStillTargetsOriginalPanel(threadActionState(host), initialActiveThreadId, threadId)) return; - if (sourceName) { - try { - await client.setThreadName(forkedThreadId, sourceName); - host.notifyThreadRenamed(forkedThreadId, sourceName); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`); - } - } - if (archiveSource) { - if (!(await archiveThreadOnServer(host, threadId))) return; - try { - await host.openThreadInCurrentPanel(forkedThreadId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`); - } - host.notifyThreadArchived(threadId); - return; - } - try { - await host.openThreadInNewView(forkedThreadId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`); - } - } catch (error) { - host.addSystemMessage(error instanceof Error ? error.message : String(error)); - } -} diff --git a/src/features/chat/threads/goal-actions.ts b/src/features/chat/threads/goal-actions.ts index 47b80c39..59fc8660 100644 --- a/src/features/chat/threads/goal-actions.ts +++ b/src/features/chat/threads/goal-actions.ts @@ -4,6 +4,7 @@ import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../do import type { ChatStateStore } from "../state/reducer"; import type { GoalDisplayItem } from "../display/types"; import { goalChangeItem } from "../display/items/goal"; +import { emptyGoalObjectiveMessage } from "./messages"; export interface GoalActionsHost { stateStore: ChatStateStore; @@ -45,7 +46,7 @@ async function syncThreadGoal(host: GoalActionsHost, threadId: string): Promise< async function setObjective(host: GoalActionsHost, threadId: string, objective: string, tokenBudget: number | null): Promise { const trimmed = objective.trim(); if (!trimmed) { - host.addSystemMessage("Goal objective cannot be empty."); + host.addSystemMessage(emptyGoalObjectiveMessage()); return false; } const current = host.stateStore.getState().activeThread.goal; diff --git a/src/features/chat/threads/messages.ts b/src/features/chat/threads/messages.ts new file mode 100644 index 00000000..ea10e6ef --- /dev/null +++ b/src/features/chat/threads/messages.ts @@ -0,0 +1,65 @@ +export const STATUS_COMPACTION_REQUESTED = "Compaction requested."; +export const STATUS_ROLLBACK_STARTING = "Rolling back latest turn..."; +export const STATUS_ROLLBACK_COMPLETE = "Rolled back latest turn."; +export const STATUS_ROLLBACK_FAILED = "Rollback failed."; +export const STATUS_THREAD_READY_TO_RESUME = "Thread ready to resume."; + +export function noActiveThreadToCompactMessage(): string { + return "No active thread to compact."; +} + +export function noActiveThreadToForkMessage(): string { + return "No active thread to fork."; +} + +export function noActiveThreadToRollbackMessage(): string { + return "No active thread to roll back."; +} + +export function finishBeforeSwitchingThreadsMessage(): string { + return "Finish or interrupt the current turn before switching threads."; +} + +export function finishBeforeArchivingThreadsMessage(): string { + return "Finish or interrupt the current turn before archiving threads."; +} + +export function finishBeforeForkingThreadsMessage(): string { + return "Finish or interrupt the current turn before forking threads."; +} + +export function selectedTurnNotFoundForForkMessage(): string { + return "Could not find the selected turn to fork."; +} + +export function forkNameCopyFailedMessage(threadId: string, message: string): string { + return `Forked thread ${threadId}, but could not copy the source thread name: ${message}`; +} + +export function archivedSourceOpenForkFailedMessage(sourceThreadId: string, forkedThreadId: string, message: string): string { + return `Archived thread ${sourceThreadId}, but could not open forked thread ${forkedThreadId}: ${message}`; +} + +export function openForkInNewPanelFailedMessage(forkedThreadId: string, message: string): string { + return `Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`; +} + +export function interruptBeforeRollbackMessage(): string { + return "Interrupt the current turn before rolling back."; +} + +export function noCompletedTurnToRollbackMessage(): string { + return "No completed turn to roll back."; +} + +export function rollbackCompletedMessage(): string { + return "Rolled back the latest turn. Local file changes were not reverted."; +} + +export function resumedThreadMessage(threadId: string): string { + return `Resumed thread ${threadId}`; +} + +export function emptyGoalObjectiveMessage(): string { + return "Goal objective cannot be empty."; +} diff --git a/src/features/chat/threads/naming-parts.ts b/src/features/chat/threads/naming-parts.ts index 02120c17..1d51cd34 100644 --- a/src/features/chat/threads/naming-parts.ts +++ b/src/features/chat/threads/naming-parts.ts @@ -3,7 +3,7 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import type { ChatStateStore } from "../state/reducer"; import type { CodexChatHost } from "../chat-host"; import { AutoTitleController } from "./auto-title-controller"; -import { RenameController } from "./rename-controller"; +import { ThreadRenameEditorController } from "./rename-editor-controller"; export interface ThreadNamingPartsContext { plugin: CodexChatHost; @@ -22,14 +22,14 @@ export interface ThreadNamingPartsRefs { } export interface ThreadNamingParts { - rename: RenameController; + rename: ThreadRenameEditorController; autoTitle: AutoTitleController; resetThreadTurnPresence: (hadTurns: boolean) => void; } export function createThreadNamingParts(context: ThreadNamingPartsContext, refs: ThreadNamingPartsRefs): ThreadNamingParts { const { plugin, stateStore, client, status } = context; - const rename = new RenameController({ + const rename = new ThreadRenameEditorController({ stateStore, vaultPath: plugin.vaultPath, settings: () => plugin.settings, diff --git a/src/features/chat/threads/rename-actions.ts b/src/features/chat/threads/rename-actions.ts deleted file mode 100644 index b547485d..00000000 --- a/src/features/chat/threads/rename-actions.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AppServerClient } from "../../../app-server/connection/client"; -import type { ChatStateStore } from "../state/reducer"; - -export interface ThreadRenameActionsHost { - stateStore: ChatStateStore; - ensureConnected: () => Promise; - currentClient: () => AppServerClient | null; - addSystemMessage: (text: string) => void; - notifyThreadRenamed: (threadId: string, name: string) => void; -} - -export async function renameThread(host: ThreadRenameActionsHost, threadId: string, value: string): Promise { - const title = value.trim(); - if (!title) return false; - - await host.ensureConnected(); - return renameConnectedThread(host, threadId, title); -} - -export async function renameConnectedThread(host: ThreadRenameActionsHost, threadId: string, title: string): Promise { - const client = host.currentClient(); - if (!client) return false; - - try { - await client.setThreadName(threadId, title); - host.stateStore.dispatch({ - type: "thread-list/applied", - threads: host.stateStore - .getState() - .threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: title } : thread)), - }); - host.notifyThreadRenamed(threadId, title); - return true; - } catch (error) { - host.addSystemMessage(error instanceof Error ? error.message : String(error)); - return false; - } -} diff --git a/src/features/chat/threads/rename-controller.ts b/src/features/chat/threads/rename-editor-controller.ts similarity index 93% rename from src/features/chat/threads/rename-controller.ts rename to src/features/chat/threads/rename-editor-controller.ts index bcf2c8d8..9303a0d8 100644 --- a/src/features/chat/threads/rename-controller.ts +++ b/src/features/chat/threads/rename-editor-controller.ts @@ -1,6 +1,6 @@ import type { AppServerClient } from "../../../app-server/connection/client"; import { readCompletedConversationSummariesPage } from "../../../app-server/services/threads"; -import { getThreadTitle } from "../../../domain/threads/model"; +import { getThreadTitle, normalizeExplicitThreadName } from "../../../domain/threads/model"; import type { Thread } from "../../../domain/threads/model"; import type { CodexPanelSettings } from "../../../settings/model"; import { generateThreadTitleWithCodex } from "../../thread-title/generation"; @@ -14,7 +14,7 @@ import { type ChatStateStore, } from "../state/reducer"; import { messageStreamDisplayItems } from "../state/message-stream"; -import { renameConnectedThread } from "./rename-actions"; +import { renameConnectedThread } from "./thread-management-actions"; import { firstThreadTitleContextFromDisplayItems } from "./title-context"; export interface RenameEditState { @@ -22,7 +22,7 @@ export interface RenameEditState { generating: boolean; } -export interface RenameControllerHost { +export interface ThreadRenameEditorControllerHost { stateStore: ChatStateStore; vaultPath: string; settings: () => CodexPanelSettings; @@ -33,10 +33,10 @@ export interface RenameControllerHost { generateThreadTitle?: (context: ThreadTitleContext) => Promise; } -export class RenameController { +export class ThreadRenameEditorController { private nextRenameGenerationId = 1; - constructor(private readonly host: RenameControllerHost) {} + constructor(private readonly host: ThreadRenameEditorControllerHost) {} private get state(): ChatState { return this.host.stateStore.getState(); @@ -79,7 +79,7 @@ export class RenameController { async save(threadId: string, value: string): Promise { if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId || this.renameState.kind === "generating") return; const editingState = this.renameState; - const title = value.trim(); + const title = normalizeExplicitThreadName(value); if (!title) { this.cancel(threadId); return; diff --git a/src/features/chat/threads/restoration-controller.ts b/src/features/chat/threads/restoration-controller.ts index 2fbe4b10..7d447581 100644 --- a/src/features/chat/threads/restoration-controller.ts +++ b/src/features/chat/threads/restoration-controller.ts @@ -6,6 +6,7 @@ import { type RestoredThreadState, type ChatViewDeferredTasks, } from "../lifecycle"; +import { STATUS_THREAD_READY_TO_RESUME } from "./messages"; export interface RestorationControllerHost { deferredTasks: ChatViewDeferredTasks; @@ -47,7 +48,7 @@ export class RestorationController { restoredThread, }); this.host.stateStore.dispatch({ type: "active-thread/restored-placeholder", threadId: restoredThread.threadId }); - this.host.setStatus("Thread ready to resume."); + this.host.setStatus(STATUS_THREAD_READY_TO_RESUME); this.host.refreshTabHeader(); this.scheduleHydration(); } diff --git a/src/features/chat/threads/resume-controller.ts b/src/features/chat/threads/resume-controller.ts index a95b1c31..e08e7ca4 100644 --- a/src/features/chat/threads/resume-controller.ts +++ b/src/features/chat/threads/resume-controller.ts @@ -6,6 +6,7 @@ import type { RestorationController } from "./restoration-controller"; import { resumedThreadActionFromAppServerResponse } from "./resume"; import type { HistoryController } from "./history-controller"; import type { ChatResumeWorkTracker, ActiveChatResume } from "../lifecycle"; +import { finishBeforeSwitchingThreadsMessage, resumedThreadMessage } from "./messages"; export interface ResumeControllerHost { stateStore: ChatStateStore; @@ -30,7 +31,7 @@ export class ResumeController { async resumeThread(threadId: string): Promise { if (!canSwitchToThread(this.host.stateStore.getState(), threadId)) { - this.host.addSystemMessage("Finish or interrupt the current turn before switching threads."); + this.host.addSystemMessage(finishBeforeSwitchingThreadsMessage()); return; } const resume = this.host.resumeWork.begin(threadId); @@ -54,7 +55,7 @@ export class ResumeController { if (this.isStale(resume)) return; const renderFallbackMessage = displayItemsEmpty(this.host.stateStore.getState()); if (renderFallbackMessage) { - this.host.addSystemMessage(`Resumed thread ${response.thread.id}`); + this.host.addSystemMessage(resumedThreadMessage(response.thread.id)); } this.host.refreshLiveState(); } catch (error) { diff --git a/src/features/chat/threads/rollback-actions.ts b/src/features/chat/threads/rollback-actions.ts deleted file mode 100644 index d4b67cb8..00000000 --- a/src/features/chat/threads/rollback-actions.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { rollbackThread as rollbackThreadOnAppServer } from "../../../app-server/services/threads"; -import { rollbackCandidateFromItems } from "../display/item-actions"; -import { displayItemsFromTurns } from "../display/turn-items"; -import { chatTurnBusy } from "../state/reducer"; -import { messageStreamDisplayItems } from "../state/message-stream"; -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))) { - host.addSystemMessage("Interrupt the current turn before rolling back."); - return; - } - await host.ensureConnected(); - const client = host.currentClient(); - if (!client) return; - - const candidate = rollbackCandidateFromItems(messageStreamDisplayItems(threadActionState(host).messageStream)); - if (!candidate) { - host.addSystemMessage("No completed turn to roll back."); - return; - } - - try { - host.setStatus("Rolling back latest turn..."); - const snapshot = await rollbackThreadOnAppServer(client, threadId); - if (!threadActionStillTargetsPanel(threadActionState(host), threadId)) return; - threadActionDispatch( - host, - resumedThreadActionFromActiveRuntime({ - thread: snapshot.thread, - cwd: snapshot.cwd, - runtime: threadActionState(host).runtime, - listedThreads: threadActionState(host).threadList.listedThreads, - }), - ); - threadActionDispatch(host, { - type: "message-stream/items-replaced", - items: displayItemsFromTurns(snapshot.turns), - historyCursor: null, - loadingHistory: false, - }); - host.setComposerText(candidate.text); - host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted."); - host.setStatus("Rolled back latest turn."); - host.notifyActiveThreadIdentityChanged(); - await host.refreshThreads(); - host.refreshSharedThreadListFromOpenSurface(); - } catch (error) { - host.addSystemMessage(error instanceof Error ? error.message : String(error)); - host.setStatus("Rollback failed."); - } -} diff --git a/src/features/chat/threads/selection-actions.ts b/src/features/chat/threads/selection-actions.ts index bca77379..99a396e3 100644 --- a/src/features/chat/threads/selection-actions.ts +++ b/src/features/chat/threads/selection-actions.ts @@ -1,5 +1,6 @@ import { canSwitchToThread } from "../state/selectors"; import type { ChatStateStore } from "../state/reducer"; +import { finishBeforeSwitchingThreadsMessage } from "./messages"; export interface SelectionActionsHost { stateStore: ChatStateStore; @@ -17,7 +18,7 @@ export interface SelectionActions { 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."); + host.addSystemMessage(finishBeforeSwitchingThreadsMessage()); return; } diff --git a/src/features/chat/threads/thread-management-actions.ts b/src/features/chat/threads/thread-management-actions.ts new file mode 100644 index 00000000..723e79e2 --- /dev/null +++ b/src/features/chat/threads/thread-management-actions.ts @@ -0,0 +1,335 @@ +import { Notice } from "obsidian"; + +import type { AppServerClient } from "../../../app-server/connection/client"; +import { readThreadForArchiveExport, rollbackThread as rollbackThreadOnAppServer } from "../../../app-server/services/threads"; +import { inheritedForkThreadName, normalizeExplicitThreadName } from "../../../domain/threads/model"; +import type { CodexPanelSettings } from "../../../settings/model"; +import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown"; +import { exportArchivedThreadMarkdown } from "../../thread-export/archive-markdown"; +import type { CodexChatHost } from "../chat-host"; +import { + archivedSourceOpenForkFailedMessage, + finishBeforeArchivingThreadsMessage, + finishBeforeForkingThreadsMessage, + forkNameCopyFailedMessage, + interruptBeforeRollbackMessage, + noCompletedTurnToRollbackMessage, + openForkInNewPanelFailedMessage, + rollbackCompletedMessage, + selectedTurnNotFoundForForkMessage, + STATUS_COMPACTION_REQUESTED, + STATUS_ROLLBACK_COMPLETE, + STATUS_ROLLBACK_FAILED, + STATUS_ROLLBACK_STARTING, +} from "./messages"; +import { displayItemsFromTurns } from "../display/turn-items"; +import { messageStreamRollbackCandidate, messageStreamTurnsAfterTurnId } from "../state/message-stream"; +import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../state/reducer"; +import { resumedThreadActionFromActiveRuntime } from "./resume"; + +export interface ThreadManagementActionsHost { + stateStore: ChatStateStore; + vaultPath: string; + settings: () => CodexPanelSettings; + archiveAdapter: () => ArchiveExportAdapter; + ensureConnected: () => Promise; + currentClient: () => AppServerClient | null; + addSystemMessage: (text: string) => void; + setStatus: (status: string) => void; + setComposerText: (text: string) => void; + openThreadInNewView: (threadId: string) => Promise; + openThreadInCurrentPanel: (threadId: string) => Promise; + notifyThreadArchived: (threadId: string) => void; + notifyThreadRenamed: (threadId: string, name: string) => void; + notifyActiveThreadIdentityChanged: () => void; + refreshThreads: () => Promise; + refreshSharedThreadListFromOpenSurface: () => void; +} + +export interface ThreadManagementActions { + compactThread: (threadId: string) => Promise; + archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise; + forkThread: (threadId: string) => Promise; + forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise; + renameThread: (threadId: string, name: string) => Promise; + rollbackThread: (threadId: string) => Promise; +} + +export interface ThreadManagementActionsContext { + obsidian: { + archiveAdapter: () => ArchiveExportAdapter; + }; + plugin: CodexChatHost; + stateStore: ChatStateStore; + client: { + currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; + }; + status: { + set: (status: string) => void; + addSystemMessage: (text: string) => void; + }; + thread: { + selectThread: (threadId: string) => Promise; + refreshThreads: () => Promise; + notifyIdentityChanged: () => void; + }; + composer: { + setText: (text: string) => void; + }; +} + +type RenameThreadHost = Pick< + ThreadManagementActionsHost, + "ensureConnected" | "currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed" +>; + +type ConnectedRenameThreadHost = Pick< + ThreadManagementActionsHost, + "currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed" +>; + +export function createThreadManagementActions(context: ThreadManagementActionsContext): ThreadManagementActions { + const { obsidian, plugin, stateStore, client, status, thread, composer } = context; + const host: ThreadManagementActionsHost = { + stateStore, + vaultPath: plugin.vaultPath, + settings: () => plugin.settings, + archiveAdapter: obsidian.archiveAdapter, + ensureConnected: client.ensureConnected, + currentClient: client.currentClient, + addSystemMessage: status.addSystemMessage, + setStatus: status.set, + setComposerText: composer.setText, + openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId), + openThreadInCurrentPanel: thread.selectThread, + notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin), + notifyThreadRenamed: (threadId, name) => { + plugin.notifyThreadRenamed(threadId, name); + }, + notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, + refreshThreads: thread.refreshThreads, + refreshSharedThreadListFromOpenSurface: () => { + plugin.refreshSharedThreadListFromOpenSurface(); + }, + }; + + return { + compactThread: (threadId) => compactThread(host, threadId), + archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown), + forkThread: (threadId) => forkThread(host, threadId), + forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource), + renameThread: (threadId, name) => renameThread(host, threadId, name), + rollbackThread: (threadId) => rollbackThread(host, threadId), + }; +} + +export async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise { + await host.ensureConnected(); + const client = host.currentClient(); + if (!client) return; + const initialActiveThreadId = threadManagementState(host).activeThread.id; + try { + await client.compactThread(threadId); + if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return; + host.addSystemMessage(STATUS_COMPACTION_REQUESTED); + host.setStatus(STATUS_COMPACTION_REQUESTED); + } catch (error) { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + } +} + +export async function archiveThread( + host: ThreadManagementActionsHost, + threadId: string, + saveMarkdown = host.settings().archiveExportEnabled, +): Promise { + if (await archiveThreadOnServer(host, threadId, saveMarkdown)) { + host.notifyThreadArchived(threadId); + } +} + +async function archiveThreadOnServer( + host: ThreadManagementActionsHost, + threadId: string, + saveMarkdown = host.settings().archiveExportEnabled, +): Promise { + if (chatTurnBusy(threadManagementState(host))) { + host.addSystemMessage(finishBeforeArchivingThreadsMessage()); + return false; + } + await host.ensureConnected(); + const client = host.currentClient(); + if (!client) return false; + try { + const settings = host.settings(); + if (saveMarkdown) { + const result = await exportArchivedThreadMarkdown( + await readThreadForArchiveExport(client, threadId), + { ...settings, vaultPath: host.vaultPath }, + host.archiveAdapter(), + ); + new Notice(`Saved archived thread to ${result.path}.`); + } + await client.archiveThread(threadId); + return true; + } catch (error) { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + return false; + } +} + +function forkThread(host: ThreadManagementActionsHost, threadId: string): Promise { + return forkThreadFromTurn(host, threadId, null, false); +} + +export async function forkThreadFromTurn( + host: ThreadManagementActionsHost, + threadId: string, + turnId: string | null, + archiveSource: boolean, +): Promise { + if (chatTurnBusy(threadManagementState(host))) { + host.addSystemMessage(finishBeforeForkingThreadsMessage()); + return; + } + await host.ensureConnected(); + const client = host.currentClient(); + if (!client) return; + + const initialActiveThreadId = threadManagementState(host).activeThread.id; + const turnsToDrop = turnId ? messageStreamTurnsAfterTurnId(threadManagementState(host).messageStream, turnId) : 0; + if (turnsToDrop === null) { + host.addSystemMessage(selectedTurnNotFoundForForkMessage()); + return; + } + + try { + const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads); + const response = await client.forkThread(threadId, host.vaultPath); + const forkedThreadId = response.thread.id; + if (turnsToDrop > 0) { + await client.rollbackThread(forkedThreadId, turnsToDrop); + } + if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return; + if (sourceName) { + try { + await client.setThreadName(forkedThreadId, sourceName); + host.notifyThreadRenamed(forkedThreadId, sourceName); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + host.addSystemMessage(forkNameCopyFailedMessage(forkedThreadId, message)); + } + } + if (archiveSource) { + if (!(await archiveThreadOnServer(host, threadId))) return; + try { + await host.openThreadInCurrentPanel(forkedThreadId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + host.addSystemMessage(archivedSourceOpenForkFailedMessage(threadId, forkedThreadId, message)); + } + host.notifyThreadArchived(threadId); + return; + } + try { + await host.openThreadInNewView(forkedThreadId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + host.addSystemMessage(openForkInNewPanelFailedMessage(forkedThreadId, message)); + } + } catch (error) { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + } +} + +export async function renameThread(host: RenameThreadHost, threadId: string, value: string): Promise { + const title = normalizeExplicitThreadName(value); + if (!title) return false; + + await host.ensureConnected(); + return renameConnectedThread(host, threadId, title); +} + +export async function renameConnectedThread(host: ConnectedRenameThreadHost, threadId: string, title: string): Promise { + const client = host.currentClient(); + if (!client) return false; + + try { + await client.setThreadName(threadId, title); + host.stateStore.dispatch({ + type: "thread-list/applied", + threads: host.stateStore + .getState() + .threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: title } : thread)), + }); + host.notifyThreadRenamed(threadId, title); + return true; + } catch (error) { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + return false; + } +} + +export async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise { + if (chatTurnBusy(threadManagementState(host))) { + host.addSystemMessage(interruptBeforeRollbackMessage()); + return; + } + await host.ensureConnected(); + const client = host.currentClient(); + if (!client) return; + + const candidate = messageStreamRollbackCandidate(threadManagementState(host).messageStream); + if (!candidate) { + host.addSystemMessage(noCompletedTurnToRollbackMessage()); + return; + } + + try { + host.setStatus(STATUS_ROLLBACK_STARTING); + const snapshot = await rollbackThreadOnAppServer(client, threadId); + if (!threadManagementStillTargetsPanel(threadManagementState(host), threadId)) return; + threadManagementDispatch( + host, + resumedThreadActionFromActiveRuntime({ + thread: snapshot.thread, + cwd: snapshot.cwd, + runtime: threadManagementState(host).runtime, + listedThreads: threadManagementState(host).threadList.listedThreads, + }), + ); + threadManagementDispatch(host, { + type: "message-stream/items-replaced", + items: displayItemsFromTurns(snapshot.turns), + historyCursor: null, + loadingHistory: false, + }); + host.setComposerText(candidate.text); + host.addSystemMessage(rollbackCompletedMessage()); + host.setStatus(STATUS_ROLLBACK_COMPLETE); + host.notifyActiveThreadIdentityChanged(); + await host.refreshThreads(); + host.refreshSharedThreadListFromOpenSurface(); + } catch (error) { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + host.setStatus(STATUS_ROLLBACK_FAILED); + } +} + +function threadManagementState(host: ThreadManagementActionsHost): ChatState { + return host.stateStore.getState(); +} + +function threadManagementDispatch(host: ThreadManagementActionsHost, action: ChatAction): void { + host.stateStore.dispatch(action); +} + +function threadManagementStillTargetsPanel(state: ChatState, threadId: string): boolean { + return state.activeThread.id === threadId; +} + +function threadManagementStillTargetsOriginalPanel(state: ChatState, initialThreadId: string | null, threadId: string): boolean { + if (!initialThreadId) return true; + return initialThreadId === threadId && state.activeThread.id === threadId; +} diff --git a/src/features/chat/threads/title-context.ts b/src/features/chat/threads/title-context.ts index ea0ef283..be02af36 100644 --- a/src/features/chat/threads/title-context.ts +++ b/src/features/chat/threads/title-context.ts @@ -1,6 +1,6 @@ import type { ThreadTitleContext } from "../../thread-title/model"; import { truncate } from "../../../utils"; -import { isCompletedTurnOutcomeMessage } from "../display/predicates"; +import { isCompletedTurnOutcomeMessage } from "../display/item-selectors"; import type { DisplayItem } from "../display/types"; const MAX_CONTEXT_CHARS = 4_000; diff --git a/src/features/chat/threads/title-display.ts b/src/features/chat/threads/title-display.ts new file mode 100644 index 00000000..0bf21f2b --- /dev/null +++ b/src/features/chat/threads/title-display.ts @@ -0,0 +1,10 @@ +import { getThreadTitle, type Thread } from "../../../domain/threads/model"; +import { shortThreadId } from "../../../utils"; + +export function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string { + if (!activeThreadId) return "Codex"; + + const thread = threads.find((item) => item.id === activeThreadId); + const title = thread ? getThreadTitle(thread).replace(/\s+/g, " ").trim() : (fallbackTitle ?? shortThreadId(activeThreadId)); + return title ? `Codex: ${title}` : "Codex"; +} diff --git a/src/features/thread-export/archive-markdown.ts b/src/features/thread-export/archive-markdown.ts index d8f94fea..aaeb9604 100644 --- a/src/features/thread-export/archive-markdown.ts +++ b/src/features/thread-export/archive-markdown.ts @@ -1,6 +1,6 @@ import { shortThreadId } from "../../utils"; import { getThreadTitle, type Thread } from "../../domain/threads/model"; -import { referencedThreadDisplayFromPrompt } from "../../domain/threads/reference"; +import { referencedThreadMetadataFromPrompt } from "../../domain/threads/reference"; import type { ThreadTranscriptEntry } from "../../domain/threads/transcript"; export interface ArchiveExportAdapter { @@ -125,7 +125,7 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[ switch (entry.kind) { case "user": { const heading = timestampedHeading("User", entry.timestamp); - const referenced = referencedThreadDisplayFromPrompt(entry.text); + const referenced = referencedThreadMetadataFromPrompt(entry.text); if (referenced) { return [ heading, diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 23620044..6499721a 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -3,6 +3,7 @@ import { Notice } from "obsidian"; import type { AppServerClient } from "../../app-server/connection/client"; import { ConnectionManager, StaleConnectionError } from "../../app-server/connection/connection-manager"; import { listThreads, readCompletedConversationSummariesPage, readThreadForArchiveExport } from "../../app-server/services/threads"; +import { normalizeExplicitThreadName } from "../../domain/threads/model"; import type { Thread } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot"; @@ -291,7 +292,7 @@ export class CodexThreadsSession { private async saveRename(threadId: string, value: string): Promise { const editingState = this.renameStates.get(threadId); if (!editingState || editingState.kind === "generating") return; - const name = value.trim(); + const name = normalizeExplicitThreadName(value); if (!name) { this.cancelRename(threadId); return; diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index 64a4e9b5..d21b7b91 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -1,6 +1,6 @@ import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot"; import type { Thread } from "../../domain/threads/model"; -import { getThreadTitle } from "../../domain/threads/model"; +import { explicitThreadName, getThreadTitle } from "../../domain/threads/model"; type ThreadsLiveStatus = "needs-input" | "approval" | "running" | "draft" | "offline" | "open"; @@ -54,7 +54,7 @@ export function threadRows( selected, rename: { active: rename !== undefined, - draft: rename?.draft ?? thread.name ?? getThreadTitle(thread), + draft: rename?.draft ?? explicitThreadName(thread) ?? getThreadTitle(thread), generating: rename?.kind === "generating", }, archiveConfirm: { diff --git a/src/settings/archived-thread-title.ts b/src/settings/archived-thread-title.ts new file mode 100644 index 00000000..f86270ac --- /dev/null +++ b/src/settings/archived-thread-title.ts @@ -0,0 +1,19 @@ +import { getThreadTitle, type Thread } from "../domain/threads/model"; + +const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function archivedThreadDisplayTitle(thread: Thread): string { + const title = normalizedThreadTitle(thread); + if (!title || title === thread.id || UUID_PATTERN.test(title)) return "Untitled archived thread"; + return truncateTitle(title, MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH); +} + +function normalizedThreadTitle(thread: Thread): string { + return getThreadTitle(thread).replace(/\s+/g, " ").trim(); +} + +function truncateTitle(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3).trimEnd()}...`; +} diff --git a/src/settings/dynamic-sections.ts b/src/settings/dynamic-sections.ts index dcf0455c..f51031fa 100644 --- a/src/settings/dynamic-sections.ts +++ b/src/settings/dynamic-sections.ts @@ -2,8 +2,8 @@ import { Setting } from "obsidian"; import type { HookItem } from "../domain/catalog/metadata"; import type { Thread } from "../domain/threads/model"; -import { archivedThreadDisplayTitle } from "../domain/threads/model"; import { shortThreadId } from "../utils"; +import { archivedThreadDisplayTitle } from "./archived-thread-title"; export interface ArchivedThreadSectionState { exportEnabled: boolean; diff --git a/src/settings/tab.ts b/src/settings/tab.ts index b6e5abd6..98887bd0 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -10,8 +10,8 @@ import type { HookItem, ModelMetadata } from "../domain/catalog/metadata"; import type { Thread } from "../domain/threads/model"; import { supportedEffortsForModelMetadata } from "../domain/catalog/metadata"; import { findModelMetadataByIdOrName, sortedModelMetadata } from "../domain/catalog/metadata"; -import { archivedThreadDisplayTitle } from "../domain/threads/model"; import { errorMessage } from "../utils"; +import { archivedThreadDisplayTitle } from "./archived-thread-title"; import { createSettingsDynamicSectionLifecycle, transitionSettingsDynamicSectionLifecycle, diff --git a/tests/domain/threads/reference.test.ts b/tests/domain/threads/reference.test.ts index 361f26b7..e59b83bc 100644 --- a/tests/domain/threads/reference.test.ts +++ b/tests/domain/threads/reference.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; import { - referencedThreadDisplayFromPrompt, + referencedThreadMetadataFromPrompt, referencedThreadPromptBundle, referencedThreadPrompt, } from "../../../src/domain/threads/reference"; @@ -35,7 +35,7 @@ describe("thread reference context", () => { it("extracts display text and metadata from a reference prompt", () => { const prompt = referencedThreadPrompt(thread(), [{ userText: "元の依頼", assistantText: "回答" }], "この続きです"); - expect(referencedThreadDisplayFromPrompt(prompt)).toEqual({ + expect(referencedThreadMetadataFromPrompt(prompt)).toEqual({ text: "この続きです", reference: { threadId: "019abcde-0000-7000-8000-000000000001", @@ -48,7 +48,7 @@ describe("thread reference context", () => { it("does not parse the old line-based reference prompt format", () => { expect( - referencedThreadDisplayFromPrompt( + referencedThreadMetadataFromPrompt( [ "[Codex Panel referenced thread]", "Title: 参照元", @@ -72,7 +72,7 @@ describe("thread reference context", () => { it("rejects malformed or unsupported reference envelopes", () => { expect( - referencedThreadDisplayFromPrompt( + referencedThreadMetadataFromPrompt( [ "[Codex Panel referenced thread v1]", "{not-json}", @@ -88,7 +88,7 @@ describe("thread reference context", () => { ).toBeNull(); expect( - referencedThreadDisplayFromPrompt( + referencedThreadMetadataFromPrompt( [ "[Codex Panel referenced thread v1]", '{"version":2,"threadId":"thread-ref","title":"参照元","includedTurns":1,"turnLimit":20}', @@ -108,7 +108,6 @@ describe("thread reference context", () => { const source = thread(); const input = referencedThreadPromptBundle(source, [{ userText: "元の依頼", assistantText: "回答" }], "この続きです"); - expect(input.status).toBe("Referencing 019abcde (1/20 turns)."); expect(input.referencedThread).toMatchObject({ threadId: source.id, title: "参照元", includedTurns: 1 }); expect(input.prompt).toContain("Current user request:\nこの続きです"); }); diff --git a/tests/domain/threads/threads.test.ts b/tests/domain/threads/threads.test.ts index fc75a755..9a982e46 100644 --- a/tests/domain/threads/threads.test.ts +++ b/tests/domain/threads/threads.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { - codexPanelDisplayTitle, explicitThreadName, getThreadTitle, inheritedForkThreadName, + normalizeExplicitThreadName, upsertThread, type Thread, } from "../../../src/domain/threads/model"; @@ -16,13 +16,6 @@ describe("thread helpers", () => { expect(getThreadTitle(thread({ id: "thread-id", name: null, preview: "" }))).toBe("thread-id"); }); - it("formats Codex panel display titles from the active thread", () => { - expect(codexPanelDisplayTitle(null, [])).toBe("Codex"); - expect(codexPanelDisplayTitle("thread-named", [thread({ id: "thread-named", name: "作業メモ" })])).toBe("Codex: 作業メモ"); - expect(codexPanelDisplayTitle("thread-preview", [thread({ id: "thread-preview", preview: "初回依頼" })])).toBe("Codex: 初回依頼"); - expect(codexPanelDisplayTitle("019e061e-0000-7000-8000-000000000001", [])).toBe("Codex: 019e061e"); - }); - it("inherits only explicit thread names for forked threads", () => { expect(inheritedForkThreadName("named", [thread({ id: "named", name: "親スレッド", preview: "Preview" })])).toBe("親スレッド"); expect(inheritedForkThreadName("preview-only", [thread({ id: "preview-only", preview: "Preview" })])).toBeNull(); @@ -38,6 +31,13 @@ describe("thread helpers", () => { expect(explicitThreadName(thread({ name: null, preview: "", id: "thread-id" }))).toBeNull(); }); + it("normalizes explicit thread name values", () => { + expect(normalizeExplicitThreadName(" Rename thread ")).toBe("Rename thread"); + expect(normalizeExplicitThreadName(" ")).toBeNull(); + expect(normalizeExplicitThreadName(null)).toBeNull(); + expect(normalizeExplicitThreadName(undefined)).toBeNull(); + }); + it("upserts resumed thread metadata without reordering existing rows", () => { const first = thread({ id: "first", preview: "old" }); const second = thread({ id: "second" }); diff --git a/tests/features/chat/conversation/turns/slash-command-handler.test.ts b/tests/features/chat/conversation/turns/slash-command-handler.test.ts index da7de78c..88bcf793 100644 --- a/tests/features/chat/conversation/turns/slash-command-handler.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-handler.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../../src/app-server/connection/client"; import type { CodexInput } from "../../../../../src/app-server/protocol/request-input"; +import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protocol/turn"; import { createChatState, createChatStateStore } from "../../../../../src/features/chat/state/reducer"; import { createSlashCommandHandler, @@ -154,4 +155,44 @@ describe("createSlashCommandHandler", () => { expect(result).toBeUndefined(); expect(host.addSystemMessage).toHaveBeenCalledWith("Referenced thread has no readable conversation turns."); }); + + it("sets chat-owned status copy for readable referenced threads", async () => { + const { host, stateStore, threadTurnsList } = createHost(); + stateStore.dispatch({ + type: "thread-list/applied", + threads: [thread("019abcde-0000-7000-8000-000000000001", "Other")], + }); + threadTurnsList.mockResolvedValue({ + data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])], + nextCursor: null, + }); + const controller = createSlashCommandHandler(host); + + const result = await controller.execute("refer", "Other summarize"); + + expect(result?.sendText).toBe("summarize"); + expect(host.setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns)."); + }); }); + +function turn(items: TurnRecord["items"], overrides: Partial = {}): TurnRecord { + return { + id: "turn", + items, + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + ...overrides, + }; +} + +function userMessage(id: string, text: string): TurnItem { + return { type: "userMessage", id, clientId: null, content: [{ type: "text", text, text_elements: [] }] }; +} + +function agentMessage(id: string, text: string): TurnItem { + return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null }; +} diff --git a/tests/features/chat/display/display-model.test.ts b/tests/features/chat/display/display-model.test.ts index a90f9456..32646d86 100644 --- a/tests/features/chat/display/display-model.test.ts +++ b/tests/features/chat/display/display-model.test.ts @@ -12,7 +12,7 @@ import { upsertDisplayItem, } from "../../../../src/features/chat/state/message-stream-updates"; import { taskProgressDisplayItem, taskProgressExecutionState } from "../../../../src/features/chat/display/items/task-progress"; -import { normalizeProposedPlanMarkdown } from "../../../../src/features/chat/display/items/proposed-plan"; +import { normalizeProposedPlanMarkdown } from "../../../../src/features/chat/display/items/message-content"; import { pathRelativeToRoot } from "../../../../src/features/chat/display/details/path-labels"; import { permissionRows } from "../../../../src/features/chat/display/details/permission-rows"; import { diff --git a/tests/features/chat/display/item-actions.test.ts b/tests/features/chat/display/item-actions.test.ts deleted file mode 100644 index c532a925..00000000 --- a/tests/features/chat/display/item-actions.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - forkCandidatesFromItems, - isForkCandidateItem, - isRollbackCandidateItem, - rollbackCandidateFromItems, - turnsAfterTurnId, -} from "../../../../src/features/chat/display/item-actions"; -import type { DisplayItem } from "../../../../src/features/chat/display/types"; - -describe("fork candidates", () => { - it("selects final assistant messages and counts later turns", () => { - const items: DisplayItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "first", turnId: "turn-1" }, - { - id: "a1", - kind: "message", - role: "assistant", - text: "first answer", - turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", - }, - { id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" }, - { - id: "a2-delta", - kind: "message", - role: "assistant", - text: "draft", - turnId: "turn-2", - messageKind: "proposedPlan", - messageState: "streaming", - }, - { - id: "a2", - kind: "message", - role: "assistant", - text: "second answer", - turnId: "turn-2", - messageKind: "assistantResponse", - messageState: "completed", - }, - { id: "u3", kind: "message", messageKind: "user", role: "user", text: "third", turnId: "turn-3" }, - { - id: "a3", - kind: "message", - role: "assistant", - text: "third answer", - turnId: "turn-3", - messageKind: "assistantResponse", - messageState: "completed", - }, - ]; - - const candidates = forkCandidatesFromItems(items); - - expect(candidates).toEqual([ - { displayItemId: "a1", turnId: "turn-1" }, - { displayItemId: "a2", turnId: "turn-2" }, - { displayItemId: "a3", turnId: "turn-3" }, - ]); - expect(isForkCandidateItem(expectPresent(items[4]), candidates)).toBe(true); - expect(isForkCandidateItem(expectPresent(items[3]), candidates)).toBe(false); - expect(turnsAfterTurnId(items, "turn-1")).toBe(2); - expect(turnsAfterTurnId(items, "turn-2")).toBe(1); - expect(turnsAfterTurnId(items, "turn-3")).toBe(0); - expect(turnsAfterTurnId(items, "missing")).toBeNull(); - }); -}); - -describe("rollback candidate", () => { - it("selects the first user message from the latest turn", () => { - const items: DisplayItem[] = [ - { id: "u1", kind: "message", messageKind: "user", role: "user", text: "older", turnId: "turn-1" }, - { - id: "a1", - kind: "message", - role: "assistant", - text: "older answer", - turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", - }, - { id: "u2", kind: "message", messageKind: "user", role: "user", text: "latest", turnId: "turn-2" }, - { - id: "a2", - kind: "message", - role: "assistant", - text: "latest answer", - turnId: "turn-2", - messageKind: "assistantResponse", - messageState: "completed", - }, - ]; - - const candidate = rollbackCandidateFromItems(items); - - expect(candidate).toEqual({ turnId: "turn-2", displayItemId: "u2", text: "latest" }); - expect(isRollbackCandidateItem(expectPresent(items[2]), candidate)).toBe(true); - expect(isRollbackCandidateItem(expectPresent(items[0]), candidate)).toBe(false); - expect(isRollbackCandidateItem({ ...expectPresent(items[2]), turnId: "turn-other" }, candidate)).toBe(false); - }); - - it("returns null when there is no completed user turn in display items", () => { - expect(rollbackCandidateFromItems([])).toBeNull(); - expect(rollbackCandidateFromItems([{ id: "system", kind: "system", role: "system", text: "Idle" }])).toBeNull(); - expect( - rollbackCandidateFromItems([ - { - id: "a1", - kind: "message", - role: "assistant", - text: "answer", - turnId: "turn-1", - messageKind: "assistantResponse", - messageState: "completed", - }, - ]), - ).toBeNull(); - }); -}); - -function expectPresent(value: T | null | undefined): T { - if (value === null || value === undefined) throw new Error("Expected value to be present"); - return value; -} diff --git a/tests/features/chat/display/item-selectors.test.ts b/tests/features/chat/display/item-selectors.test.ts new file mode 100644 index 00000000..1a75702a --- /dev/null +++ b/tests/features/chat/display/item-selectors.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { + forkCandidatesFromItems, + isForkCandidateItem, + isRollbackCandidateItem, +} from "../../../../src/features/chat/display/item-selectors"; +import type { DisplayItem } from "../../../../src/features/chat/display/types"; + +describe("display item selectors", () => { + it("selects final assistant messages as fork candidates", () => { + const items = displayItems(); + + const candidates = forkCandidatesFromItems(items); + + expect(candidates).toEqual([ + { displayItemId: "a1", turnId: "turn-1" }, + { displayItemId: "a2", turnId: "turn-2" }, + { displayItemId: "a3", turnId: "turn-3" }, + ]); + expect(isForkCandidateItem(expectPresent(items[4]), candidates)).toBe(true); + expect(isForkCandidateItem(expectPresent(items[3]), candidates)).toBe(false); + }); + + it("matches rollback candidate display items", () => { + const items = displayItems(); + const candidate = { turnId: "turn-3", displayItemId: "u3" }; + + expect(isRollbackCandidateItem(expectPresent(items[5]), candidate)).toBe(true); + expect(isRollbackCandidateItem(expectPresent(items[0]), candidate)).toBe(false); + expect(isRollbackCandidateItem({ ...expectPresent(items[5]), turnId: "turn-other" }, candidate)).toBe(false); + }); +}); + +function displayItems(): DisplayItem[] { + return [ + { id: "u1", kind: "message", messageKind: "user", role: "user", text: "first", turnId: "turn-1" }, + { + id: "a1", + kind: "message", + role: "assistant", + text: "first answer", + turnId: "turn-1", + messageKind: "assistantResponse", + messageState: "completed", + }, + { id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" }, + { + id: "a2-delta", + kind: "message", + role: "assistant", + text: "draft", + turnId: "turn-2", + messageKind: "proposedPlan", + messageState: "streaming", + }, + { + id: "a2", + kind: "message", + role: "assistant", + text: "second answer", + turnId: "turn-2", + messageKind: "assistantResponse", + messageState: "completed", + }, + { id: "u3", kind: "message", messageKind: "user", role: "user", text: "third", turnId: "turn-3" }, + { + id: "a3", + kind: "message", + role: "assistant", + text: "third answer", + turnId: "turn-3", + messageKind: "assistantResponse", + messageState: "completed", + }, + ]; +} + +function expectPresent(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("Expected value to be present"); + return value; +} diff --git a/tests/features/chat/panel/surface/toolbar.test.ts b/tests/features/chat/panel/surface/toolbar.test.ts index 1f456fb8..a6831913 100644 --- a/tests/features/chat/panel/surface/toolbar.test.ts +++ b/tests/features/chat/panel/surface/toolbar.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { createChatState, createChatStateStore } from "../../../../../src/features/chat/state/reducer"; import { createToolbarPanelActions } from "../../../../../src/features/chat/panel/toolbar-actions"; -import type { ChatThreadActions } from "../../../../../src/features/chat/threads/action-context"; +import type { ThreadManagementActions } from "../../../../../src/features/chat/threads/thread-management-actions"; describe("createToolbarPanelActions", () => { it("tracks archive confirmation and delegates archive actions", async () => { @@ -12,7 +12,7 @@ describe("createToolbarPanelActions", () => { const archiveThread = vi.fn().mockResolvedValue(undefined); const actions = createToolbarPanelActions({ stateStore, - threadActions: { archiveThread } as unknown as ChatThreadActions, + threadActions: { archiveThread } as unknown as ThreadManagementActions, }); actions.startArchive("thread"); @@ -28,7 +28,7 @@ describe("createToolbarPanelActions", () => { const stateStore = createChatStateStore(createChatState()); const actions = createToolbarPanelActions({ stateStore, - threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActions, + threadActions: { archiveThread: vi.fn() } as unknown as ThreadManagementActions, }); actions.toggleHistory(); expect(stateStore.getState().ui.toolbarPanel).toBe("history"); diff --git a/tests/features/chat/protocol/inbound/controller.test.ts b/tests/features/chat/protocol/inbound/controller.test.ts index 74f8d860..bb424ad8 100644 --- a/tests/features/chat/protocol/inbound/controller.test.ts +++ b/tests/features/chat/protocol/inbound/controller.test.ts @@ -1332,7 +1332,7 @@ describe("ChatInboundController", () => { controller.handleNotification({ method: "thread/name/updated", - params: { threadId: "thread-active", threadName: "Codex Panel自動命名" }, + params: { threadId: "thread-active", threadName: " Codex Panel自動命名 " }, } satisfies Extract); expect(state.threadList.listedThreads[0]?.name).toBe("Codex Panel自動命名"); diff --git a/tests/features/chat/protocol/server-requests/approval.test.ts b/tests/features/chat/protocol/server-requests/approval.test.ts index 534dac97..bdc11023 100644 --- a/tests/features/chat/protocol/server-requests/approval.test.ts +++ b/tests/features/chat/protocol/server-requests/approval.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from "vitest"; import { - approvalActionOptions, - approvalDetails, approvalResponse, - approvalSummary, - approvalTitle, toPendingApproval, type CommandApprovalDecision, } from "../../../../../src/features/chat/protocol/server-requests/approval"; +import { + approvalActionOptions, + approvalDetails, + approvalSummary, + approvalTitle, +} from "../../../../../src/features/chat/conversation/pending-requests/approval-view"; import type { ServerRequest } from "../../../../../src/app-server/connection/rpc-messages"; function expectPresent(value: T | null | undefined): T { diff --git a/tests/features/chat/state/message-stream-selectors.test.ts b/tests/features/chat/state/message-stream-selectors.test.ts new file mode 100644 index 00000000..3ef7802b --- /dev/null +++ b/tests/features/chat/state/message-stream-selectors.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import type { DisplayItem } from "../../../../src/features/chat/display/types"; +import { + initialChatMessageStreamState, + messageStreamRollbackCandidate, + messageStreamTurnsAfterTurnId, + messageStreamTurnIds, + messageStreamWithActiveTurnItems, +} from "../../../../src/features/chat/state/message-stream"; + +describe("message stream selectors", () => { + it("counts turns after a turn id from message stream state", () => { + const state = initialChatMessageStreamState(displayItems()); + + expect(messageStreamTurnIds(state)).toEqual(["turn-1", "turn-2", "turn-3"]); + expect(messageStreamTurnsAfterTurnId(state, "turn-1")).toBe(2); + expect(messageStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); + expect(messageStreamTurnsAfterTurnId(state, "turn-3")).toBe(0); + expect(messageStreamTurnsAfterTurnId(state, "missing")).toBeNull(); + }); + + it("includes the active segment when counting turns", () => { + const state = messageStreamWithActiveTurnItems(initialChatMessageStreamState(displayItems()), "turn-3", displayItems()); + + expect(messageStreamTurnIds(state)).toEqual(["turn-1", "turn-2", "turn-3"]); + expect(messageStreamTurnsAfterTurnId(state, "turn-2")).toBe(1); + }); + + it("selects the latest turn user message for rollback restoration", () => { + const state = initialChatMessageStreamState(displayItems()); + + expect(messageStreamRollbackCandidate(state)).toEqual({ turnId: "turn-3", displayItemId: "u3", text: "third" }); + }); + + it("returns null when rollback has no user message candidate", () => { + expect(messageStreamRollbackCandidate(initialChatMessageStreamState([]))).toBeNull(); + expect( + messageStreamRollbackCandidate(initialChatMessageStreamState([{ id: "system", kind: "system", role: "system", text: "Idle" }])), + ).toBeNull(); + expect( + messageStreamRollbackCandidate( + initialChatMessageStreamState([ + { + id: "a1", + kind: "message", + role: "assistant", + text: "answer", + turnId: "turn-1", + messageKind: "assistantResponse", + messageState: "completed", + }, + ]), + ), + ).toBeNull(); + }); +}); + +function displayItems(): DisplayItem[] { + return [ + { id: "u1", kind: "message", messageKind: "user", role: "user", text: "first", turnId: "turn-1" }, + { + id: "a1", + kind: "message", + role: "assistant", + text: "first answer", + turnId: "turn-1", + messageKind: "assistantResponse", + messageState: "completed", + }, + { id: "tool-1", kind: "tool", role: "tool", text: "work", turnId: "turn-2" }, + { + id: "a2", + kind: "message", + role: "assistant", + text: "second answer", + turnId: "turn-2", + messageKind: "assistantResponse", + messageState: "completed", + }, + { id: "u3", kind: "message", messageKind: "user", role: "user", text: "third", turnId: "turn-3" }, + { + id: "a3", + kind: "message", + role: "assistant", + text: "third answer", + turnId: "turn-3", + messageKind: "assistantResponse", + messageState: "completed", + }, + ]; +} diff --git a/tests/features/chat/threads/rename-controller.test.ts b/tests/features/chat/threads/rename-editor-controller.test.ts similarity index 91% rename from tests/features/chat/threads/rename-controller.test.ts rename to tests/features/chat/threads/rename-editor-controller.test.ts index 33a275a1..222cb59a 100644 --- a/tests/features/chat/threads/rename-controller.test.ts +++ b/tests/features/chat/threads/rename-editor-controller.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { createChatStateStore } from "../../../../src/features/chat/state/reducer"; -import { RenameController } from "../../../../src/features/chat/threads/rename-controller"; +import { ThreadRenameEditorController } from "../../../../src/features/chat/threads/rename-editor-controller"; import type { AppServerClient } from "../../../../src/app-server/connection/client"; import type { Thread } from "../../../../src/domain/threads/model"; import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; import { DEFAULT_SETTINGS } from "../../../../src/settings/model"; import { deferred } from "../../../support/async"; -describe("RenameController", () => { +describe("ThreadRenameEditorController", () => { it("stores controlled rename drafts in chat UI state", () => { const { controller } = controllerFixture(); @@ -84,7 +84,7 @@ describe("RenameController", () => { }); controller.start("thread"); - const save = controller.save("thread", "Saved title"); + const save = controller.save("thread", " Saved title "); await flushPromises(); controller.cancel("thread"); @@ -93,6 +93,7 @@ describe("RenameController", () => { saved.resolve({}); await save; + expect(setThreadName).toHaveBeenCalledWith("thread", "Saved title"); expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Saved title"); expect(notifyThreadRenamed).toHaveBeenCalledWith("thread", "Saved title"); expect(controller.editState("thread")).toEqual({ draft: "New draft", generating: false }); @@ -144,8 +145,8 @@ describe("RenameController", () => { }); function controllerFixture( - overrides: Partial[0]> = {}, -): ConstructorParameters[0] & { controller: RenameController } { + overrides: Partial[0]> = {}, +): ConstructorParameters[0] & { controller: ThreadRenameEditorController } { const stateStore = createChatStateStore(); stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread")] }); const host = { @@ -157,8 +158,8 @@ function controllerFixture( addSystemMessage: vi.fn(), notifyThreadRenamed: vi.fn(), ...overrides, - } satisfies ConstructorParameters[0]; - return { ...host, controller: new RenameController(host) }; + } satisfies ConstructorParameters[0]; + return { ...host, controller: new ThreadRenameEditorController(host) }; } function fakeClient(options: { setThreadName?: ReturnType } = {}): AppServerClient { diff --git a/tests/features/chat/threads/actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts similarity index 92% rename from tests/features/chat/threads/actions.test.ts rename to tests/features/chat/threads/thread-management-actions.test.ts index 528b7fa3..b125a791 100644 --- a/tests/features/chat/threads/actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -4,12 +4,15 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; import type { ArchiveExportAdapter } from "../../../../src/features/thread-export/archive-markdown"; import { createChatState, createChatStateStore } from "../../../../src/features/chat/state/reducer"; -import { archiveThread } from "../../../../src/features/chat/threads/archive-actions"; -import { compactThread } from "../../../../src/features/chat/threads/compact-actions"; -import { forkThreadFromTurn } from "../../../../src/features/chat/threads/fork-actions"; -import { renameThread } from "../../../../src/features/chat/threads/rename-actions"; -import { rollbackThread as rollbackThreadAction } from "../../../../src/features/chat/threads/rollback-actions"; -import type { ChatThreadActions, ChatThreadActionsHost } from "../../../../src/features/chat/threads/action-context"; +import { + archiveThread, + compactThread, + forkThreadFromTurn, + renameThread, + rollbackThread as rollbackThreadAction, + type ThreadManagementActions, + type ThreadManagementActionsHost, +} from "../../../../src/features/chat/threads/thread-management-actions"; import type { DisplayItem } from "../../../../src/features/chat/display/types"; import { DEFAULT_SETTINGS } from "../../../../src/settings/model"; import { notices } from "../../../mocks/obsidian"; @@ -22,7 +25,7 @@ type MockArchiveExportAdapter = ArchiveExportAdapter & { write: ReturnType>; }; -describe("chat thread actions", () => { +describe("thread management actions", () => { beforeEach(() => { notices.length = 0; }); @@ -30,7 +33,7 @@ describe("chat thread actions", () => { it("requests thread compaction and reports the shared status", async () => { const client = clientMock(); const host = hostMock({ client, displayItems: [] }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.compactThread("source"); @@ -56,7 +59,7 @@ describe("chat thread actions", () => { approvalsReviewer: null, activePermissionProfile: null, }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); const pendingCompact = controller.compactThread("source"); await waitForAsyncWork(() => { @@ -94,7 +97,7 @@ describe("chat thread actions", () => { archiveExportFilenameTemplate: "{{title}} {{shortId}}", }, }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.archiveThread("source"); @@ -126,7 +129,7 @@ describe("chat thread actions", () => { archiveExportFilenameTemplate: "{{title}} {{shortId}}", }, }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.archiveThread("source"); @@ -139,7 +142,7 @@ describe("chat thread actions", () => { it("forks from a selected turn by dropping later turns on the fork", async () => { const client = clientMock(); const host = hostMock({ client, displayItems: turnItems() }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.forkThreadFromTurn("source", "turn-1", false); @@ -164,7 +167,7 @@ describe("chat thread actions", () => { archiveExportFilenameTemplate: "{{title}} {{shortId}}", }, }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.forkThreadFromTurn("source", "turn-3", true); @@ -183,7 +186,7 @@ describe("chat thread actions", () => { const client = clientMock(); client.archiveThread.mockRejectedValue(new Error("archive failed")); const host = hostMock({ client, displayItems: turnItems() }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.forkThreadFromTurn("source", "turn-3", true); @@ -198,7 +201,7 @@ describe("chat thread actions", () => { const client = clientMock(); const host = hostMock({ client, displayItems: turnItems() }); host.openThreadInCurrentPanel.mockRejectedValue(new Error("resume failed")); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.forkThreadFromTurn("source", "turn-3", true); @@ -228,7 +231,7 @@ describe("chat thread actions", () => { threads: [{ ...panelThread("source"), name: "Source name" }], threadsLoaded: true, }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); const pendingFork = controller.forkThreadFromTurn("source", null, true); await waitForAsyncWork(() => { @@ -259,9 +262,9 @@ describe("chat thread actions", () => { const client = clientMock(); const host = hostMock({ client, displayItems: [] }); host.stateStore.dispatch({ type: "thread-list/applied", threads: [{ ...panelThread("thread"), name: "Old" }] }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); - await expect(controller.renameThread("thread", " Slash command title ")).resolves.toBe(true); + await expect(controller.renameThread("thread", " Slash command title ")).resolves.toBe(true); expect(host.ensureConnected).toHaveBeenCalledOnce(); expect(client.setThreadName).toHaveBeenCalledWith("thread", "Slash command title"); @@ -272,7 +275,7 @@ describe("chat thread actions", () => { it("ignores empty thread rename titles", async () => { const client = clientMock(); const host = hostMock({ client, displayItems: [] }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await expect(controller.renameThread("thread", " ")).resolves.toBe(false); @@ -296,7 +299,7 @@ describe("chat thread actions", () => { activePermissionProfile: null, }); host.stateStore.dispatch({ type: "message-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); await controller.rollbackThread("source"); @@ -327,7 +330,7 @@ describe("chat thread actions", () => { activePermissionProfile: null, }); host.stateStore.dispatch({ type: "message-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false }); - const controller = chatThreadActions(host); + const controller = threadManagementActions(host); const pendingRollback = controller.rollbackThread("source"); await waitForAsyncWork(() => { @@ -400,7 +403,7 @@ function clientMock() { }; } -function chatThreadActions(host: ChatThreadActionsHost): ChatThreadActions { +function threadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { return { compactThread: (threadId) => compactThread(host, threadId), archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown), @@ -442,7 +445,7 @@ function hostMock({ notifyActiveThreadIdentityChanged: vi.fn(), refreshThreads: vi.fn().mockResolvedValue(undefined), refreshSharedThreadListFromOpenSurface: vi.fn(), - } satisfies ChatThreadActionsHost; + } satisfies ThreadManagementActionsHost; } function archiveAdapterMock(overrides: Partial = {}): MockArchiveExportAdapter { diff --git a/tests/features/chat/threads/title-display.test.ts b/tests/features/chat/threads/title-display.test.ts new file mode 100644 index 00000000..8d5e73e4 --- /dev/null +++ b/tests/features/chat/threads/title-display.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import type { Thread } from "../../../../src/domain/threads/model"; +import { codexPanelDisplayTitle } from "../../../../src/features/chat/threads/title-display"; + +describe("chat thread title display", () => { + it("formats Codex panel display titles from the active thread", () => { + expect(codexPanelDisplayTitle(null, [])).toBe("Codex"); + expect(codexPanelDisplayTitle("thread-named", [thread({ id: "thread-named", name: "作業メモ" })])).toBe("Codex: 作業メモ"); + expect(codexPanelDisplayTitle("thread-preview", [thread({ id: "thread-preview", preview: "初回依頼" })])).toBe("Codex: 初回依頼"); + expect(codexPanelDisplayTitle("019e061e-0000-7000-8000-000000000001", [])).toBe("Codex: 019e061e"); + }); +}); + +function thread(overrides: Partial = {}): Thread { + return { + id: "thread-1", + preview: "", + createdAt: 1, + updatedAt: 1, + name: null, + archived: false, + ...overrides, + }; +} diff --git a/tests/features/chat/ui/toolbar-archive-state.test.tsx b/tests/features/chat/ui/toolbar-archive-state.test.tsx index e2258b52..35dc41eb 100644 --- a/tests/features/chat/ui/toolbar-archive-state.test.tsx +++ b/tests/features/chat/ui/toolbar-archive-state.test.tsx @@ -7,7 +7,7 @@ import type { Thread } from "../../../../src/domain/threads/model"; import { createChatStateStore } from "../../../../src/features/chat/state/reducer"; import { createToolbarPanelActions, type ToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions"; import type { ChatPanelSurface, ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/model"; -import type { ChatThreadActions } from "../../../../src/features/chat/threads/action-context"; +import type { ThreadManagementActions } from "../../../../src/features/chat/threads/thread-management-actions"; import { renderChatPanelShell, unmountChatPanelShell, type ChatPanelShellParts } from "../../../../src/features/chat/ui/shell"; import { installObsidianDomShims } from "../../../support/dom"; @@ -20,7 +20,7 @@ describe("chat toolbar archive confirmation state", () => { const container = document.createElement("div"); const toolbarActions = createToolbarPanelActions({ stateStore: store, - threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActions, + threadActions: { archiveThread: vi.fn() } as unknown as ThreadManagementActions, }); store.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread-1", "Thread one")] }); store.dispatch({ type: "ui/panel-set", panel: "history" }); diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts index e7f7d880..5653b303 100644 --- a/tests/features/threads-view/state.test.ts +++ b/tests/features/threads-view/state.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; +import type { Thread } from "../../../src/domain/threads/model"; import { completedThreadAutoNameState, editingThreadRenameState, generatedThreadAutoNameState, startedThreadAutoNameState, + threadRows, updatedThreadRenameState, } from "../../../src/features/threads-view/state"; @@ -39,4 +41,21 @@ describe("threads view rename state", () => { expect(generated).toEqual({ kind: "generating", draft: "Generated title", originalDraft: "Original draft" }); expect(completedThreadAutoNameState(generated ?? undefined, generating)).toEqual({ kind: "editing", draft: "Generated title" }); }); + + it("initializes rename drafts from normalized explicit thread names", () => { + expect(threadRows([thread({ name: " Saved name ", preview: "Preview" })], [], new Map())[0]?.rename.draft).toBe("Saved name"); + expect(threadRows([thread({ name: " ", preview: "Preview title" })], [], new Map())[0]?.rename.draft).toBe("Preview title"); + }); }); + +function thread(overrides: Partial = {}): Thread { + return { + id: "thread", + preview: "", + name: null, + archived: false, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index b196f4a6..a18c3e15 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -314,7 +314,7 @@ describe("CodexThreadsView", () => { const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); expect(input).not.toBeNull(); if (!input) return; - changeInputValue(input, "Renamed thread"); + changeInputValue(input, " Renamed thread "); view.containerEl.querySelector(".codex-panel-threads__rename-input")?.dispatchEvent(new FocusEvent("blur")); await waitForAsyncWork(() => { diff --git a/tests/runtime/override-commands.test.ts b/tests/runtime/override-commands.test.ts index e5adf291..18cc5af2 100644 --- a/tests/runtime/override-commands.test.ts +++ b/tests/runtime/override-commands.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { collaborationModeLabel, nextCollaborationMode } from "../../src/features/chat/runtime/pending-settings"; +import { collaborationModeLabel } from "../../src/features/chat/runtime/messages"; +import { nextCollaborationMode } from "../../src/features/chat/runtime/pending-settings"; describe("runtime collaboration mode", () => { it("toggles between Default and Plan mode", () => { diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index de028507..9d65a2ac 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -11,7 +11,7 @@ import { compactReasoningEffortLabel, modelOverrideMessage, reasoningEffortOverrideMessage, -} from "../../src/features/chat/runtime/settings-copy"; +} from "../../src/features/chat/runtime/messages"; import { parseModelOverride, parseReasoningEffortOverride } from "../../src/features/chat/conversation/turns/runtime-setting-commands"; import { autoReviewActive, @@ -21,9 +21,7 @@ import { currentServiceTier, fastModeActive, fastRuntimeServiceTierRequestValue, - fastModeLabel, runtimeConfigOrDefault, - serviceTierLabel, supportedReasoningEfforts, } from "../../src/features/chat/runtime/effective"; import type { RuntimeSnapshot } from "../../src/features/chat/runtime/snapshot"; @@ -33,7 +31,13 @@ import { requestedTurnCollaborationModeSettings, serviceTierRequestForThreadStart, } from "../../src/features/chat/runtime/thread-settings-update"; -import { contextSummary, runtimeConfigSections, rateLimitSummary } from "../../src/features/chat/display/status/runtime"; +import { + contextSummary, + fastModeLabel, + runtimeConfigSections, + rateLimitSummary, + serviceTierLabel, +} from "../../src/features/chat/display/status/runtime"; describe("runtime settings", () => { it("parses model overrides", () => { diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 29384444..ad71ea02 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -7,7 +7,8 @@ import type { ThreadRecord } from "../../src/app-server/protocol/thread"; import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata"; import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog"; import { CodexPanelSettingTab } from "../../src/settings/tab"; -import { archivedThreadDisplayTitle, type Thread } from "../../src/domain/threads/model"; +import type { Thread } from "../../src/domain/threads/model"; +import { archivedThreadDisplayTitle } from "../../src/settings/archived-thread-title"; import { notices } from "../mocks/obsidian"; import { deferred } from "../support/async"; import { installObsidianDomShims } from "../support/dom";