mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Consolidate chat thread operations and display copy boundaries
This commit is contained in:
parent
f8bd73229f
commit
a79d517e8a
89 changed files with 1458 additions and 1117 deletions
|
|
@ -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()}...`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
9
src/features/chat/connection/messages.ts
Normal file
9
src/features/chat/connection/messages.ts
Normal 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})`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
151
src/features/chat/conversation/pending-requests/approval-view.ts
Normal file
151
src/features/chat/conversation/pending-requests/approval-view.ts
Normal 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;
|
||||
}
|
||||
19
src/features/chat/conversation/pending-requests/messages.ts
Normal file
19
src/features/chat/conversation/pending-requests/messages.ts
Normal 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.";
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
27
src/features/chat/conversation/turns/messages.ts
Normal file
27
src/features/chat/conversation/turns/messages.ts
Normal 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.";
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
38
src/features/chat/display/item-selectors.ts
Normal file
38
src/features/chat/display/item-selectors.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
82
src/features/chat/display/items/streaming.ts
Normal file
82
src/features/chat/display/items/streaming.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
67
src/features/chat/runtime/messages.ts
Normal file
67
src/features/chat/runtime/messages.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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)";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
65
src/features/chat/threads/messages.ts
Normal file
65
src/features/chat/threads/messages.ts
Normal 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.";
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
335
src/features/chat/threads/thread-management-actions.ts
Normal file
335
src/features/chat/threads/thread-management-actions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
10
src/features/chat/threads/title-display.ts
Normal file
10
src/features/chat/threads/title-display.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
19
src/settings/archived-thread-title.ts
Normal file
19
src/settings/archived-thread-title.ts
Normal 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()}...`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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この続きです");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
82
tests/features/chat/display/item-selectors.test.ts
Normal file
82
tests/features/chat/display/item-selectors.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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自動命名");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
92
tests/features/chat/state/message-stream-selectors.test.ts
Normal file
92
tests/features/chat/state/message-stream-selectors.test.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 {
|
||||
25
tests/features/chat/threads/title-display.test.ts
Normal file
25
tests/features/chat/threads/title-display.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue