Consolidate chat thread operations and display copy boundaries

This commit is contained in:
murashit 2026-06-14 06:04:03 +09:00
parent f8bd73229f
commit a79d517e8a
89 changed files with 1458 additions and 1117 deletions

View file

@ -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()}...`;
}

View file

@ -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,

View file

@ -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<ServerInitialization>;
@ -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<void> {
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 {

View file

@ -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})`;
}

View file

@ -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<void> {
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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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.";
}

View file

@ -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";

View file

@ -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;

View file

@ -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<SlashCommandExecutionResult | undefined>;
};
turnSubmission: {
sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise<void>;
sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata): Promise<void>;
};
connection: {
ensureConnected: () => Promise<void>;
@ -84,7 +85,7 @@ async function interruptTurn(host: ComposerSubmitActionsHost): Promise<void> {
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));
}

View file

@ -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;
}

View file

@ -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.";
}

View file

@ -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";

View file

@ -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;
}

View file

@ -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,

View file

@ -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<void> {
async sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata): Promise<void> {
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<void> {
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 });

View file

@ -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;

View file

@ -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<string, ForkCandidate>();
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<string>();
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;
}

View file

@ -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<string, ForkCandidate>();
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,
);
}

View file

@ -14,6 +14,13 @@ export function fileMentionsFromInput(input: readonly CodexInputItem[]): Display
return mentions;
}
export function normalizeProposedPlanMarkdown(text: string): string {
return text
.replace(/^\s*<proposed_plan>\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;

View file

@ -1,6 +0,0 @@
export function normalizeProposedPlanMarkdown(text: string): string {
return text
.replace(/^\s*<proposed_plan>\s*\n?/i, "")
.replace(/\n?\s*<\/proposed_plan>\s*$/i, "")
.trim();
}

View file

@ -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<DisplayKind, "tool" | "hook" | "reasoning">;
}): 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),
};
}

View file

@ -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";
}

View file

@ -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);

View file

@ -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";

View file

@ -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,

View file

@ -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;

View file

@ -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";

View file

@ -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;

View file

@ -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);

View file

@ -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<void> {
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);

View file

@ -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 {

View file

@ -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<string, string>): 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());
}
}

View file

@ -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<M extends ServerNotification["method"]> = (
notification: Extract<ServerNotification, { method: M }>,
@ -105,7 +108,7 @@ const DIAGNOSTIC_STATUS_PLANNERS = {
} satisfies ServerNotificationPlannerMap<DiagnosticStatusNotificationMethod>;
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),
});
}

View file

@ -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<typeof permissionRows>[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> = Request extends ApprovalRequest
? {
requestId: RequestId;
@ -96,13 +96,6 @@ type PendingApprovalFor<Request extends ApprovalRequest> = Request extends Appro
: never;
export type PendingApproval = PendingApprovalFor<ApprovalRequest>;
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;
}

View file

@ -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";
}

View file

@ -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<TurnCollaborationModeWarning, string> = {
"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];
}

View file

@ -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<T>(setting: PendingRuntimeSetting<T>): string {
if (setting.kind === "set") return String(setting.value);
if (setting.kind === "resetToConfig") return "(reset to config)";
return "(none)";
}

View file

@ -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<TurnCollaborationModeWarning, string> = {
"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();
}

View file

@ -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;
}

View file

@ -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({

View file

@ -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(

View file

@ -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<string>;
}
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<ChatMessageStreamState, "stable
return state.stableItems.length === 0 && (!state.activeSegment || state.activeSegment.items.length === 0);
}
export function messageStreamTurnIds(state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">): string[] {
return orderedTurnIds(messageStreamDisplayItems(state));
}
export function messageStreamTurnsAfterTurnId(
state: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">,
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<ChatMessageStreamState, "stableItems" | "activeSegment">,
): 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<string, string>, turnId: string
return next;
}
function orderedTurnIds(items: readonly DisplayItem[]): string[] {
const turnIds: string[] = [];
const seen = new Set<string>();
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<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };

View file

@ -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) },
});
}

View file

@ -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<void>;
currentClient: () => AppServerClient | null;
addSystemMessage: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
refreshSharedThreadListFromOpenSurface: () => void;
}
export interface ChatThreadActions {
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<boolean>;
rollbackThread: (threadId: string) => Promise<void>;
}
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;
}

View file

@ -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<void>;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
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),
};
}

View file

@ -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<void> {
if (await archiveThreadOnServer(host, threadId, saveMarkdown)) {
host.notifyThreadArchived(threadId);
}
}
export async function archiveThreadOnServer(
host: ChatThreadActionsHost,
threadId: string,
saveMarkdown = host.settings().archiveExportEnabled,
): Promise<boolean> {
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;
}
}

View file

@ -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<void> {
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));
}
}

View file

@ -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,

View file

@ -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<void> {
return forkThreadFromTurn(host, threadId, null, false);
}
export async function forkThreadFromTurn(
host: ChatThreadActionsHost,
threadId: string,
turnId: string | null,
archiveSource: boolean,
): Promise<void> {
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));
}
}

View file

@ -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<boolean> {
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;

View file

@ -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.";
}

View file

@ -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,

View file

@ -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<void>;
currentClient: () => AppServerClient | null;
addSystemMessage: (text: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
}
export async function renameThread(host: ThreadRenameActionsHost, threadId: string, value: string): Promise<boolean> {
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<boolean> {
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;
}
}

View file

@ -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<string | null>;
}
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<void> {
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;

View file

@ -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();
}

View file

@ -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<void> {
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) {

View file

@ -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<void> {
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.");
}
}

View file

@ -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<void> => {
if (!canSwitchToThread(host.stateStore.getState(), threadId)) {
host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
host.addSystemMessage(finishBeforeSwitchingThreadsMessage());
return;
}

View file

@ -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<void>;
currentClient: () => AppServerClient | null;
addSystemMessage: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
refreshSharedThreadListFromOpenSurface: () => void;
}
export interface ThreadManagementActions {
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<boolean>;
rollbackThread: (threadId: string) => Promise<void>;
}
export interface ThreadManagementActionsContext {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
plugin: CodexChatHost;
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
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<void> {
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<void> {
if (await archiveThreadOnServer(host, threadId, saveMarkdown)) {
host.notifyThreadArchived(threadId);
}
}
async function archiveThreadOnServer(
host: ThreadManagementActionsHost,
threadId: string,
saveMarkdown = host.settings().archiveExportEnabled,
): Promise<boolean> {
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<void> {
return forkThreadFromTurn(host, threadId, null, false);
}
export async function forkThreadFromTurn(
host: ThreadManagementActionsHost,
threadId: string,
turnId: string | null,
archiveSource: boolean,
): Promise<void> {
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<boolean> {
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<boolean> {
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<void> {
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;
}

View file

@ -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;

View file

@ -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";
}

View file

@ -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,

View file

@ -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<void> {
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;

View file

@ -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: {

View file

@ -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()}...`;
}

View file

@ -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;

View file

@ -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,

View file

@ -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この続きです");
});

View file

@ -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" });

View file

@ -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> = {}): 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 };
}

View file

@ -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 {

View file

@ -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<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}

View file

@ -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<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}

View file

@ -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");

View file

@ -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<ServerNotification, { method: "thread/name/updated" }>);
expect(state.threadList.listedThreads[0]?.name).toBe("Codex Panel自動命名");

View file

@ -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<T>(value: T | null | undefined): T {

View file

@ -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",
},
];
}

View file

@ -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<ConstructorParameters<typeof RenameController>[0]> = {},
): ConstructorParameters<typeof RenameController>[0] & { controller: RenameController } {
overrides: Partial<ConstructorParameters<typeof ThreadRenameEditorController>[0]> = {},
): ConstructorParameters<typeof ThreadRenameEditorController>[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<typeof RenameController>[0];
return { ...host, controller: new RenameController(host) };
} satisfies ConstructorParameters<typeof ThreadRenameEditorController>[0];
return { ...host, controller: new ThreadRenameEditorController(host) };
}
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {

View file

@ -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<typeof vi.fn<ArchiveExportAdapter["write"]>>;
};
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> = {}): MockArchiveExportAdapter {

View file

@ -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> = {}): Thread {
return {
id: "thread-1",
preview: "",
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
...overrides,
};
}

View file

@ -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" });

View file

@ -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> = {}): Thread {
return {
id: "thread",
preview: "",
name: null,
archived: false,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}

View file

@ -314,7 +314,7 @@ describe("CodexThreadsView", () => {
const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
expect(input).not.toBeNull();
if (!input) return;
changeInputValue(input, "Renamed thread");
changeInputValue(input, " Renamed thread ");
view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.dispatchEvent(new FocusEvent("blur"));
await waitForAsyncWork(() => {

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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";