mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(threads): disable unavailable auto-name actions
This commit is contained in:
parent
8a798a5bca
commit
2fb7d29841
21 changed files with 350 additions and 76 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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" }) },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue