fix(threads): disable unavailable auto-name actions

This commit is contained in:
murashit 2026-07-21 13:16:49 +09:00
parent 8a798a5bca
commit 2fb7d29841
21 changed files with 350 additions and 76 deletions

View file

@ -1,7 +1,11 @@
import type { ThreadTitleContext } from "./title-generation-model";
type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" } | { kind: "ready"; context: ThreadTitleContext };
export type ThreadRenameLifecycleState =
| { kind: "idle" }
| { kind: "editing"; draft: string }
| { kind: "generating"; draft: string; generationToken: number };
| { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
| { kind: "generating"; draft: string; autoName: Extract<ThreadRenameAutoNameState, { kind: "ready" }>; generationToken: number };
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
@ -9,6 +13,7 @@ type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "
export type ThreadRenameLifecycleEvent =
| { type: "started"; draft: string }
| { type: "draft-updated"; draft: string }
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
| { type: "cancelled" }
| { type: "generation-started"; generationToken: number }
| { type: "generation-succeeded"; generationToken: number; draft: string }
@ -25,16 +30,23 @@ export function transitionThreadRenameLifecycleState(
): ThreadRenameLifecycleState {
switch (event.type) {
case "started":
return { kind: "editing", draft: event.draft };
return { kind: "editing", draft: event.draft, autoName: { kind: "checking" } };
case "draft-updated":
return state.kind === "editing" ? { ...state, draft: event.draft } : state;
case "auto-name-context-resolved":
if (state.kind !== "editing") return state;
return {
...state,
autoName: event.context ? { kind: "ready", context: event.context } : { kind: "unavailable" },
};
case "cancelled":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
case "generation-started":
if (state.kind !== "editing") return state;
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
return {
kind: "generating",
draft: state.draft,
autoName: state.autoName,
generationToken: event.generationToken,
};
case "generation-succeeded":
@ -42,7 +54,7 @@ export function transitionThreadRenameLifecycleState(
return { ...state, draft: event.draft };
case "generation-finished":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { kind: "editing", draft: state.draft };
return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "cleared":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
default:

View file

@ -6,9 +6,6 @@ const DEFAULT_CONTEXT_PAGE_LIMIT = 20;
const DEFAULT_CONTEXT_MAX_PAGES = 5;
export const THREAD_TITLE_MAX_CHARS = 40;
export const THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE =
"Auto-name needs completed history or visible resumed history with both user and assistant text.";
export interface ThreadTitleContext {
userRequest: string;
assistantResponse: string;

View file

@ -7,6 +7,7 @@ import {
threadRenameGenerationStillActive,
transitionThreadRenameLifecycleState,
} from "../../../../domain/threads/rename-lifecycle";
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
import type { DisclosureSetAction } from "./actions";
import { patchObject } from "./patch";
@ -19,6 +20,7 @@ type ChatRenameUiAction = Extract<
type:
| "ui/rename-started"
| "ui/rename-draft-updated"
| "ui/rename-auto-name-context-resolved"
| "ui/rename-cancelled"
| "ui/rename-generation-started"
| "ui/rename-generation-succeeded"
@ -71,6 +73,7 @@ export type UiAction =
| { type: "ui/archive-confirm-set"; threadId: string | null }
| { type: "ui/rename-started"; threadId: string; draft: string }
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
| { type: "ui/rename-auto-name-context-resolved"; threadId: string; context: ThreadTitleContext | null }
| { type: "ui/rename-cancelled"; threadId: string }
| { type: "ui/rename-generation-started"; threadId: string; generationToken: number }
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
@ -99,6 +102,7 @@ export function isUiAction(action: { type: string }): action is UiAction {
case "ui/archive-confirm-set":
case "ui/rename-started":
case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
case "ui/rename-cancelled":
case "ui/rename-generation-started":
case "ui/rename-generation-succeeded":
@ -123,6 +127,7 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
return patchObject(state, { archiveConfirmThreadId: action.threadId });
case "ui/rename-started":
case "ui/rename-draft-updated":
case "ui/rename-auto-name-context-resolved":
case "ui/rename-cancelled":
case "ui/rename-generation-started":
case "ui/rename-generation-succeeded":
@ -253,9 +258,14 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
switch (action.type) {
case "ui/rename-started":
return { kind: "editing", threadId: action.threadId, draft: action.draft };
return { kind: "editing", threadId: action.threadId, draft: action.draft, autoName: { kind: "checking" } };
case "ui/rename-draft-updated":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft });
case "ui/rename-auto-name-context-resolved":
return transitionScopedChatRenameUiState(state, action.threadId, {
type: "auto-name-context-resolved",
context: action.context,
});
case "ui/rename-cancelled":
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
case "ui/rename-generation-started":
@ -304,11 +314,12 @@ function chatRenameLifecycleStateWithoutThreadId(state: ChatRenameUiState): Thre
function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState, { kind: "idle" }>): ThreadRenameActiveState {
switch (state.kind) {
case "editing":
return { kind: "editing", draft: state.draft };
return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "generating":
return {
kind: "generating",
draft: state.draft,
autoName: state.autoName,
generationToken: state.generationToken,
};
}

View file

@ -16,7 +16,8 @@ export interface ThreadRenameEditorActionsHost {
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
renameThread(threadId: string, value: string): Promise<boolean>;
generateThreadTitle(threadId: string, signal?: AbortSignal): Promise<string>;
resolveThreadTitleContext(threadId: string): Promise<ThreadTitleContext | null>;
generateThreadTitle(context: ThreadTitleContext, signal?: AbortSignal): Promise<string | null>;
}
export interface ThreadRenameEditorActions {
@ -34,11 +35,13 @@ export interface ThreadRenameEditorActions {
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
let nextRenameGenerationToken = 1;
let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null;
let activeContextPreparation: { threadId: string } | null = null;
const action = {
invalidate(): void {
activeGeneration?.controller.abort();
activeGeneration = null;
activeContextPreparation = null;
dispatch(host, { type: "ui/rename-cleared" });
},
@ -60,6 +63,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
if (!thread) return;
abortActiveGeneration();
dispatch(host, { type: "ui/rename-started", threadId, draft: threadRenameDraftTitle(thread) });
void prepareAutoName(threadId);
},
updateDraft(threadId: string, value: string): void {
@ -68,6 +72,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
cancel(threadId: string): void {
abortGeneration(threadId);
if (activeContextPreparation?.threadId === threadId) activeContextPreparation = null;
dispatch(host, { type: "ui/rename-cancelled", threadId });
},
@ -85,18 +90,18 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
try {
await host.ensureConnected();
if (renameState(host) !== editingState) return;
if (!renameEditStillCurrent(host, threadId, editingState.draft)) return;
const result = await host.renameThread(threadId, value);
if (!result) {
if (renameState(host) === editingState) action.cancel(threadId);
if (renameEditStillCurrent(host, threadId, editingState.draft)) action.cancel(threadId);
return;
}
if (renameState(host) === editingState) {
if (renameEditStillCurrent(host, threadId, editingState.draft)) {
dispatch(host, { type: "ui/rename-cleared" });
}
} catch (error) {
if (renameState(host) !== editingState) return;
if (!renameEditStillCurrent(host, threadId, editingState.draft)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
},
@ -105,24 +110,21 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
const current = renameState(host);
if (current.kind !== "editing" || current.threadId !== threadId) return;
const editingState = current;
await host.ensureConnected();
if (renameState(host) !== editingState) return;
dispatch(host, {
type: "ui/rename-generation-started",
threadId,
generationToken: nextRenameGenerationToken,
});
const generationToken = nextRenameGenerationToken;
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return;
const generating = renameState(host);
if (!renameGenerationStillActive(generating, threadId, generationToken)) return;
nextRenameGenerationToken += 1;
const controller = new AbortController();
activeGeneration = { threadId, generationToken, controller };
try {
const title = await host.generateThreadTitle(threadId, controller.signal);
const title = await host.generateThreadTitle(generating.autoName.context, controller.signal);
if (!title) throw new Error("Codex did not return a usable thread title.");
dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title });
} catch (error) {
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
@ -151,6 +153,24 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
function clearGeneration(generationToken: number): void {
if (activeGeneration?.generationToken === generationToken) activeGeneration = null;
}
async function prepareAutoName(threadId: string): Promise<void> {
const preparation = { threadId };
activeContextPreparation = preparation;
let context: ThreadTitleContext | null = null;
try {
await host.ensureConnected();
if (activeContextPreparation !== preparation) return;
context = await host.resolveThreadTitleContext(threadId);
} catch {
// Auto-name availability is reflected by the disabled action.
}
if (activeContextPreparation !== preparation) return;
activeContextPreparation = null;
const current = renameState(host);
if (current.kind !== "editing" || current.threadId !== threadId) return;
dispatch(host, { type: "ui/rename-auto-name-context-resolved", threadId, context });
}
}
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {
@ -161,6 +181,11 @@ function renameState(host: ThreadRenameEditorActionsHost): ChatRenameUiState {
return host.stateStore.getState().ui.rename;
}
function renameEditStillCurrent(host: ThreadRenameEditorActionsHost, threadId: string, draft: string): boolean {
const current = renameState(host);
return current.kind === "editing" && current.threadId === threadId && current.draft === draft;
}
function dispatch(host: ThreadRenameEditorActionsHost, action: ChatAction): void {
host.stateStore.dispatch(action);
}

View file

@ -227,7 +227,8 @@ export function createThreadLifecycleBundle(
ensureConnected,
addSystemMessage: status.addSystemMessage,
renameThread: (threadId, value) => foundation.threadOperations.renameThread(threadId, value),
generateThreadTitle: (threadId, signal) => foundation.titleService.generateTitle(threadId, signal),
resolveThreadTitleContext: (threadId) => foundation.titleService.resolveContext(threadId),
generateThreadTitle: (context, signal) => foundation.titleService.generate(context, signal),
});
const { identity, restoration, resume } = lifecycle;

View file

@ -211,7 +211,9 @@ function toolbarThreadRows(input: {
archiveDisabled: input.turnBusy,
canArchive: true,
archiveConfirm: core.archiveConfirm,
rename: core.rename.active ? { draft: core.rename.draft, generating: core.rename.generating } : null,
rename: core.rename.active
? { draft: core.rename.draft, generating: core.rename.generating, autoNameDisabled: core.rename.autoNameDisabled }
: null,
};
});
}

View file

@ -20,6 +20,7 @@ export interface ToolbarThreadRow {
rename: {
draft: string;
generating: boolean;
autoNameDisabled: boolean;
} | null;
}
@ -529,6 +530,7 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action
icon={generating ? "x" : "sparkles"}
label={generating ? "Cancel auto-name" : "Auto-name thread"}
className="codex-panel__thread-action"
disabled={!generating && (thread.rename?.autoNameDisabled ?? true)}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();

View file

@ -55,6 +55,10 @@ interface ThreadsViewOperationLease {
readonly lifetime: AbortSignal;
}
interface ThreadTitleContextPreparation {
readonly threadId: string;
}
export class ThreadsViewSession {
private readonly lifetime = new OwnerLifetime();
private readonly operations: ThreadOperations;
@ -66,6 +70,7 @@ export class ThreadsViewSession {
private threads: readonly Thread[] = [];
private threadsLoaded = false;
private readonly renameStates = new Map<string, ThreadsRenameState>();
private readonly renameContextPreparations = new Map<string, ThreadTitleContextPreparation>();
private readonly renameGenerationControllers = new Map<string, { generationToken: number; controller: AbortController }>();
private nextRenameGenerationToken = 1;
private unsubscribeThreads: (() => void) | null = null;
@ -115,6 +120,7 @@ export class ThreadsViewSession {
this.lifetime.dispose();
for (const operation of this.renameGenerationControllers.values()) operation.controller.abort();
this.renameGenerationControllers.clear();
this.renameContextPreparations.clear();
this.titleService.invalidate();
this.observedFetching = false;
this.observedFetchingNextPage = false;
@ -265,6 +271,7 @@ export class ThreadsViewSession {
this.archiveConfirmThreadId = null;
this.transitionRenameState(threadId, { type: "started", draft: value });
this.render();
void this.prepareAutoName(threadId);
}
private updateRename(threadId: string, value: string): void {
@ -274,6 +281,7 @@ export class ThreadsViewSession {
private cancelRename(threadId: string): void {
this.abortAutoName(threadId);
this.renameContextPreparations.delete(threadId);
this.transitionRenameState(threadId, { type: "cancelled" });
this.render();
}
@ -290,22 +298,21 @@ export class ThreadsViewSession {
const editingState = this.renameStates.get(threadId);
if (!editingState || editingState.kind === "generating") return;
try {
if (this.renameStates.get(threadId) !== editingState) return;
const result = await this.operations.renameThread(threadId, value, {
shouldStart: () => this.operationContextIsCurrent(lease),
shouldPublish: () => this.operationContextIsCurrent(lease),
});
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
if (!this.operationViewIsCurrent(lease) || !this.renameEditStillCurrent(threadId, editingState.draft)) return;
if (!result) {
this.cancelRename(threadId);
return;
}
this.renameStates.delete(threadId);
this.renameContextPreparations.delete(threadId);
this.render();
} catch (error) {
if (!this.operationViewIsCurrent(lease) || this.renameStates.get(threadId) !== editingState) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
if (!this.operationViewIsCurrent(lease) || !this.renameEditStillCurrent(threadId, editingState.draft)) return;
this.noticeError(error);
}
}
@ -324,8 +331,9 @@ export class ThreadsViewSession {
this.render();
try {
if (this.renameStates.get(threadId) !== generatingState) return;
const title = await this.titleService.generateTitle(threadId, controller.signal);
const context = generatingState.autoName.context;
const title = await this.titleService.generate(context, controller.signal);
if (!title) throw new Error("Codex did not return a usable thread title.");
if (!this.operationViewIsCurrent(lease)) return;
this.transitionRenameState(threadId, { type: "generation-succeeded", generationToken, draft: title });
} catch (error) {
@ -393,6 +401,23 @@ export class ThreadsViewSession {
if (nextState !== previousState) this.render();
}
private async prepareAutoName(threadId: string): Promise<void> {
const preparation = { threadId };
this.renameContextPreparations.set(threadId, preparation);
let context = null;
try {
context = await this.titleService.resolveContext(threadId);
} catch {
// Auto-name availability is reflected by the disabled action.
}
if (!this.lifetime.isActive() || this.renameContextPreparations.get(threadId) !== preparation) return;
this.renameContextPreparations.delete(threadId);
const state = this.renameStates.get(threadId);
if (state?.kind !== "editing") return;
this.transitionRenameState(threadId, { type: "auto-name-context-resolved", context });
this.render();
}
private abortAutoName(threadId: string): void {
this.renameGenerationControllers.get(threadId)?.controller.abort();
this.renameGenerationControllers.delete(threadId);
@ -408,6 +433,11 @@ export class ThreadsViewSession {
return nextState;
}
private renameEditStillCurrent(threadId: string, draft: string): boolean {
const current = this.renameStates.get(threadId);
return current?.kind === "editing" && current.draft === draft;
}
private viewWindow(): Window {
return this.environment.viewWindow() ?? window;
}

View file

@ -253,6 +253,7 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions:
icon={row.rename.generating ? "x" : "sparkles"}
label={row.rename.generating ? "Cancel auto-name" : "Auto-name thread"}
className="codex-panel-threads__row-button"
disabled={!row.rename.generating && row.rename.autoNameDisabled}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();

View file

@ -6,6 +6,7 @@ interface ThreadRowCoreRenameProjection {
readonly active: boolean;
readonly draft: string;
readonly generating: boolean;
readonly autoNameDisabled: boolean;
}
interface ThreadRowCoreArchiveConfirmProjection {
@ -37,6 +38,7 @@ export function threadRowCoreProjection(input: {
active: rename !== undefined,
draft: rename?.draft ?? threadRenameDraftTitle(input.thread),
generating: rename?.kind === "generating",
autoNameDisabled: rename?.autoName.kind !== "ready",
},
archiveConfirm: {
active: input.archiveConfirmActive ?? false,

View file

@ -1,8 +1,4 @@
import {
THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE,
type ThreadTitleContext,
threadTitleContextFromTurnTranscriptSummary,
} from "../../../domain/threads/title-generation-model";
import { type ThreadTitleContext, threadTitleContextFromTurnTranscriptSummary } from "../../../domain/threads/title-generation-model";
import type { TurnTranscriptSummary } from "../../../domain/threads/transcript";
import type { ThreadTitleTransport } from "./ports";
@ -15,10 +11,9 @@ export interface ThreadTitleServiceHost {
export interface ThreadTitleService {
invalidate(): void;
generateTitle(threadId: string, signal?: AbortSignal): Promise<string>;
resolveContext(threadId: string): Promise<ThreadTitleContext | null>;
completedTurnContext(turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null): ThreadTitleContext | null;
generate(context: ThreadTitleContext): Promise<string | null>;
generate(context: ThreadTitleContext, signal?: AbortSignal): Promise<string | null>;
}
export function createThreadTitleService(host: ThreadTitleServiceHost): ThreadTitleService {
@ -29,17 +24,16 @@ export function createThreadTitleService(host: ThreadTitleServiceHost): ThreadTi
controller.abort();
controller = new AbortController();
},
generateTitle: async (threadId, signal) => {
generate: async (context, signal) => {
const operation = linkedAbortSignal(controller.signal, signal);
try {
return await generateTitle(host, threadId, operation.signal);
return await generateTitleFromContext(host, context, operation.signal);
} finally {
operation.dispose();
}
},
resolveContext: (threadId) => resolveThreadTitleContext(host, threadId),
completedTurnContext: (turnId, completedTurnTranscriptSummary) => completedTurnContext(host, turnId, completedTurnTranscriptSummary),
generate: (context) => generateTitleFromContext(host, context, controller.signal),
};
}
@ -68,16 +62,6 @@ function linkedAbortSignal(
};
}
async function generateTitle(host: ThreadTitleServiceHost, threadId: string, signal: AbortSignal): Promise<string> {
const context = await resolveThreadTitleContext(host, threadId);
throwIfTitleGenerationCancelled(signal);
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
const title = await generateTitleFromContext(host, context, signal);
if (!title) throw new Error("Codex did not return a usable thread title.");
return title;
}
async function resolveThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise<ThreadTitleContext | null> {
const visibleContext = host.visibleContext?.(threadId);
if (visibleContext) return visibleContext;

View file

@ -32,7 +32,7 @@ describe("thread rename lifecycle", () => {
type: "generation-finished",
generationToken: generating.generationToken,
}),
).toEqual({ kind: "editing", draft: "Generated title" });
).toEqual({ kind: "editing", draft: "Generated title", autoName: generating.autoName });
});
it("does not create an editor from a stray draft update", () => {
@ -46,7 +46,11 @@ type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "
function generatingRenameState(draft: string, generationToken: number): ThreadRenameGeneratingState {
const editing = expectRenameState(transitionThreadRenameLifecycleState(initialThreadRenameLifecycleState(), { type: "started", draft }));
const generating = transitionThreadRenameLifecycleState(editing, { type: "generation-started", generationToken });
const ready = transitionThreadRenameLifecycleState(editing, {
type: "auto-name-context-resolved",
context: { userRequest: "Request", assistantResponse: "Response" },
});
const generating = transitionThreadRenameLifecycleState(ready, { type: "generation-started", generationToken });
if (generating.kind !== "generating") throw new Error("Expected generating rename state.");
return generating;
}

View file

@ -47,6 +47,12 @@ describe("chat UI state", () => {
threadId: "thread",
draft: "Original",
});
const context = { userRequest: "Request", assistantResponse: "Response" };
state = reduceUiSlice(state, {
type: "ui/rename-auto-name-context-resolved",
threadId: "thread",
context,
});
state = reduceUiSlice(state, {
type: "ui/rename-generation-started",
threadId: "thread",
@ -81,7 +87,7 @@ describe("chat UI state", () => {
generationToken: 1,
});
expect(state.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Generated title" });
expect(state.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Generated title", autoName: { kind: "ready", context } });
});
it("clears goal expansion only when the displayed goal identity changes", () => {

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/connection/client";
import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protocol/turn";
import { normalizeExplicitThreadName, type Thread } from "../../../../../src/domain/threads/model";
import type { ThreadTitleContext } from "../../../../../src/domain/threads/title-generation-model";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import {
createThreadRenameEditorActions,
@ -39,14 +40,41 @@ describe("ThreadRenameEditorActions", () => {
expect(actions.editState("thread")).toBeNull();
});
it("applies generated rename drafts and clears the generating state", async () => {
it("keeps auto-name disabled when no title context is available", async () => {
const resolvedContext = deferred<null>();
const generateThreadTitle = vi.fn().mockResolvedValue("Generated title");
const { actions } = actionsFixture({ generateThreadTitle });
const addSystemMessage = vi.fn();
const { actions, stateStore } = actionsFixture({
resolveThreadTitleContext: vi.fn(() => resolvedContext.promise),
generateThreadTitle,
addSystemMessage,
});
actions.start("thread");
expect(stateStore.getState().ui.rename).toMatchObject({ autoName: { kind: "checking" } });
await actions.autoNameDraft("thread");
expect(generateThreadTitle).not.toHaveBeenCalled();
resolvedContext.resolve(null);
await flushPromises();
expect(stateStore.getState().ui.rename).toMatchObject({ autoName: { kind: "unavailable" } });
expect(addSystemMessage).not.toHaveBeenCalled();
});
it("applies generated rename drafts and clears the generating state", async () => {
const context = { userRequest: "Please name this.", assistantResponse: "Done." };
const resolveThreadTitleContext = vi.fn().mockResolvedValue(context);
const generateThreadTitle = vi.fn().mockResolvedValue("Generated title");
const { actions } = actionsFixture({ resolveThreadTitleContext, generateThreadTitle });
actions.start("thread");
await flushPromises();
await actions.autoNameDraft("thread");
expect(generateThreadTitle).toHaveBeenCalledOnce();
expect(generateThreadTitle).toHaveBeenCalledWith(context, expect.any(AbortSignal));
expect(resolveThreadTitleContext).toHaveBeenCalledOnce();
expect(actions.editState("thread")).toEqual({ draft: "Generated title", generating: false });
});
@ -61,6 +89,7 @@ describe("ThreadRenameEditorActions", () => {
});
actions.start("thread");
await flushPromises();
const autoName = actions.autoNameDraft("thread");
await Promise.resolve();
expect(generationSignal?.aborted).toBe(false);
@ -89,6 +118,7 @@ describe("ThreadRenameEditorActions", () => {
});
actions.start("thread");
await flushPromises();
const autoName = actions.autoNameDraft("thread");
await Promise.resolve();
actions.start("other");
@ -109,6 +139,7 @@ describe("ThreadRenameEditorActions", () => {
});
actions.start("thread");
await flushPromises();
const autoName = actions.autoNameDraft("thread");
actions.cancel("thread");
connection.resolve(undefined);
@ -168,6 +199,36 @@ describe("ThreadRenameEditorActions", () => {
expect(addSystemMessage).toHaveBeenCalledWith("Rename failed.");
});
it("keeps preparing auto-name while a rename save is in flight", async () => {
const context = deferred<ThreadTitleContext | null>();
const saved = deferred<void>();
const readyContext = { userRequest: "Name this thread.", assistantResponse: "Done." };
const resolveThreadTitleContext = vi.fn().mockReturnValue(context.promise);
const generateThreadTitle = vi.fn().mockResolvedValue("Generated title");
const { actions } = actionsFixture({
currentClient: () => fakeClient({ renameThreadRequest: vi.fn(() => saved.promise) }),
resolveThreadTitleContext,
generateThreadTitle,
});
actions.start("thread");
await flushPromises();
expect(resolveThreadTitleContext).toHaveBeenCalledOnce();
const saving = actions.save("thread", "Unsaved draft");
await flushPromises();
actions.updateDraft("thread", "Changed while saving");
context.resolve(readyContext);
saved.reject(new Error("Rename failed."));
await saving;
await flushPromises();
expect(resolveThreadTitleContext).toHaveBeenCalledOnce();
await actions.autoNameDraft("thread");
expect(generateThreadTitle).toHaveBeenCalledWith(readyContext, expect.any(AbortSignal));
expect(actions.editState("thread")).toEqual({ draft: "Generated title", generating: false });
});
it("does not report a delayed save failure after the edit is invalidated", async () => {
const saved = deferred<object>();
const addSystemMessage = vi.fn();
@ -220,6 +281,7 @@ describe("ThreadRenameEditorActions", () => {
});
actions.start("thread");
await flushPromises();
const autoName = actions.autoNameDraft("thread");
await flushPromises();
@ -240,10 +302,12 @@ describe("ThreadRenameEditorActions", () => {
const { actions } = actionsFixture({ generateThreadTitle });
actions.start("thread");
await flushPromises();
const firstAutoName = actions.autoNameDraft("thread");
await flushPromises();
actions.cancel("thread");
actions.start("thread");
await flushPromises();
const secondAutoName = actions.autoNameDraft("thread");
await flushPromises();
@ -265,11 +329,13 @@ describe("ThreadRenameEditorActions", () => {
const { actions } = actionsFixture({ generateThreadTitle, addSystemMessage });
actions.start("thread");
await flushPromises();
const staleAutoName = actions.autoNameDraft("thread");
await flushPromises();
actions.invalidate();
actions.start("thread");
await flushPromises();
await actions.autoNameDraft("thread");
oldTitle.resolve("Stale title");
await staleAutoName;
@ -283,6 +349,7 @@ function actionsFixture(
overrides: Partial<Pick<ThreadRenameEditorActionsHost, "ensureConnected" | "addSystemMessage">> & {
currentClient?: () => AppServerClient;
generateThreadTitle?: ThreadRenameEditorActionsHost["generateThreadTitle"];
resolveThreadTitleContext?: ThreadRenameEditorActionsHost["resolveThreadTitleContext"];
} = {},
): ThreadRenameEditorActionsHost & {
actions: ThreadRenameEditorActions;
@ -307,6 +374,8 @@ function actionsFixture(
notifyThreadRenamed(threadId, name);
return true;
},
resolveThreadTitleContext:
overrides.resolveThreadTitleContext ?? vi.fn().mockResolvedValue({ userRequest: "Please name this.", assistantResponse: "Done." }),
generateThreadTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"),
} satisfies ThreadRenameEditorActionsHost;
return { ...host, notifyThreadRenamed, actions: createThreadRenameEditorActions(host) };

View file

@ -290,7 +290,9 @@ describe("chat panel surface projections", () => {
state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } });
state = chatStateWith(state, { ui: { toolbarPanel: "history" } });
state = chatStateWith(state, { ui: { archiveConfirmThreadId: "thread-2" } });
state = chatStateWith(state, { ui: { rename: { kind: "editing", threadId: "thread-1", draft: "Active" } } });
state = chatStateWith(state, {
ui: { rename: { kind: "editing", threadId: "thread-1", draft: "Active", autoName: { kind: "unavailable" } } },
});
state = chatStateWith(state, {
connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }) },
});

View file

@ -264,7 +264,7 @@ describe("Toolbar decisions", () => {
disabled: false,
archiveDisabled: false,
canArchive: true,
rename: { draft: "Draft title", generating: false },
rename: { draft: "Draft title", generating: false, autoNameDisabled: false },
},
],
}),
@ -299,7 +299,7 @@ describe("Toolbar decisions", () => {
disabled: false,
archiveDisabled: false,
canArchive: true,
rename: { draft: "New title", generating: false },
rename: { draft: "New title", generating: false, autoNameDisabled: false },
},
],
}),
@ -338,7 +338,7 @@ describe("Toolbar decisions", () => {
disabled: false,
archiveDisabled: false,
canArchive: true,
rename: { draft: "Draft title", generating: true },
rename: { draft: "Draft title", generating: true, autoNameDisabled: false },
},
],
}),
@ -366,7 +366,7 @@ describe("Toolbar decisions", () => {
disabled: false,
archiveDisabled: false,
canArchive: true,
rename: { draft: "Draft title", generating: false },
rename: { draft: "Draft title", generating: false, autoNameDisabled: false },
},
],
}),
@ -376,6 +376,36 @@ describe("Toolbar decisions", () => {
parent.remove();
});
it("disables auto-name while title context is unavailable", () => {
const parent = document.createElement("div");
const autoNameThread = vi.fn();
mountToolbar(
parent,
toolbarModel({
historyOpen: true,
openPanel: "history",
threads: [
{
title: "Editing",
threadId: "editing",
selected: false,
disabled: false,
archiveDisabled: false,
canArchive: true,
rename: { draft: "Draft title", generating: false, autoNameDisabled: true },
},
],
}),
toolbarActions({ autoNameThread }),
);
const autoName = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]'));
expect(autoName.disabled).toBe(true);
autoName.click();
expect(autoNameThread).not.toHaveBeenCalled();
});
it("renders toolbar archive confirmation with the default action on the right", () => {
const parent = document.createElement("div");
const startArchiveThread = vi.fn();

View file

@ -44,7 +44,7 @@ function rowFixture(overrides: Partial<ThreadsRowModel> = {}): ThreadsRowModel {
title,
live: null,
selected: false,
rename: { active: false, draft: title, generating: false },
rename: { active: false, draft: title, generating: false, autoNameDisabled: true },
archiveConfirm: { active: false, defaultSaveMarkdown: false },
...overrides,
};
@ -171,7 +171,7 @@ describe("threads view renderer decisions", () => {
const actions = threadsViewActions();
const row = rowFixture({
title: "Old name",
rename: { active: true, draft: "Old name", generating: false },
rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false },
});
renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions);
@ -184,7 +184,11 @@ describe("threads view renderer decisions", () => {
renderThreadsViewShell(
parent,
{ status: "1 thread", loading: false, rows: [{ ...row, rename: { active: true, draft: "New name", generating: false } }] },
{
status: "1 thread",
loading: false,
rows: [{ ...row, rename: { active: true, draft: "New name", generating: false, autoNameDisabled: false } }],
},
actions,
);
const renamedInput = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
@ -208,7 +212,7 @@ describe("threads view renderer decisions", () => {
const actions = threadsViewActions();
const row = rowFixture({
title: "Old name",
rename: { active: true, draft: "Old name", generating: false },
rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false },
});
renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions);
@ -224,7 +228,7 @@ describe("threads view renderer decisions", () => {
const actions = threadsViewActions();
const row = rowFixture({
title: "Old name",
rename: { active: true, draft: "Old name", generating: true },
rename: { active: true, draft: "Old name", generating: true, autoNameDisabled: false },
});
renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions);

View file

@ -6,7 +6,11 @@ import { type ThreadsViewPanelActivity, threadRows, transitionThreadsRenameState
describe("threads view rename state", () => {
it("keeps auto-name results scoped to the active unchanged generation", () => {
const editing = transitionThreadsRenameState(undefined, { type: "started", draft: "Original draft" });
const generating = transitionThreadsRenameState(editing, { type: "generation-started", generationToken: 1 });
const ready = transitionThreadsRenameState(editing, {
type: "auto-name-context-resolved",
context: { userRequest: "Request", assistantResponse: "Response" },
});
const generating = transitionThreadsRenameState(ready, { type: "generation-started", generationToken: 1 });
if (generating?.kind !== "generating") throw new Error("Expected generating state");
const generated = transitionThreadsRenameState(generating, {
@ -18,11 +22,13 @@ describe("threads view rename state", () => {
expect(generated).toEqual({
kind: "generating",
draft: "Generated title",
autoName: { kind: "ready", context: { userRequest: "Request", assistantResponse: "Response" } },
generationToken: 1,
});
expect(transitionThreadsRenameState(generated, { type: "generation-finished", generationToken: generating.generationToken })).toEqual({
kind: "editing",
draft: "Generated title",
autoName: { kind: "ready", context: { userRequest: "Request", assistantResponse: "Response" } },
});
expect(transitionThreadsRenameState(generated, { type: "cancelled" })).toBeUndefined();
expect(transitionThreadsRenameState(undefined, { type: "draft-updated", draft: "Stray" })).toBeUndefined();

View file

@ -472,6 +472,9 @@ describe("CodexThreadsView", () => {
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
await waitForAsyncWork(() => {
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(false);
});
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
await waitForAsyncWork(() => {
@ -482,11 +485,75 @@ describe("CodexThreadsView", () => {
sortDirection: "asc",
itemsView: "full",
});
expect(threadTurnsList).toHaveBeenCalledOnce();
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Threads rename UI");
});
});
it("disables auto-name without publishing an error when completed history is unavailable", async () => {
const threadTurnsList = vi.fn().mockResolvedValue({ data: [], nextCursor: null });
connectionMock.state.client = clientFixture({
"thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
"thread/turns/list": threadTurnsList,
});
const view = await threadsView();
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
await waitForAsyncWork(() => {
expect(threadTurnsList).toHaveBeenCalledOnce();
});
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
expect(view.containerEl.querySelector(".codex-panel-threads__status")).toBeNull();
expect(view.containerEl.textContent).not.toContain("completed history");
expect(namingMock.generateThreadTitleWithCodex).not.toHaveBeenCalled();
});
it("keeps loading auto-name history while a rename save is in flight", async () => {
const history = deferred<unknown>();
const saved = deferred<object>();
const completedHistory = {
data: [
turnFixture([
{ type: "userMessage", id: "u1", clientId: null, content: [{ type: "text", text: "Name this", text_elements: [] }] },
{ type: "agentMessage", id: "a1", text: "Done.", phase: "final_answer", memoryCitation: null },
]),
],
nextCursor: null,
};
const threadTurnsList = vi.fn().mockReturnValue(history.promise);
namingMock.generateThreadTitleWithCodex.mockResolvedValue("Generated title");
connectionMock.state.client = clientFixture({
"thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
"thread/name/set": vi.fn(() => saved.promise),
"thread/turns/list": threadTurnsList,
});
const view = await threadsView();
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
await waitForAsyncWork(() => expect(threadTurnsList).toHaveBeenCalledOnce());
const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
if (!input) throw new Error("Missing rename input");
input.dispatchEvent(new FocusEvent("blur"));
changeInputValue(input, "Changed while saving");
history.resolve(completedHistory);
saved.reject(new Error("Rename failed."));
await waitForAsyncWork(() => {
expect(threadTurnsList).toHaveBeenCalledOnce();
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(false);
});
const autoName = view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]');
autoName?.click();
await waitForAsyncWork(() => {
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Generated title");
});
});
it("replaces the auto-name action with cancellation while generating", async () => {
const threadTurnsList = vi.fn().mockResolvedValue({
data: [
@ -512,6 +579,9 @@ describe("CodexThreadsView", () => {
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
await waitForAsyncWork(() => {
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(false);
});
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
await waitForAsyncWork(() => {
@ -545,6 +615,9 @@ describe("CodexThreadsView", () => {
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
await waitForAsyncWork(() => {
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(false);
});
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
await waitForAsyncWork(() => {
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
@ -585,6 +658,9 @@ describe("CodexThreadsView", () => {
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
await waitForAsyncWork(() => {
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(false);
});
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
await waitForAsyncWork(() => {
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();

View file

@ -8,7 +8,12 @@ describe("thread row core projection", () => {
const row = threadRowCoreProjection({
thread: thread({ name: " Saved name ", preview: "Preview" }),
selected: true,
renameState: { kind: "generating", draft: "Draft", generationToken: 1 },
renameState: {
kind: "generating",
draft: "Draft",
autoName: { kind: "ready", context: { userRequest: "Request", assistantResponse: "Response" } },
generationToken: 1,
},
archiveConfirmActive: true,
defaultArchiveSaveMarkdown: true,
});
@ -17,7 +22,7 @@ describe("thread row core projection", () => {
threadId: "thread",
title: "Saved name",
selected: true,
rename: { active: true, draft: "Draft", generating: true },
rename: { active: true, draft: "Draft", generating: true, autoNameDisabled: false },
archiveConfirm: { active: true, defaultSaveMarkdown: true },
});
});
@ -27,6 +32,7 @@ describe("thread row core projection", () => {
active: false,
draft: "Preview title",
generating: false,
autoNameDisabled: true,
});
expect(threadRowCoreProjection({ thread: thread({ name: null, preview: "" }), selected: false }).rename.draft).toBe("");
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model";
import type { ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model";
import {
createThreadTitleService,
type ThreadTitleService,
@ -8,7 +8,7 @@ import {
} from "../../../../src/features/threads/workflows/thread-title-service";
describe("ThreadTitleService", () => {
it("generates a title from visible context without saving it", async () => {
it("resolves visible context once and generates from the prepared context", async () => {
const generateThreadTitle = vi.fn().mockResolvedValue("Generated title");
const withClient = vi.fn().mockRejectedValue(new Error("should not read persisted context"));
const service = titleService({
@ -20,16 +20,19 @@ describe("ThreadTitleService", () => {
},
});
await expect(service.generateTitle("thread")).resolves.toBe("Generated title");
const context = await service.resolveContext("thread");
expect(context).toEqual(titleContext("visible request", "visible response"));
if (!context) throw new Error("Expected visible title context.");
await expect(service.generate(context)).resolves.toBe("Generated title");
expect(withClient).not.toHaveBeenCalled();
expect(generateThreadTitle).toHaveBeenCalledWith(titleContext("visible request", "visible response"), expect.any(AbortSignal));
});
it("throws the existing unavailable-context error when no context can be resolved", async () => {
it("reports unavailable context without turning it into a generation error", async () => {
const service = titleService();
await expect(service.generateTitle("thread")).rejects.toThrow(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
await expect(service.resolveContext("thread")).resolves.toBeNull();
});
it("prefers visible completed-turn context over completed summaries", () => {
@ -71,13 +74,14 @@ describe("ThreadTitleService", () => {
},
});
const staleTitle = service.generateTitle("thread");
const context = titleContext("request", "response");
const staleTitle = service.generate(context);
await Promise.resolve();
const staleSignal = generateTitle.mock.calls[0]?.[1];
service.invalidate();
expect(staleSignal?.aborted).toBe(true);
await expect(service.generateTitle("thread")).resolves.toBe("Fresh title");
await expect(service.generate(context)).resolves.toBe("Fresh title");
resolveOldTitle("Stale title");
await expect(staleTitle).rejects.toThrow("Thread title generation cancelled.");
});