Centralize thread rename and archive catalog mutations

This commit is contained in:
murashit 2026-06-15 14:44:31 +09:00
parent ef61280b7d
commit c71024e11c
27 changed files with 255 additions and 268 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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