mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Centralize thread rename and archive catalog mutations
This commit is contained in:
parent
ef61280b7d
commit
c71024e11c
27 changed files with 255 additions and 268 deletions
|
|
@ -40,8 +40,8 @@ export interface ChatInboundControllerActions {
|
|||
refreshSkills: (forceReload?: boolean) => void;
|
||||
publishAppServerMetadata: () => void;
|
||||
maybeNameThread: (threadId: string, turnId: string, completedSummary: ThreadConversationSummary | null) => void;
|
||||
notifyThreadArchived: (threadId: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string | null) => void;
|
||||
applyThreadArchived: (threadId: string) => void;
|
||||
applyThreadRenamed: (threadId: string, name: string | null) => void;
|
||||
recordMcpStartupStatus: (name: string, status: McpServerStartupStatus, message: string | null) => void;
|
||||
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
|
||||
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
|
||||
|
|
@ -199,11 +199,11 @@ export class ChatInboundController {
|
|||
case "maybe-name-thread":
|
||||
this.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedSummary);
|
||||
return;
|
||||
case "notify-thread-archived":
|
||||
this.actions.notifyThreadArchived(effect.threadId);
|
||||
case "apply-thread-archived":
|
||||
this.actions.applyThreadArchived(effect.threadId);
|
||||
return;
|
||||
case "notify-thread-renamed":
|
||||
this.actions.notifyThreadRenamed(effect.threadId, effect.name);
|
||||
case "apply-thread-renamed":
|
||||
this.actions.applyThreadRenamed(effect.threadId, effect.name);
|
||||
return;
|
||||
case "record-mcp-startup-status":
|
||||
this.actions.recordMcpStartupStatus(effect.name, effect.status, effect.message);
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ export type ChatNotificationEffect =
|
|||
| { type: "refresh-skills"; forceReload: boolean }
|
||||
| { type: "publish-app-server-metadata" }
|
||||
| { type: "maybe-name-thread"; threadId: string; turnId: string; completedSummary: ThreadConversationSummary | null }
|
||||
| { type: "notify-thread-archived"; threadId: string }
|
||||
| { type: "notify-thread-renamed"; threadId: string; name: string | null }
|
||||
| { type: "apply-thread-archived"; threadId: string }
|
||||
| { type: "apply-thread-renamed"; threadId: string; name: string | null }
|
||||
| {
|
||||
type: "record-mcp-startup-status";
|
||||
name: string;
|
||||
|
|
@ -248,29 +248,16 @@ const THREAD_LIFECYCLE_PLANNERS = {
|
|||
}
|
||||
return EMPTY_PLAN;
|
||||
},
|
||||
"thread/archived": (state, notification) => ({
|
||||
actions: [
|
||||
{
|
||||
type: "thread-list/applied",
|
||||
threads: state.threadList.listedThreads.filter((thread) => thread.id !== notification.params.threadId),
|
||||
},
|
||||
...(state.activeThread.id === notification.params.threadId ? ([{ type: "active-thread/cleared" }] satisfies ChatAction[]) : []),
|
||||
],
|
||||
effects: [{ type: "notify-thread-archived", threadId: notification.params.threadId }],
|
||||
"thread/archived": (_state, notification) => ({
|
||||
actions: [],
|
||||
effects: [{ type: "apply-thread-archived", threadId: notification.params.threadId }],
|
||||
}),
|
||||
"thread/unarchived": () => ({ actions: [], effects: [{ type: "refresh-threads" }] }),
|
||||
"thread/name/updated": (state, notification) => {
|
||||
"thread/name/updated": (_state, notification) => {
|
||||
const name = normalizeExplicitThreadName(notification.params.threadName);
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
type: "thread-list/applied",
|
||||
threads: state.threadList.listedThreads.map((thread) =>
|
||||
thread.id === notification.params.threadId ? { ...thread, name } : thread,
|
||||
),
|
||||
},
|
||||
],
|
||||
effects: [{ type: "notify-thread-renamed", threadId: notification.params.threadId, name }],
|
||||
actions: [],
|
||||
effects: [{ type: "apply-thread-renamed", threadId: notification.params.threadId, name }],
|
||||
};
|
||||
},
|
||||
"thread/settings/updated": (state, notification) => {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export interface WorkspacePanels {
|
|||
}
|
||||
|
||||
export interface ThreadCatalogFacade {
|
||||
notifyThreadArchived(threadId: string): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
archiveThreadInCatalog(threadId: string): void;
|
||||
renameThreadInCatalog(threadId: string, name: string | null): void;
|
||||
refreshThreadsViewLiveState(): void;
|
||||
refreshFromOpenSurface(): void;
|
||||
applyThreads(threads: readonly Thread[]): void;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { ThreadConversationSummary } from "../../../../domain/threads/trans
|
|||
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
import type { ThreadOperations } from "../../../threads/thread-operations";
|
||||
import type { ThreadTitleService } from "../../../threads/thread-title-service";
|
||||
import type { ChatAction, ChatState } from "../state/root-reducer";
|
||||
import type { ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
|
||||
export interface AutoTitleControllerHost {
|
||||
|
|
@ -23,10 +23,6 @@ export class AutoTitleController {
|
|||
return this.host.stateStore.getState();
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
resetThreadTurnPresence(hadTurns: boolean): void {
|
||||
this.activeThreadHadTurns = hadTurns;
|
||||
}
|
||||
|
|
@ -51,15 +47,10 @@ export class AutoTitleController {
|
|||
const title = await this.generateTitle(context);
|
||||
if (!title || !this.threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
|
||||
const result = await this.host.operations.renameThread(threadId, title, {
|
||||
const renamed = await this.host.operations.renameThread(threadId, title, {
|
||||
shouldPublish: () => this.threadCanReceiveGeneratedTitle(threadId),
|
||||
});
|
||||
if (!result) return;
|
||||
if (!this.threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
this.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: this.state.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: result.name } : thread)),
|
||||
});
|
||||
if (!renamed) return;
|
||||
} catch {
|
||||
// Auto-title is best-effort metadata. Leave the thread preview untouched on failure.
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { RestorationController } from "./restoration-controller";
|
||||
import { activeThreadId, listedThreads } from "../state/selectors";
|
||||
import { activeThreadId } from "../state/selectors";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
|
||||
export interface IdentitySyncHost {
|
||||
|
|
@ -15,8 +15,8 @@ export interface IdentitySyncHost {
|
|||
|
||||
export interface IdentitySync {
|
||||
clearActiveThreadContext: () => void;
|
||||
notifyThreadArchived: (threadId: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string | null) => void;
|
||||
applyThreadArchived: (threadId: string) => void;
|
||||
applyThreadRenamed: (threadId: string, name: string | null) => void;
|
||||
}
|
||||
|
||||
export function createIdentitySync(host: IdentitySyncHost): IdentitySync {
|
||||
|
|
@ -24,11 +24,11 @@ export function createIdentitySync(host: IdentitySyncHost): IdentitySync {
|
|||
clearActiveThreadContext: () => {
|
||||
clearActiveThreadContext(host);
|
||||
},
|
||||
notifyThreadArchived: (threadId) => {
|
||||
notifyThreadArchived(host, threadId);
|
||||
applyThreadArchived: (threadId) => {
|
||||
applyThreadArchived(host, threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
notifyThreadRenamed(host, threadId, name);
|
||||
applyThreadRenamed: (threadId, name) => {
|
||||
applyThreadRenamed(host, threadId, name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -43,19 +43,13 @@ function clearActiveThreadContext(host: IdentitySyncHost): void {
|
|||
host.refreshLiveState();
|
||||
}
|
||||
|
||||
function notifyThreadArchived(host: IdentitySyncHost, threadId: string): void {
|
||||
function applyThreadArchived(host: IdentitySyncHost, threadId: string): void {
|
||||
if (activeThreadId(host.stateStore.getState()) !== threadId) return;
|
||||
clearActiveThreadContext(host);
|
||||
}
|
||||
|
||||
function notifyThreadRenamed(host: IdentitySyncHost, threadId: string, name: string | null): void {
|
||||
function applyThreadRenamed(host: IdentitySyncHost, threadId: string, name: string | null): void {
|
||||
let changed = false;
|
||||
const renamedThreads = listedThreads(host.stateStore.getState()).map((thread) => {
|
||||
if (thread.id !== threadId) return thread;
|
||||
changed = true;
|
||||
return { ...thread, name };
|
||||
});
|
||||
host.stateStore.dispatch({ type: "thread-list/applied", threads: renamedThreads });
|
||||
const restoredThread = host.restoration.placeholder();
|
||||
if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) {
|
||||
host.restoration.rename(threadId, name);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { getThreadTitle } from "../../../../domain/threads/model";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import { threadRenameFromValue } from "../../../../app-server/services/thread-rename";
|
||||
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
import type { ThreadOperations } from "../../../threads/thread-operations";
|
||||
import type { ThreadTitleService } from "../../../threads/thread-title-service";
|
||||
|
|
@ -74,17 +73,17 @@ export class ThreadRenameEditorController {
|
|||
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 rename = threadRenameFromValue(value);
|
||||
if (!rename) {
|
||||
this.cancel(threadId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.host.ensureConnected();
|
||||
if (this.renameState !== editingState) return;
|
||||
|
||||
if (await this.host.operations.renameThread(threadId, rename.name)) {
|
||||
if (this.renameState === editingState) this.clear();
|
||||
const result = await this.host.operations.renameThread(threadId, value);
|
||||
if (!result) {
|
||||
if (this.renameState === editingState) this.cancel(threadId);
|
||||
return;
|
||||
}
|
||||
if (this.renameState === editingState) {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import { rollbackThread as rollbackThreadOnAppServer } from "../../../../app-server/services/threads";
|
||||
import { inheritedForkThreadName } from "../../../../domain/threads/model";
|
||||
import { threadRenameFromValue } from "../../../../app-server/services/thread-rename";
|
||||
import type { ThreadOperations } from "../../../threads/thread-operations";
|
||||
import {
|
||||
archivedSourceOpenForkFailedMessage,
|
||||
|
|
@ -127,9 +126,7 @@ async function forkThreadFromTurn(
|
|||
if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return;
|
||||
if (sourceName) {
|
||||
try {
|
||||
const rename = threadRenameFromValue(sourceName);
|
||||
if (!rename) return;
|
||||
await host.operations.renameThread(forkedThreadId, rename.name);
|
||||
if (!(await host.operations.renameThread(forkedThreadId, sourceName))) return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
host.addSystemMessage(forkNameCopyFailedMessage(forkedThreadId, message));
|
||||
|
|
@ -157,17 +154,9 @@ async function forkThreadFromTurn(
|
|||
}
|
||||
|
||||
async function renameThread(host: ThreadManagementActionsHost, threadId: string, value: string): Promise<boolean> {
|
||||
const rename = threadRenameFromValue(value);
|
||||
if (!rename) return false;
|
||||
|
||||
try {
|
||||
const result = await host.operations.renameThread(threadId, rename.name);
|
||||
const result = await host.operations.renameThread(threadId, value);
|
||||
if (!result) return false;
|
||||
const { name } = result;
|
||||
host.stateStore.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: host.stateStore.getState().threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export interface ChatConnectionBundleContext {
|
|||
deferredTasks: ChatViewDeferredTasks;
|
||||
threadCatalog: Pick<
|
||||
ThreadCatalogFacade,
|
||||
"applyThreads" | "publishAppServerMetadata" | "refreshThreads" | "notifyThreadArchived" | "notifyThreadRenamed"
|
||||
"applyThreads" | "publishAppServerMetadata" | "refreshThreads" | "archiveThreadInCatalog" | "renameThreadInCatalog"
|
||||
>;
|
||||
goalSync: ThreadGoalSyncActions;
|
||||
autoTitle: AutoTitleController;
|
||||
|
|
@ -108,11 +108,11 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext)
|
|||
maybeNameThread: (threadId, turnId, completedSummary) => {
|
||||
autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
|
||||
},
|
||||
notifyThreadArchived: (threadId) => {
|
||||
threadCatalog.notifyThreadArchived(threadId);
|
||||
applyThreadArchived: (threadId) => {
|
||||
threadCatalog.archiveThreadInCatalog(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
applyThreadRenamed: (threadId, name) => {
|
||||
threadCatalog.renameThreadInCatalog(threadId, name);
|
||||
},
|
||||
recordMcpStartupStatus: (name, mcpStatus, message) => {
|
||||
serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message);
|
||||
|
|
|
|||
|
|
@ -143,14 +143,11 @@ export function createChatPanelRuntime(context: ChatPanelRuntimeContext): ChatPa
|
|||
},
|
||||
archiveAdapter: environment.obsidian.archiveAdapter,
|
||||
catalog: {
|
||||
notifyThreadArchived: (threadId) => {
|
||||
environment.plugin.threadCatalog.notifyThreadArchived(threadId);
|
||||
archiveThreadInCatalog: (threadId) => {
|
||||
environment.plugin.threadCatalog.archiveThreadInCatalog(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
environment.plugin.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshFromOpenSurface: () => {
|
||||
environment.plugin.threadCatalog.refreshFromOpenSurface();
|
||||
renameThreadInCatalog: (threadId, name) => {
|
||||
environment.plugin.threadCatalog.renameThreadInCatalog(threadId, name);
|
||||
},
|
||||
},
|
||||
notice: (text) => {
|
||||
|
|
|
|||
|
|
@ -194,12 +194,12 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
this.parts.composer.controller.focus();
|
||||
}
|
||||
|
||||
notifyThreadArchived(threadId: string): void {
|
||||
this.parts.thread.identity.notifyThreadArchived(threadId);
|
||||
applyThreadArchived(threadId: string): void {
|
||||
this.parts.thread.identity.applyThreadArchived(threadId);
|
||||
}
|
||||
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void {
|
||||
this.parts.thread.identity.notifyThreadRenamed(threadId, name);
|
||||
applyThreadRenamed(threadId: string, name: string | null): void {
|
||||
this.parts.thread.identity.applyThreadRenamed(threadId, name);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ export interface ChatSurfaceHandle {
|
|||
openThread(threadId: string): Promise<void>;
|
||||
focusThread(threadId?: string | null): Promise<void>;
|
||||
focusComposer(): void;
|
||||
notifyThreadArchived(threadId: string): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
applyThreadArchived(threadId: string): void;
|
||||
applyThreadRenamed(threadId: string, name: string | null): void;
|
||||
setComposerText(text: string): void;
|
||||
connect(): Promise<void>;
|
||||
startNewThread(): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { Thread } from "../../domain/threads/model";
|
|||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
|
||||
import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown";
|
||||
import { threadRenameFromValue } from "../../app-server/services/thread-rename";
|
||||
import { ThreadOperations } from "../threads/thread-operations";
|
||||
import { ThreadTitleService } from "../threads/thread-title-service";
|
||||
import { renderThreadsView, unmountThreadsView } from "./renderer";
|
||||
|
|
@ -36,8 +35,8 @@ export interface CodexThreadsHost {
|
|||
readonly settings: CodexPanelSettings;
|
||||
readonly vaultPath: string;
|
||||
readonly threadCatalog: {
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
renameThreadInCatalog(threadId: string, name: string | null): void;
|
||||
refreshFromOpenSurface(): void;
|
||||
refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
|
||||
cachedThreads(): readonly Thread[] | null;
|
||||
|
|
@ -89,14 +88,11 @@ export class CodexThreadsSession {
|
|||
},
|
||||
archiveAdapter: () => this.environment.archiveAdapter(),
|
||||
catalog: {
|
||||
notifyThreadArchived: (threadId, options) => {
|
||||
this.host.threadCatalog.notifyThreadArchived(threadId, options);
|
||||
archiveThreadInCatalog: (threadId, options) => {
|
||||
this.host.threadCatalog.archiveThreadInCatalog(threadId, options);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.host.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshFromOpenSurface: () => {
|
||||
this.host.threadCatalog.refreshFromOpenSurface();
|
||||
renameThreadInCatalog: (threadId, name) => {
|
||||
this.host.threadCatalog.renameThreadInCatalog(threadId, name);
|
||||
},
|
||||
},
|
||||
notice: (message) => {
|
||||
|
|
@ -329,16 +325,14 @@ 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 rename = threadRenameFromValue(value);
|
||||
if (!rename) {
|
||||
this.cancelRename(threadId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.renameStates.get(threadId) !== editingState) return;
|
||||
const result = await this.operations.renameThread(threadId, rename.name);
|
||||
if (!result) return;
|
||||
const result = await this.operations.renameThread(threadId, value);
|
||||
if (!result) {
|
||||
this.cancelRename(threadId);
|
||||
return;
|
||||
}
|
||||
this.renameStates.delete(threadId);
|
||||
} catch (error) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@ export interface ThreadOperationsHost {
|
|||
};
|
||||
archiveAdapter(): ArchiveExportAdapter;
|
||||
catalog: {
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
refreshFromOpenSurface(): void;
|
||||
archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
renameThreadInCatalog(threadId: string, name: string | null): void;
|
||||
};
|
||||
notice(message: string): void;
|
||||
}
|
||||
|
|
@ -27,10 +26,6 @@ export interface ArchiveThreadOptions {
|
|||
closeOpenPanels?: boolean;
|
||||
}
|
||||
|
||||
export interface RenameThreadResult {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RenameThreadOptions {
|
||||
shouldPublish?: () => boolean;
|
||||
}
|
||||
|
|
@ -38,27 +33,23 @@ export interface RenameThreadOptions {
|
|||
export class ThreadOperations {
|
||||
constructor(private readonly host: ThreadOperationsHost) {}
|
||||
|
||||
async renameThread(threadId: string, value: string, options: RenameThreadOptions = {}): Promise<RenameThreadResult | null> {
|
||||
async renameThread(threadId: string, value: string, options: RenameThreadOptions = {}): Promise<boolean> {
|
||||
const rename = threadRenameFromValue(value);
|
||||
if (!rename) return null;
|
||||
if (!rename) return false;
|
||||
|
||||
await this.host.connection.ensureConnected();
|
||||
return this.renameConnectedThread(threadId, rename, options);
|
||||
}
|
||||
|
||||
private async renameConnectedThread(
|
||||
threadId: string,
|
||||
rename: ThreadRename,
|
||||
options: RenameThreadOptions = {},
|
||||
): Promise<RenameThreadResult | null> {
|
||||
private async renameConnectedThread(threadId: string, rename: ThreadRename, options: RenameThreadOptions = {}): Promise<boolean> {
|
||||
const client = this.host.connection.currentClient();
|
||||
if (!client) return null;
|
||||
if (!client) return false;
|
||||
|
||||
const result = await renameThreadOnAppServer(client, threadId, rename);
|
||||
if (options.shouldPublish?.() ?? true) {
|
||||
this.host.catalog.notifyThreadRenamed(threadId, result.name);
|
||||
this.host.catalog.renameThreadInCatalog(threadId, result.name);
|
||||
}
|
||||
return { name: result.name };
|
||||
return true;
|
||||
}
|
||||
|
||||
async archiveThread(threadId: string, options: ArchiveThreadOptions = {}): Promise<ArchiveThreadResult | null> {
|
||||
|
|
@ -77,7 +68,7 @@ export class ThreadOperations {
|
|||
this.host.notice(`Saved archived thread to ${result.exportedPath}.`);
|
||||
}
|
||||
const notificationOptions = options.closeOpenPanels === undefined ? undefined : { closeOpenPanels: options.closeOpenPanels };
|
||||
this.host.catalog.notifyThreadArchived(threadId, notificationOptions);
|
||||
this.host.catalog.archiveThreadInCatalog(threadId, notificationOptions);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,11 +96,11 @@ export class CodexPanelRuntime {
|
|||
openTurnDiff: (state) => this.openTurnDiff(state),
|
||||
},
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: (threadId) => {
|
||||
this.threadCatalog.notifyThreadArchived(threadId);
|
||||
archiveThreadInCatalog: (threadId) => {
|
||||
this.threadCatalog.archiveThreadInCatalog(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
renameThreadInCatalog: (threadId, name) => {
|
||||
this.threadCatalog.renameThreadInCatalog(threadId, name);
|
||||
},
|
||||
refreshThreadsViewLiveState: () => {
|
||||
this.threadCatalog.refreshThreadsViewLiveState();
|
||||
|
|
@ -126,11 +126,11 @@ export class CodexPanelRuntime {
|
|||
settings: this.options.settingsRef.settings,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: (threadId, options) => {
|
||||
this.threadCatalog.notifyThreadArchived(threadId, options);
|
||||
archiveThreadInCatalog: (threadId, options) => {
|
||||
this.threadCatalog.archiveThreadInCatalog(threadId, options);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
renameThreadInCatalog: (threadId, name) => {
|
||||
this.threadCatalog.renameThreadInCatalog(threadId, name);
|
||||
},
|
||||
refreshFromOpenSurface: () => {
|
||||
this.threadCatalog.refreshFromOpenSurface();
|
||||
|
|
|
|||
|
|
@ -48,21 +48,33 @@ export class SharedThreadCatalog {
|
|||
}
|
||||
|
||||
refreshFromOpenSurface(): void {
|
||||
this.options.surfaces.refreshSharedThreadListFromOpenSurface();
|
||||
this.invalidateThreadsFromOpenSurface();
|
||||
}
|
||||
|
||||
invalidateThreadsFromOpenSurface(): void {
|
||||
this.options.surfaces.invalidateThreadsFromOpenSurface();
|
||||
}
|
||||
|
||||
renameThreadInCatalog(threadId: string, name: string | null): void {
|
||||
const threads = this.cachedThreads();
|
||||
if (threads) {
|
||||
this.applyThreads(threads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)));
|
||||
}
|
||||
this.options.surfaces.applyThreadRenamed(threadId, name);
|
||||
}
|
||||
|
||||
archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void {
|
||||
const threads = this.cachedThreads();
|
||||
if (threads) {
|
||||
this.applyThreads(threads.filter((thread) => thread.id !== threadId));
|
||||
}
|
||||
this.options.surfaces.applyThreadArchived(threadId, options);
|
||||
}
|
||||
|
||||
refreshThreadsViewLiveState(): void {
|
||||
this.options.surfaces.refreshThreadsViewLiveState();
|
||||
}
|
||||
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void {
|
||||
this.options.surfaces.notifyThreadArchived(threadId, options);
|
||||
}
|
||||
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void {
|
||||
this.options.surfaces.notifyThreadRenamed(threadId, name);
|
||||
}
|
||||
|
||||
private context(): SharedAppServerCacheContext {
|
||||
return this.options.context();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ export interface ThreadSurfaceActionsOptions {
|
|||
|
||||
export interface ThreadSurfaceActions {
|
||||
refreshOpenViews(): void;
|
||||
refreshSharedThreadListFromOpenSurface(): void;
|
||||
invalidateThreadsFromOpenSurface(): void;
|
||||
applyThreadListSnapshot(threads: readonly Thread[]): void;
|
||||
applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
applyThreadRenamed(threadId: string, name: string | null): void;
|
||||
publishAppServerMetadata(metadata: SharedServerMetadata): void;
|
||||
publishModels(models: readonly ModelMetadata[]): void;
|
||||
refreshThreadsViewLiveState(): void;
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
}
|
||||
|
||||
export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions): ThreadSurfaceActions {
|
||||
|
|
@ -29,7 +29,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)
|
||||
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
|
||||
|
||||
const refreshSharedThreadListFromOpenSurface = (): void => {
|
||||
const invalidateThreadsFromOpenSurface = (): void => {
|
||||
const chatView = options.panels.panelViews().find((view) => view.surface.openPanelSnapshot().connected);
|
||||
if (chatView) {
|
||||
void chatView.surface.refreshSharedThreadList();
|
||||
|
|
@ -47,7 +47,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
}
|
||||
},
|
||||
|
||||
refreshSharedThreadListFromOpenSurface,
|
||||
invalidateThreadsFromOpenSurface,
|
||||
|
||||
applyThreadListSnapshot(threads: readonly Thread[]): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
|
|
@ -58,6 +58,22 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
}
|
||||
},
|
||||
|
||||
applyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void {
|
||||
const leavesToClose = archiveOptions.closeOpenPanels ? options.panels.panelLeavesForThread(threadId) : [];
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.surface.applyThreadArchived(threadId);
|
||||
}
|
||||
for (const leaf of leavesToClose) {
|
||||
leaf.detach();
|
||||
}
|
||||
},
|
||||
|
||||
applyThreadRenamed(threadId: string, name: string | null): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.surface.applyThreadRenamed(threadId, name);
|
||||
}
|
||||
},
|
||||
|
||||
publishAppServerMetadata(metadata: SharedServerMetadata): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.surface.applyAppServerMetadataSnapshot(metadata);
|
||||
|
|
@ -75,23 +91,5 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
view.refreshLiveState();
|
||||
}
|
||||
},
|
||||
|
||||
notifyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void {
|
||||
const leavesToClose = archiveOptions.closeOpenPanels ? options.panels.panelLeavesForThread(threadId) : [];
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.surface.notifyThreadArchived(threadId);
|
||||
}
|
||||
for (const leaf of leavesToClose) {
|
||||
leaf.detach();
|
||||
}
|
||||
refreshSharedThreadListFromOpenSurface();
|
||||
},
|
||||
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.surface.notifyThreadRenamed(threadId, name);
|
||||
}
|
||||
refreshSharedThreadListFromOpenSurface();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { ChatInboundController } from "../../../../../src/features/chat/app-server/inbound/controller";
|
||||
import { attachHookRunsToTurn } from "../../../../../src/features/chat/domain/message-stream/updates";
|
||||
import {
|
||||
activeTurnId,
|
||||
chatReducer,
|
||||
chatTurnBusy,
|
||||
createChatState,
|
||||
pendingTurnStart,
|
||||
type ChatAction,
|
||||
|
|
@ -29,8 +27,8 @@ function controllerForState(
|
|||
refreshSkills: vi.fn(),
|
||||
publishAppServerMetadata: vi.fn(),
|
||||
maybeNameThread: vi.fn(),
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
applyThreadArchived: vi.fn(),
|
||||
applyThreadRenamed: vi.fn(),
|
||||
recordMcpStartupStatus: vi.fn(),
|
||||
respondToServerRequest: vi.fn(() => true),
|
||||
rejectServerRequest: vi.fn(() => true),
|
||||
|
|
@ -1038,29 +1036,16 @@ describe("ChatInboundController", () => {
|
|||
},
|
||||
];
|
||||
state.requests.userInputDrafts = new Map([["20:note", "draft"]]);
|
||||
const notifyThreadArchived = vi.fn();
|
||||
const controller = controllerForState(state, { notifyThreadArchived });
|
||||
const applyThreadArchived = vi.fn();
|
||||
const controller = controllerForState(state, { applyThreadArchived });
|
||||
|
||||
controller.handleNotification({
|
||||
method: "thread/archived",
|
||||
params: { threadId: "thread-active" },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/archived" }>);
|
||||
|
||||
expect(state.activeThread.id).toBeNull();
|
||||
expect(activeTurnId(state)).toBeNull();
|
||||
expect(state.runtime.activeModel).toBeNull();
|
||||
expect(state.runtime.activeServiceTier).toBeNull();
|
||||
expect(state.activeThread.tokenUsage).toBeNull();
|
||||
expect(state.messageStream.historyCursor).toBeNull();
|
||||
expect(state.messageStream.loadingHistory).toBe(false);
|
||||
expect(chatStateMessageStreamItems(state)).toEqual([]);
|
||||
expect(state.messageStream.turnDiffs.size).toBe(0);
|
||||
expect(state.composer.draft).toBe("");
|
||||
expect(chatTurnBusy(state)).toBe(false);
|
||||
expect(state.requests.approvals).toEqual([]);
|
||||
expect(state.requests.pendingUserInputs).toEqual([]);
|
||||
expect(state.requests.userInputDrafts.size).toBe(0);
|
||||
expect(notifyThreadArchived).toHaveBeenCalledWith("thread-active");
|
||||
expect(state.activeThread.id).toBe("thread-active");
|
||||
expect(applyThreadArchived).toHaveBeenCalledWith("thread-active");
|
||||
});
|
||||
|
||||
it("does not replace the active cwd from unrelated thread-started notifications", () => {
|
||||
|
|
@ -1323,20 +1308,20 @@ describe("ChatInboundController", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("updates listed thread names from thread name notifications", () => {
|
||||
it("routes thread name notifications through catalog mutation effects", () => {
|
||||
const state = createChatState();
|
||||
state.activeThread.id = "thread-active";
|
||||
state.threadList.listedThreads = [panelThread("thread-active")];
|
||||
const notifyThreadRenamed = vi.fn();
|
||||
const controller = controllerForState(state, { notifyThreadRenamed });
|
||||
const applyThreadRenamed = vi.fn();
|
||||
const controller = controllerForState(state, { applyThreadRenamed });
|
||||
|
||||
controller.handleNotification({
|
||||
method: "thread/name/updated",
|
||||
params: { threadId: "thread-active", threadName: " Codex Panel自動命名 " },
|
||||
} satisfies Extract<ServerNotification, { method: "thread/name/updated" }>);
|
||||
|
||||
expect(state.threadList.listedThreads[0]?.name).toBe("Codex Panel自動命名");
|
||||
expect(notifyThreadRenamed).toHaveBeenCalledWith("thread-active", "Codex Panel自動命名");
|
||||
expect(state.threadList.listedThreads[0]?.name).toBeNull();
|
||||
expect(applyThreadRenamed).toHaveBeenCalledWith("thread-active", "Codex Panel自動命名");
|
||||
});
|
||||
|
||||
it("syncs active runtime state from thread settings notifications", () => {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ function controllerFixture(
|
|||
renameThread: async (threadId: string, value: string, options?: { shouldPublish?: () => boolean }) => {
|
||||
await currentClient().setThreadName(threadId, value);
|
||||
if (options?.shouldPublish?.() ?? true) notifyThreadRenamed(threadId, value);
|
||||
return { name: value };
|
||||
return true;
|
||||
},
|
||||
},
|
||||
titleService,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ describe("createIdentitySync", () => {
|
|||
activePermissionProfile: null,
|
||||
});
|
||||
|
||||
controller.notifyThreadArchived("thread");
|
||||
controller.applyThreadArchived("thread");
|
||||
|
||||
expect(stateStore.getState().activeThread.id).toBeNull();
|
||||
expect(host.invalidateResumeWork).toHaveBeenCalledOnce();
|
||||
|
|
@ -64,7 +64,7 @@ describe("createIdentitySync", () => {
|
|||
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("updates listed and restored thread titles on rename notifications", () => {
|
||||
it("updates restored thread titles on rename notifications", () => {
|
||||
const { controller, restoredPlaceholder, restoredRename, stateStore } = createController();
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [thread("thread", "Old")] });
|
||||
restoredPlaceholder.mockReturnValue({
|
||||
|
|
@ -75,9 +75,9 @@ describe("createIdentitySync", () => {
|
|||
loading: null,
|
||||
});
|
||||
|
||||
controller.notifyThreadRenamed("thread", "New");
|
||||
controller.applyThreadRenamed("thread", "New");
|
||||
|
||||
expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("New");
|
||||
expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Old");
|
||||
expect(restoredRename).toHaveBeenCalledWith("thread", "New");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ThreadRenameEditorController } from "../../../../src/features/chat/appl
|
|||
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 { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename";
|
||||
import { deferred } from "../../../support/async";
|
||||
|
||||
describe("ThreadRenameEditorController", () => {
|
||||
|
|
@ -162,15 +163,17 @@ function controllerFixture(
|
|||
addSystemMessage: overrides.addSystemMessage ?? vi.fn(),
|
||||
operations: {
|
||||
renameThread: async (threadId: string, value: string) => {
|
||||
await currentClient().setThreadName(threadId, value);
|
||||
const rename = threadRenameFromValue(value);
|
||||
if (!rename) return false;
|
||||
await currentClient().setThreadName(threadId, rename.name);
|
||||
stateStore.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: stateStore
|
||||
.getState()
|
||||
.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: value } : thread)),
|
||||
.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: rename.name } : thread)),
|
||||
});
|
||||
notifyThreadRenamed(threadId, value);
|
||||
return { name: value };
|
||||
notifyThreadRenamed(threadId, rename.name);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
titleService: {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie
|
|||
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
|
||||
import { archiveThreadOnAppServer } from "../../../../src/app-server/services/thread-archive";
|
||||
import type { ArchiveExportAdapter } from "../../../../src/app-server/services/thread-archive-markdown";
|
||||
import { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename";
|
||||
import { createChatState } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
import {
|
||||
|
|
@ -261,7 +262,7 @@ describe("thread management actions", () => {
|
|||
|
||||
expect(host.ensureConnected).toHaveBeenCalledOnce();
|
||||
expect(client.setThreadName).toHaveBeenCalledWith("thread", "Slash command title");
|
||||
expect(host.stateStore.getState().threadList.listedThreads[0]?.name).toBe("Slash command title");
|
||||
expect(host.stateStore.getState().threadList.listedThreads[0]?.name).toBe("Old");
|
||||
expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Slash command title");
|
||||
});
|
||||
|
||||
|
|
@ -451,10 +452,12 @@ function hostMock({
|
|||
return result;
|
||||
}),
|
||||
renameThread: vi.fn(async (threadId: string, value: string) => {
|
||||
const rename = threadRenameFromValue(value);
|
||||
if (!rename) return false;
|
||||
await ensureConnected();
|
||||
await client.setThreadName(threadId, value);
|
||||
notifyThreadRenamed(threadId, value);
|
||||
return { name: value };
|
||||
await client.setThreadName(threadId, rename.name);
|
||||
notifyThreadRenamed(threadId, rename.name);
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
showNotice,
|
||||
|
|
|
|||
|
|
@ -633,9 +633,9 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
|
||||
it("routes slash archive through shared panel notifications", async () => {
|
||||
const notifyThreadArchived = vi.fn();
|
||||
const archiveThreadInCatalog = vi.fn();
|
||||
const host = chatHost({
|
||||
notifyThreadArchived,
|
||||
archiveThreadInCatalog,
|
||||
});
|
||||
const client = connectedClient({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
|
||||
|
|
@ -649,12 +649,12 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
await submitComposerByEnter(view);
|
||||
|
||||
expect(client.archiveThread).toHaveBeenCalledWith("thread-1");
|
||||
expect(notifyThreadArchived).toHaveBeenCalledWith("thread-1");
|
||||
expect(archiveThreadInCatalog).toHaveBeenCalledWith("thread-1");
|
||||
});
|
||||
|
||||
it("replaces the current panel with the forked thread after fork and archive", async () => {
|
||||
const notifyThreadArchived = vi.fn();
|
||||
const host = chatHost({ notifyThreadArchived });
|
||||
const archiveThreadInCatalog = vi.fn();
|
||||
const host = chatHost({ archiveThreadInCatalog });
|
||||
const client = connectedClient({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("source")] }),
|
||||
resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))),
|
||||
|
|
@ -682,7 +682,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault");
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" });
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "forked" });
|
||||
expect(notifyThreadArchived).toHaveBeenCalledWith("source");
|
||||
expect(archiveThreadInCatalog).toHaveBeenCalledWith("source");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -693,7 +693,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView({ requestSaveLayout });
|
||||
|
||||
await view.surface.openThread("thread-1");
|
||||
view.surface.notifyThreadArchived("thread-1");
|
||||
view.surface.applyThreadArchived("thread-1");
|
||||
|
||||
expect(view.getState()).toEqual({ version: 1 });
|
||||
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -703,7 +703,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never);
|
||||
view.surface.notifyThreadRenamed("thread-1", "After rename");
|
||||
view.surface.applyThreadRenamed("thread-1", "After rename");
|
||||
|
||||
expect(view.getDisplayText()).toBe("Codex: After rename");
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" });
|
||||
|
|
@ -717,7 +717,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
||||
|
||||
view.surface.notifyThreadRenamed("thread-1", "Explicit name");
|
||||
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: "Explicit name" })]);
|
||||
view.surface.applyThreadRenamed("thread-1", "Explicit name");
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Explicit name”...");
|
||||
|
|
@ -758,13 +759,15 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("thread-1");
|
||||
view.surface.notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: "Renamed thread" })]);
|
||||
view.surface.applyThreadRenamed("thread-1", "Renamed thread");
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Renamed thread”...");
|
||||
});
|
||||
|
||||
view.surface.notifyThreadRenamed("thread-1", null);
|
||||
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: null })]);
|
||||
view.surface.applyThreadRenamed("thread-1", null);
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
||||
|
|
@ -785,7 +788,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
composer.setSelectionRange(5, 9);
|
||||
|
||||
view.surface.notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: "Renamed thread" })]);
|
||||
view.surface.applyThreadRenamed("thread-1", "Renamed thread");
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerElement(view)).toBe(composer);
|
||||
|
|
@ -1282,8 +1286,8 @@ interface ChatHostFixtureOverrides {
|
|||
openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"];
|
||||
focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"];
|
||||
openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"];
|
||||
notifyThreadArchived?: CodexChatHost["threadCatalog"]["notifyThreadArchived"];
|
||||
notifyThreadRenamed?: CodexChatHost["threadCatalog"]["notifyThreadRenamed"];
|
||||
archiveThreadInCatalog?: CodexChatHost["threadCatalog"]["archiveThreadInCatalog"];
|
||||
renameThreadInCatalog?: CodexChatHost["threadCatalog"]["renameThreadInCatalog"];
|
||||
refreshFromOpenSurface?: CodexChatHost["threadCatalog"]["refreshFromOpenSurface"];
|
||||
refreshThreadsViewLiveState?: CodexChatHost["threadCatalog"]["refreshThreadsViewLiveState"];
|
||||
applyThreads?: CodexChatHost["threadCatalog"]["applyThreads"];
|
||||
|
|
@ -1311,8 +1315,8 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost {
|
|||
openTurnDiff: overrides.openTurnDiff ?? vi.fn(),
|
||||
},
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: overrides.notifyThreadArchived ?? vi.fn(),
|
||||
notifyThreadRenamed: overrides.notifyThreadRenamed ?? vi.fn(),
|
||||
archiveThreadInCatalog: overrides.archiveThreadInCatalog ?? vi.fn(),
|
||||
renameThreadInCatalog: overrides.renameThreadInCatalog ?? vi.fn(),
|
||||
refreshFromOpenSurface: overrides.refreshFromOpenSurface ?? vi.fn(),
|
||||
refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(),
|
||||
applyThreads: overrides.applyThreads ?? vi.fn(),
|
||||
|
|
|
|||
|
|
@ -282,9 +282,9 @@ describe("CodexThreadsView", () => {
|
|||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
archiveThread,
|
||||
});
|
||||
const notifyThreadArchived = vi.fn();
|
||||
const archiveThreadInCatalog = vi.fn();
|
||||
const host = threadsHost({
|
||||
threadCatalog: { notifyThreadArchived },
|
||||
threadCatalog: { archiveThreadInCatalog },
|
||||
});
|
||||
const view = await threadsView(host);
|
||||
|
||||
|
|
@ -294,7 +294,7 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(archiveThread).toHaveBeenCalledWith("thread");
|
||||
expect(notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
expect(archiveThreadInCatalog).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -304,9 +304,9 @@ describe("CodexThreadsView", () => {
|
|||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
setThreadName,
|
||||
});
|
||||
const notifyThreadRenamed = vi.fn();
|
||||
const renameThreadInCatalog = vi.fn();
|
||||
const host = threadsHost({
|
||||
threadCatalog: { notifyThreadRenamed },
|
||||
threadCatalog: { renameThreadInCatalog },
|
||||
});
|
||||
const view = await threadsView(host);
|
||||
|
||||
|
|
@ -320,7 +320,7 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread");
|
||||
expect(notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread");
|
||||
expect(renameThreadInCatalog).toHaveBeenCalledWith("thread", "Renamed thread");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -424,8 +424,8 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
|
|||
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
|
||||
getOpenPanelSnapshots: vi.fn(() => []),
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
archiveThreadInCatalog: vi.fn(),
|
||||
renameThreadInCatalog: vi.fn(),
|
||||
refreshFromOpenSurface: vi.fn(),
|
||||
refreshThreads: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
|
||||
cachedThreads: vi.fn(() => null),
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ describe("ThreadOperations", () => {
|
|||
it("renames a thread and notifies shared surfaces after success", async () => {
|
||||
const { operations, client, catalog } = operationsFixture();
|
||||
|
||||
await expect(operations.renameThread("thread", " Saved title ")).resolves.toEqual({ name: "Saved title" });
|
||||
await expect(operations.renameThread("thread", " Saved title ")).resolves.toBe(true);
|
||||
|
||||
expect(client?.setThreadName).toHaveBeenCalledWith("thread", "Saved title");
|
||||
expect(catalog.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Saved title");
|
||||
expect(catalog.renameThreadInCatalog).toHaveBeenCalledWith("thread", "Saved title");
|
||||
});
|
||||
|
||||
it("can skip rename publication when the caller invalidates the save", async () => {
|
||||
|
|
@ -29,7 +29,7 @@ describe("ThreadOperations", () => {
|
|||
|
||||
await operations.renameThread("thread", "Generated title", { shouldPublish: () => false });
|
||||
|
||||
expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
expect(catalog.renameThreadInCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => {
|
||||
|
|
@ -41,17 +41,17 @@ describe("ThreadOperations", () => {
|
|||
});
|
||||
|
||||
expect(notice).toHaveBeenCalledWith("Saved archived thread to Archive/thread.md.");
|
||||
expect(catalog.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
expect(catalog.archiveThreadInCatalog).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
});
|
||||
|
||||
it("does not notify surfaces when an operation has no current client", async () => {
|
||||
const { operations, catalog } = operationsFixture({ client: null });
|
||||
|
||||
await expect(operations.renameThread("thread", "Title")).resolves.toBeNull();
|
||||
await expect(operations.renameThread("thread", "Title")).resolves.toBe(false);
|
||||
await expect(operations.archiveThread("thread")).resolves.toBeNull();
|
||||
|
||||
expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
expect(catalog.notifyThreadArchived).not.toHaveBeenCalled();
|
||||
expect(catalog.renameThreadInCatalog).not.toHaveBeenCalled();
|
||||
expect(catalog.archiveThreadInCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -60,9 +60,8 @@ function operationsFixture(options: { client?: MockClient | null } = {}) {
|
|||
archiveMock.archiveThreadOnAppServer.mockResolvedValue({ exportedPath: null } satisfies ArchiveThreadResult);
|
||||
const client = options.client === undefined ? clientMock() : options.client;
|
||||
const catalog = {
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
refreshFromOpenSurface: vi.fn(),
|
||||
archiveThreadInCatalog: vi.fn(),
|
||||
renameThreadInCatalog: vi.fn(),
|
||||
};
|
||||
const notice = vi.fn();
|
||||
const host: ThreadOperationsHost = {
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
expect(openThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes shared thread lists after archive lifecycle notifications", async () => {
|
||||
it("does not refresh shared thread lists after known archive mutations", async () => {
|
||||
const { CodexChatView } = await import("../src/features/chat/host/view");
|
||||
const connectedLeaf = leaf();
|
||||
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
|
||||
|
|
@ -380,9 +380,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
|
||||
threadSurfaces(plugin).notifyThreadArchived("thread-1");
|
||||
threadSurfaces(plugin).applyThreadArchived("thread-1");
|
||||
|
||||
expect(refreshSharedThreadList).toHaveBeenCalledOnce();
|
||||
expect(refreshSharedThreadList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes matching chat panels only when archive notification requests it", async () => {
|
||||
|
|
@ -398,20 +398,20 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
vi.spyOn((otherLeaf.view as CodexChatView).surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]);
|
||||
|
||||
threadSurfaces(plugin).notifyThreadArchived("thread-1");
|
||||
threadSurfaces(plugin).applyThreadArchived("thread-1");
|
||||
|
||||
expect(restoredMatchingLeaf.detach).not.toHaveBeenCalled();
|
||||
expect(matchingLeaf.detach).not.toHaveBeenCalled();
|
||||
expect(otherLeaf.detach).not.toHaveBeenCalled();
|
||||
|
||||
threadSurfaces(plugin).notifyThreadArchived("thread-1", { closeOpenPanels: true });
|
||||
threadSurfaces(plugin).applyThreadArchived("thread-1", { closeOpenPanels: true });
|
||||
|
||||
expect(restoredMatchingLeaf.detach).toHaveBeenCalledOnce();
|
||||
expect(matchingLeaf.detach).toHaveBeenCalledOnce();
|
||||
expect(otherLeaf.detach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes shared thread lists after rename lifecycle notifications", async () => {
|
||||
it("does not refresh shared thread lists after known rename mutations", async () => {
|
||||
const { CodexChatView } = await import("../src/features/chat/host/view");
|
||||
const connectedLeaf = leaf();
|
||||
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
|
||||
|
|
@ -420,9 +420,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
|
||||
threadSurfaces(plugin).notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
threadSurfaces(plugin).applyThreadRenamed("thread-1", "Renamed thread");
|
||||
|
||||
expect(refreshSharedThreadList).toHaveBeenCalledOnce();
|
||||
expect(refreshSharedThreadList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("single-flights shared thread list refreshes and caches successful results", async () => {
|
||||
|
|
@ -502,7 +502,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
|
||||
const plugin = await pluginWithLeaves([disconnectedLeaf, connectedLeaf]);
|
||||
|
||||
threadSurfaces(plugin).refreshSharedThreadListFromOpenSurface();
|
||||
threadSurfaces(plugin).invalidateThreadsFromOpenSurface();
|
||||
|
||||
expect(disconnectedRefresh).not.toHaveBeenCalled();
|
||||
expect(connectedRefresh).toHaveBeenCalledOnce();
|
||||
|
|
@ -533,10 +533,10 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const plugin = await pluginWithLeaves(leaves);
|
||||
|
||||
sourceLeaf.detach();
|
||||
threadSurfaces(plugin).notifyThreadArchived("thread-1");
|
||||
threadSurfaces(plugin).applyThreadArchived("thread-1");
|
||||
|
||||
expect(sourceRefresh).not.toHaveBeenCalled();
|
||||
expect(remainingRefresh).toHaveBeenCalledOnce();
|
||||
expect(remainingRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -620,8 +620,8 @@ function chatHostFixture(): CodexChatHost {
|
|||
openTurnDiff: vi.fn(),
|
||||
},
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
archiveThreadInCatalog: vi.fn(),
|
||||
renameThreadInCatalog: vi.fn(),
|
||||
refreshFromOpenSurface: vi.fn(),
|
||||
refreshThreadsViewLiveState: vi.fn(),
|
||||
applyThreads: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import type { ThreadSurfaceActions } from "../../src/workspace/thread-surface-ac
|
|||
|
||||
type MockSurfaceActions = ThreadSurfaceActions & {
|
||||
applyThreadListSnapshot: Mock<(threads: readonly Thread[]) => void>;
|
||||
applyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>;
|
||||
applyThreadRenamed: Mock<(threadId: string, name: string | null) => void>;
|
||||
publishAppServerMetadata: Mock<(metadata: SharedServerMetadata) => void>;
|
||||
publishModels: Mock<(models: readonly ModelMetadata[]) => void>;
|
||||
notifyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>;
|
||||
notifyThreadRenamed: Mock<(threadId: string, name: string | null) => void>;
|
||||
};
|
||||
|
||||
describe("SharedThreadCatalog", () => {
|
||||
|
|
@ -55,14 +55,26 @@ describe("SharedThreadCatalog", () => {
|
|||
expect(surfaces.publishModels).toHaveBeenCalledWith(models);
|
||||
});
|
||||
|
||||
it("forwards archive and rename notifications through the surface owner", () => {
|
||||
it("applies known rename mutations to cache and surfaces", () => {
|
||||
const { catalog, surfaces } = catalogFixture();
|
||||
catalog.applyThreads([thread("thread"), thread("other")]);
|
||||
|
||||
catalog.notifyThreadArchived("thread", { closeOpenPanels: true });
|
||||
catalog.notifyThreadRenamed("thread", "Renamed");
|
||||
catalog.renameThreadInCatalog("thread", "Renamed");
|
||||
|
||||
expect(surfaces.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
expect(surfaces.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed");
|
||||
expect(catalog.cachedThreads()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
||||
expect(surfaces.applyThreadListSnapshot).toHaveBeenLastCalledWith([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
||||
expect(surfaces.applyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed");
|
||||
});
|
||||
|
||||
it("applies known archive mutations to cache and surfaces", () => {
|
||||
const { catalog, surfaces } = catalogFixture();
|
||||
catalog.applyThreads([thread("thread"), thread("other")]);
|
||||
|
||||
catalog.archiveThreadInCatalog("thread", { closeOpenPanels: true });
|
||||
|
||||
expect(catalog.cachedThreads()).toEqual([thread("other")]);
|
||||
expect(surfaces.applyThreadListSnapshot).toHaveBeenLastCalledWith([thread("other")]);
|
||||
expect(surfaces.applyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -79,13 +91,13 @@ function catalogFixture() {
|
|||
function surfaceActions(): MockSurfaceActions {
|
||||
return {
|
||||
refreshOpenViews: vi.fn(),
|
||||
refreshSharedThreadListFromOpenSurface: vi.fn(),
|
||||
invalidateThreadsFromOpenSurface: vi.fn(),
|
||||
applyThreadListSnapshot: vi.fn(),
|
||||
applyThreadArchived: vi.fn(),
|
||||
applyThreadRenamed: vi.fn(),
|
||||
publishAppServerMetadata: vi.fn(),
|
||||
publishModels: vi.fn(),
|
||||
refreshThreadsViewLiveState: vi.fn(),
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe("createThreadSurfaceActions", () => {
|
|||
} as never,
|
||||
});
|
||||
|
||||
threadSurfaces.refreshSharedThreadListFromOpenSurface();
|
||||
threadSurfaces.invalidateThreadsFromOpenSurface();
|
||||
|
||||
expect(disconnectedPanelRefresh).not.toHaveBeenCalled();
|
||||
expect(threadsRefresh).toHaveBeenCalledOnce();
|
||||
|
|
@ -65,12 +65,41 @@ describe("createThreadSurfaceActions", () => {
|
|||
} as never,
|
||||
});
|
||||
|
||||
threadSurfaces.refreshSharedThreadListFromOpenSurface();
|
||||
threadSurfaces.invalidateThreadsFromOpenSurface();
|
||||
|
||||
expect(disconnectedPanelRefresh).not.toHaveBeenCalled();
|
||||
expect(connectedPanelRefresh).toHaveBeenCalledOnce();
|
||||
expect(threadsRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies known thread mutations without refreshing thread lists", () => {
|
||||
const panel = {
|
||||
surface: {
|
||||
openPanelSnapshot: () => panelSnapshot({ connected: true }),
|
||||
refreshSharedThreadList: vi.fn().mockResolvedValue(undefined),
|
||||
applyThreadArchived: vi.fn(),
|
||||
applyThreadRenamed: vi.fn(),
|
||||
},
|
||||
};
|
||||
const threadSurfaces = createThreadSurfaceActions({
|
||||
app: {
|
||||
workspace: {
|
||||
getLeavesOfType: vi.fn(() => []),
|
||||
},
|
||||
} as never,
|
||||
panels: {
|
||||
panelViews: () => [panel],
|
||||
panelLeavesForThread: vi.fn(() => []),
|
||||
} as never,
|
||||
});
|
||||
|
||||
threadSurfaces.applyThreadRenamed("thread", "Renamed");
|
||||
threadSurfaces.applyThreadArchived("thread");
|
||||
|
||||
expect(panel.surface.applyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed");
|
||||
expect(panel.surface.applyThreadArchived).toHaveBeenCalledWith("thread");
|
||||
expect(panel.surface.refreshSharedThreadList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function threadsView(overrides: Partial<CodexThreadsView> = {}): CodexThreadsView {
|
||||
|
|
|
|||
Loading…
Reference in a new issue