mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(threads): serialize rename saves
This commit is contained in:
parent
f707da0274
commit
d74747fbf0
15 changed files with 297 additions and 98 deletions
|
|
@ -5,16 +5,21 @@ type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" }
|
|||
export type ThreadRenameLifecycleState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
|
||||
| { kind: "saving"; draft: string; autoName: ThreadRenameAutoNameState; saveToken: number }
|
||||
| { kind: "generating"; draft: string; autoName: Extract<ThreadRenameAutoNameState, { kind: "ready" }>; generationToken: number };
|
||||
|
||||
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
|
||||
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
|
||||
type ThreadRenameSavingState = Extract<ThreadRenameLifecycleState, { kind: "saving" }>;
|
||||
|
||||
export type ThreadRenameLifecycleEvent =
|
||||
| { type: "started"; draft: string }
|
||||
| { type: "draft-updated"; draft: string }
|
||||
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
|
||||
| { type: "cancelled" }
|
||||
| { type: "save-started"; saveToken: number }
|
||||
| { type: "save-failed"; saveToken: number }
|
||||
| { type: "save-succeeded"; saveToken: number }
|
||||
| { type: "generation-started"; generationToken: number }
|
||||
| { type: "generation-succeeded"; generationToken: number; draft: string }
|
||||
| { type: "generation-finished"; generationToken: number }
|
||||
|
|
@ -30,17 +35,26 @@ export function transitionThreadRenameLifecycleState(
|
|||
): ThreadRenameLifecycleState {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
if (state.kind === "saving") return state;
|
||||
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;
|
||||
if (state.kind !== "editing" && state.kind !== "saving") return state;
|
||||
return {
|
||||
...state,
|
||||
autoName: event.context ? { kind: "ready", context: event.context } : { kind: "unavailable" },
|
||||
};
|
||||
case "cancelled":
|
||||
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
return state.kind === "saving" || state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
case "save-started":
|
||||
return state.kind === "editing" ? { ...state, kind: "saving", saveToken: event.saveToken } : state;
|
||||
case "save-failed":
|
||||
return threadRenameSaveStillActive(state, event.saveToken)
|
||||
? { kind: "editing", draft: state.draft, autoName: state.autoName }
|
||||
: state;
|
||||
case "save-succeeded":
|
||||
return threadRenameSaveStillActive(state, event.saveToken) ? initialThreadRenameLifecycleState() : state;
|
||||
case "generation-started":
|
||||
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
|
||||
return {
|
||||
|
|
@ -69,6 +83,10 @@ export function threadRenameGenerationStillActive(
|
|||
return state.kind === "generating" && state.generationToken === generationToken;
|
||||
}
|
||||
|
||||
export function threadRenameSaveStillActive(state: ThreadRenameLifecycleState, saveToken: number): state is ThreadRenameSavingState {
|
||||
return state.kind === "saving" && state.saveToken === saveToken;
|
||||
}
|
||||
|
||||
function unhandledThreadRenameLifecycleEvent(event: never): never {
|
||||
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
type ThreadRenameLifecycleEvent,
|
||||
type ThreadRenameLifecycleState,
|
||||
threadRenameGenerationStillActive,
|
||||
threadRenameSaveStillActive,
|
||||
transitionThreadRenameLifecycleState,
|
||||
} from "../../../../domain/threads/rename-lifecycle";
|
||||
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
|
|
@ -14,6 +15,7 @@ import { patchObject } from "./patch";
|
|||
export type ChatRenameUiState = { readonly kind: "idle" } | (ThreadRenameActiveState & { readonly threadId: string });
|
||||
|
||||
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
|
||||
export type ChatRenameSavingUiState = Extract<ChatRenameUiState, { kind: "saving" }>;
|
||||
type ChatRenameUiAction = Extract<
|
||||
UiAction,
|
||||
{
|
||||
|
|
@ -22,6 +24,9 @@ type ChatRenameUiAction = Extract<
|
|||
| "ui/rename-draft-updated"
|
||||
| "ui/rename-auto-name-context-resolved"
|
||||
| "ui/rename-cancelled"
|
||||
| "ui/rename-save-started"
|
||||
| "ui/rename-save-failed"
|
||||
| "ui/rename-save-succeeded"
|
||||
| "ui/rename-generation-started"
|
||||
| "ui/rename-generation-succeeded"
|
||||
| "ui/rename-generation-finished"
|
||||
|
|
@ -75,6 +80,9 @@ export type UiAction =
|
|||
| { 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-save-started"; threadId: string; saveToken: number }
|
||||
| { type: "ui/rename-save-failed"; threadId: string; saveToken: number }
|
||||
| { type: "ui/rename-save-succeeded"; threadId: string; saveToken: number }
|
||||
| { type: "ui/rename-generation-started"; threadId: string; generationToken: number }
|
||||
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
|
||||
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number }
|
||||
|
|
@ -104,6 +112,9 @@ export function isUiAction(action: { type: string }): action is UiAction {
|
|||
case "ui/rename-draft-updated":
|
||||
case "ui/rename-auto-name-context-resolved":
|
||||
case "ui/rename-cancelled":
|
||||
case "ui/rename-save-started":
|
||||
case "ui/rename-save-failed":
|
||||
case "ui/rename-save-succeeded":
|
||||
case "ui/rename-generation-started":
|
||||
case "ui/rename-generation-succeeded":
|
||||
case "ui/rename-generation-finished":
|
||||
|
|
@ -129,6 +140,9 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
|
|||
case "ui/rename-draft-updated":
|
||||
case "ui/rename-auto-name-context-resolved":
|
||||
case "ui/rename-cancelled":
|
||||
case "ui/rename-save-started":
|
||||
case "ui/rename-save-failed":
|
||||
case "ui/rename-save-succeeded":
|
||||
case "ui/rename-generation-started":
|
||||
case "ui/rename-generation-succeeded":
|
||||
case "ui/rename-generation-finished":
|
||||
|
|
@ -177,6 +191,10 @@ export function renameGenerationStillActive(
|
|||
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken);
|
||||
}
|
||||
|
||||
export function renameSaveStillActive(state: ChatRenameUiState, threadId: string, saveToken: number): state is ChatRenameSavingUiState {
|
||||
return state.kind === "saving" && state.threadId === threadId && threadRenameSaveStillActive(state, saveToken);
|
||||
}
|
||||
|
||||
export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
|
||||
if (state.disclosures.approvalDetails.size === 0) return state;
|
||||
return patchObject(state, {
|
||||
|
|
@ -258,6 +276,7 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
|
|||
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
|
||||
switch (action.type) {
|
||||
case "ui/rename-started":
|
||||
if (state.kind === "saving") return state;
|
||||
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 });
|
||||
|
|
@ -268,6 +287,12 @@ function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenam
|
|||
});
|
||||
case "ui/rename-cancelled":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
|
||||
case "ui/rename-save-started":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-started", saveToken: action.saveToken });
|
||||
case "ui/rename-save-failed":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-failed", saveToken: action.saveToken });
|
||||
case "ui/rename-save-succeeded":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "save-succeeded", saveToken: action.saveToken });
|
||||
case "ui/rename-generation-started":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||
type: "generation-started",
|
||||
|
|
@ -315,6 +340,8 @@ function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState,
|
|||
switch (state.kind) {
|
||||
case "editing":
|
||||
return { kind: "editing", draft: state.draft, autoName: state.autoName };
|
||||
case "saving":
|
||||
return { kind: "saving", draft: state.draft, autoName: state.autoName, saveToken: state.saveToken };
|
||||
case "generating":
|
||||
return {
|
||||
kind: "generating",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { ThreadTitleContext } from "../../../../domain/threads/title-genera
|
|||
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import { type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state";
|
||||
import { type ChatRenameUiState, renameGenerationStillActive, renameSaveStillActive } from "../state/ui-state";
|
||||
import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
|
||||
|
||||
interface RenameEditState {
|
||||
|
|
@ -34,6 +34,7 @@ export interface ThreadRenameEditorActions {
|
|||
|
||||
export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsHost): ThreadRenameEditorActions {
|
||||
let nextRenameGenerationToken = 1;
|
||||
let nextRenameSaveToken = 1;
|
||||
let activeGeneration: { threadId: string; generationToken: number; controller: AbortController } | null = null;
|
||||
let activeContextPreparation: { threadId: string } | null = null;
|
||||
|
||||
|
|
@ -59,6 +60,8 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
},
|
||||
|
||||
start(threadId: string): void {
|
||||
const current = renameState(host);
|
||||
if (current.kind === "saving") return;
|
||||
const thread = host.stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId);
|
||||
if (!thread) return;
|
||||
abortActiveGeneration();
|
||||
|
|
@ -71,6 +74,8 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
},
|
||||
|
||||
cancel(threadId: string): void {
|
||||
const current = renameState(host);
|
||||
if (current.kind === "saving" && current.threadId === threadId) return;
|
||||
abortGeneration(threadId);
|
||||
if (activeContextPreparation?.threadId === threadId) activeContextPreparation = null;
|
||||
dispatch(host, { type: "ui/rename-cancelled", threadId });
|
||||
|
|
@ -85,24 +90,22 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
|
||||
async save(threadId: string, value: string): Promise<void> {
|
||||
const current = renameState(host);
|
||||
if (current.kind === "idle" || current.threadId !== threadId || current.kind === "generating") return;
|
||||
const editingState = current;
|
||||
if (current.kind !== "editing" || current.threadId !== threadId) return;
|
||||
const saveToken = nextRenameSaveToken;
|
||||
dispatch(host, { type: "ui/rename-save-started", threadId, saveToken });
|
||||
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||
nextRenameSaveToken += 1;
|
||||
|
||||
try {
|
||||
await host.ensureConnected();
|
||||
if (!renameEditStillCurrent(host, threadId, editingState.draft)) return;
|
||||
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||
|
||||
const result = await host.renameThread(threadId, value);
|
||||
if (!result) {
|
||||
if (renameEditStillCurrent(host, threadId, editingState.draft)) action.cancel(threadId);
|
||||
return;
|
||||
}
|
||||
if (renameEditStillCurrent(host, threadId, editingState.draft)) {
|
||||
dispatch(host, { type: "ui/rename-cleared" });
|
||||
}
|
||||
await host.renameThread(threadId, value);
|
||||
dispatch(host, { type: "ui/rename-save-succeeded", threadId, saveToken });
|
||||
} catch (error) {
|
||||
if (!renameEditStillCurrent(host, threadId, editingState.draft)) return;
|
||||
if (!renameSaveStillActive(renameState(host), threadId, saveToken)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
dispatch(host, { type: "ui/rename-save-failed", threadId, saveToken });
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -168,7 +171,7 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
if (activeContextPreparation !== preparation) return;
|
||||
activeContextPreparation = null;
|
||||
const current = renameState(host);
|
||||
if (current.kind !== "editing" || current.threadId !== threadId) return;
|
||||
if ((current.kind !== "editing" && current.kind !== "saving") || current.threadId !== threadId) return;
|
||||
dispatch(host, { type: "ui/rename-auto-name-context-resolved", threadId, context });
|
||||
}
|
||||
}
|
||||
|
|
@ -181,11 +184,6 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,13 +206,17 @@ function toolbarThreadRows(input: {
|
|||
title: core.title,
|
||||
threadId: core.threadId,
|
||||
selected: core.selected,
|
||||
disabled: false,
|
||||
openDisabled: false,
|
||||
renameDisabled: input.renameState.kind === "saving",
|
||||
archiveDisabled: input.turnBusy,
|
||||
canArchive: true,
|
||||
archiveConfirm: core.archiveConfirm,
|
||||
rename: core.rename.active
|
||||
? { draft: core.rename.draft, generating: core.rename.generating, autoNameDisabled: core.rename.autoNameDisabled }
|
||||
? {
|
||||
draft: core.rename.draft,
|
||||
generating: core.rename.generating,
|
||||
saving: core.rename.saving,
|
||||
autoNameDisabled: core.rename.autoNameDisabled,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ export interface ToolbarThreadRow {
|
|||
title: string;
|
||||
threadId: string;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
openDisabled?: boolean;
|
||||
renameDisabled: boolean;
|
||||
archiveDisabled: boolean;
|
||||
canArchive: boolean;
|
||||
archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean };
|
||||
rename: {
|
||||
draft: string;
|
||||
generating: boolean;
|
||||
saving: boolean;
|
||||
autoNameDisabled: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
|
@ -411,7 +411,6 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
|
|||
label={thread.title}
|
||||
selected={thread.selected}
|
||||
selectionStyle="row"
|
||||
disabled={thread.openDisabled ?? thread.disabled}
|
||||
className="codex-panel__thread"
|
||||
onClick={() => {
|
||||
actions.resume(thread.threadId);
|
||||
|
|
@ -422,7 +421,7 @@ function ThreadListRow({ thread, actions }: { thread: ToolbarThreadRow; actions:
|
|||
icon="pencil"
|
||||
label="Rename thread"
|
||||
className="codex-panel__thread-action"
|
||||
disabled={thread.disabled}
|
||||
disabled={thread.renameDisabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.rename.start(thread.threadId);
|
||||
|
|
@ -490,10 +489,12 @@ function ArchiveModeButton({
|
|||
function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; actions: ToolbarThreadActions }): UiNode {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const generating = thread.rename?.generating ?? false;
|
||||
const saving = thread.rename?.saving ?? false;
|
||||
const renameBusy = generating || saving;
|
||||
const draft = thread.rename?.draft ?? thread.title;
|
||||
useLayoutEffect(() => {
|
||||
if (!generating) focusToolbarRenameInput(inputRef.current);
|
||||
}, [draft, generating]);
|
||||
if (!renameBusy) focusToolbarRenameInput(inputRef.current);
|
||||
}, [draft, renameBusy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -508,23 +509,23 @@ function ThreadRenameRow({ thread, actions }: { thread: ToolbarThreadRow; action
|
|||
className="codex-panel-ui__nav-inline-input codex-panel__thread-rename-input"
|
||||
type="text"
|
||||
value={draft}
|
||||
disabled={generating}
|
||||
disabled={renameBusy}
|
||||
onInput={(event) => {
|
||||
actions.rename.updateDraft(thread.threadId, event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (!event.isComposing && !generating) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
if (!event.isComposing && !renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
actions.rename.cancel(thread.threadId);
|
||||
if (!renameBusy) actions.rename.cancel(thread.threadId);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!generating) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
if (!renameBusy) actions.rename.save(thread.threadId, event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -534,7 +535,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)}
|
||||
disabled={saving || (!generating && (thread.rename?.autoNameDisabled ?? true))}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export class ThreadsViewSession {
|
|||
private readonly renameContextPreparations = new Map<string, ThreadTitleContextPreparation>();
|
||||
private readonly renameGenerationControllers = new Map<string, { generationToken: number; controller: AbortController }>();
|
||||
private nextRenameGenerationToken = 1;
|
||||
private nextRenameSaveToken = 1;
|
||||
private unsubscribeThreads: (() => void) | null = null;
|
||||
private archiveConfirmThreadId: string | null = null;
|
||||
|
||||
|
|
@ -261,6 +262,8 @@ export class ThreadsViewSession {
|
|||
}
|
||||
|
||||
private startRename(threadId: string, value: string): void {
|
||||
const current = this.renameStates.get(threadId);
|
||||
if (current?.kind === "saving") return;
|
||||
this.archiveConfirmThreadId = null;
|
||||
this.transitionRenameState(threadId, { type: "started", draft: value });
|
||||
this.render();
|
||||
|
|
@ -273,6 +276,7 @@ export class ThreadsViewSession {
|
|||
}
|
||||
|
||||
private cancelRename(threadId: string): void {
|
||||
if (this.renameStates.get(threadId)?.kind === "saving") return;
|
||||
this.abortAutoName(threadId);
|
||||
this.renameContextPreparations.delete(threadId);
|
||||
this.transitionRenameState(threadId, { type: "cancelled" });
|
||||
|
|
@ -288,24 +292,30 @@ export class ThreadsViewSession {
|
|||
|
||||
private async saveRename(threadId: string, value: string): Promise<void> {
|
||||
const lease = this.captureOperationLease();
|
||||
const editingState = this.renameStates.get(threadId);
|
||||
if (!editingState || editingState.kind === "generating") return;
|
||||
const previousState = this.renameStates.get(threadId);
|
||||
if (previousState?.kind !== "editing") return;
|
||||
const saveToken = this.nextRenameSaveToken;
|
||||
const savingState = this.transitionRenameState(threadId, { type: "save-started", saveToken });
|
||||
if (savingState === previousState || savingState?.kind !== "saving") return;
|
||||
this.nextRenameSaveToken += 1;
|
||||
this.render();
|
||||
try {
|
||||
const result = await this.operations.renameThread(threadId, value, {
|
||||
shouldStart: () => this.operationContextIsCurrent(lease),
|
||||
shouldPublish: () => this.operationContextIsCurrent(lease),
|
||||
await this.operations.renameThread(threadId, value, {
|
||||
shouldStart: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
|
||||
shouldPublish: () => this.operationContextIsCurrent(lease) && this.renameSaveStillActive(threadId, saveToken),
|
||||
});
|
||||
if (!this.operationViewIsCurrent(lease) || !this.renameEditStillCurrent(threadId, editingState.draft)) return;
|
||||
if (!result) {
|
||||
this.cancelRename(threadId);
|
||||
return;
|
||||
if (!this.operationViewIsCurrent(lease)) return;
|
||||
const currentState = this.renameStates.get(threadId);
|
||||
const nextState = this.transitionRenameState(threadId, { type: "save-succeeded", saveToken });
|
||||
if (nextState !== currentState) {
|
||||
if (!nextState) this.renameContextPreparations.delete(threadId);
|
||||
this.render();
|
||||
}
|
||||
this.renameStates.delete(threadId);
|
||||
this.renameContextPreparations.delete(threadId);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
if (!this.operationViewIsCurrent(lease) || !this.renameEditStillCurrent(threadId, editingState.draft)) return;
|
||||
if (!this.operationViewIsCurrent(lease) || !this.renameSaveStillActive(threadId, saveToken)) return;
|
||||
this.noticeError(error);
|
||||
this.transitionRenameState(threadId, { type: "save-failed", saveToken });
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +419,7 @@ export class ThreadsViewSession {
|
|||
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;
|
||||
if (state?.kind !== "editing" && state?.kind !== "saving") return;
|
||||
this.transitionRenameState(threadId, { type: "auto-name-context-resolved", context });
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -429,9 +439,9 @@ export class ThreadsViewSession {
|
|||
return nextState;
|
||||
}
|
||||
|
||||
private renameEditStillCurrent(threadId: string, draft: string): boolean {
|
||||
private renameSaveStillActive(threadId: string, saveToken: number): boolean {
|
||||
const current = this.renameStates.get(threadId);
|
||||
return current?.kind === "editing" && current.draft === draft;
|
||||
return current?.kind === "saving" && current.saveToken === saveToken;
|
||||
}
|
||||
|
||||
private viewWindow(): Window {
|
||||
|
|
|
|||
|
|
@ -226,9 +226,10 @@ function threadArchiveDisabled(row: ThreadsRowModel): boolean {
|
|||
|
||||
function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions: ThreadsViewShellActions; className: string }): UiNode {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const renameBusy = row.rename.generating || row.rename.saving;
|
||||
useLayoutEffect(() => {
|
||||
if (!row.rename.generating) focusThreadsRenameInput(inputRef.current);
|
||||
}, [row.rename.draft, row.rename.generating]);
|
||||
if (!renameBusy) focusThreadsRenameInput(inputRef.current);
|
||||
}, [row.rename.draft, renameBusy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -239,23 +240,23 @@ function RenameRow({ row, actions, className }: { row: ThreadsRowModel; actions:
|
|||
className="codex-panel-ui__nav-inline-input codex-panel-threads__rename-input"
|
||||
type="text"
|
||||
value={row.rename.draft}
|
||||
disabled={row.rename.generating}
|
||||
disabled={renameBusy}
|
||||
onInput={(event) => {
|
||||
actions.updateRename(row.threadId, event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (!event.isComposing && !row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
if (!event.isComposing && !renameBusy) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
actions.cancelRename(row.threadId);
|
||||
if (!renameBusy) actions.cancelRename(row.threadId);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!row.rename.generating) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
if (!renameBusy) actions.saveRename(row.threadId, event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -265,7 +266,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}
|
||||
disabled={row.rename.saving || (!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 saving: boolean;
|
||||
readonly autoNameDisabled: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ export function threadRowCoreProjection(input: {
|
|||
active: rename !== undefined,
|
||||
draft: rename?.draft ?? threadRenameDraftTitle(input.thread),
|
||||
generating: rename?.kind === "generating",
|
||||
saving: rename?.kind === "saving",
|
||||
autoNameDisabled: rename?.autoName.kind !== "ready",
|
||||
},
|
||||
archiveConfirm: {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,25 @@ describe("thread rename lifecycle", () => {
|
|||
|
||||
expect(transitionThreadRenameLifecycleState(idle, { type: "draft-updated", draft: "Stray" })).toBe(idle);
|
||||
});
|
||||
|
||||
it("serializes saving until the matching operation finishes", () => {
|
||||
const editing = expectRenameState(
|
||||
transitionThreadRenameLifecycleState(initialThreadRenameLifecycleState(), { type: "started", draft: "Draft" }),
|
||||
);
|
||||
const saving = transitionThreadRenameLifecycleState(editing, { type: "save-started", saveToken: 1 });
|
||||
if (saving.kind !== "saving") throw new Error("Expected saving rename state.");
|
||||
|
||||
expect(transitionThreadRenameLifecycleState(saving, { type: "draft-updated", draft: "Changed" })).toBe(saving);
|
||||
expect(transitionThreadRenameLifecycleState(saving, { type: "cancelled" })).toBe(saving);
|
||||
expect(transitionThreadRenameLifecycleState(saving, { type: "started", draft: "Replacement" })).toBe(saving);
|
||||
expect(transitionThreadRenameLifecycleState(saving, { type: "save-succeeded", saveToken: 2 })).toBe(saving);
|
||||
|
||||
const failed = transitionThreadRenameLifecycleState(saving, { type: "save-failed", saveToken: 1 });
|
||||
expect(failed).toEqual({ kind: "editing", draft: "Draft", autoName: { kind: "checking" } });
|
||||
|
||||
const savingAgain = transitionThreadRenameLifecycleState(failed, { type: "save-started", saveToken: 2 });
|
||||
expect(transitionThreadRenameLifecycleState(savingAgain, { type: "save-succeeded", saveToken: 2 })).toEqual({ kind: "idle" });
|
||||
});
|
||||
});
|
||||
|
||||
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
|
||||
|
|
|
|||
|
|
@ -149,11 +149,11 @@ describe("ThreadRenameEditorActions", () => {
|
|||
expect(actions.editState("thread")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not save after cancellation while connection is pending", async () => {
|
||||
it("blocks cancellation while a rename save is pending", async () => {
|
||||
const connection = deferred<undefined>();
|
||||
const renameThreadRequest = vi.fn().mockResolvedValue({});
|
||||
const client = fakeClient({ renameThreadRequest });
|
||||
const { actions } = actionsFixture({
|
||||
const { actions, stateStore } = actionsFixture({
|
||||
ensureConnected: vi.fn(() => connection.promise),
|
||||
currentClient: () => client,
|
||||
});
|
||||
|
|
@ -161,10 +161,12 @@ describe("ThreadRenameEditorActions", () => {
|
|||
actions.start("thread");
|
||||
const save = actions.save("thread", "Saved title");
|
||||
actions.cancel("thread");
|
||||
expect(actions.editState("thread")).toEqual({ draft: "Thread preview", generating: false });
|
||||
expect(stateStore.getState().ui.rename.kind).toBe("saving");
|
||||
connection.resolve(undefined);
|
||||
await save;
|
||||
|
||||
expect(renameThreadRequest).not.toHaveBeenCalled();
|
||||
expect(renameThreadRequest).toHaveBeenCalledOnce();
|
||||
expect(actions.editState("thread")).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -205,7 +207,7 @@ describe("ThreadRenameEditorActions", () => {
|
|||
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({
|
||||
const { actions, stateStore } = actionsFixture({
|
||||
currentClient: () => fakeClient({ renameThreadRequest: vi.fn(() => saved.promise) }),
|
||||
resolveThreadTitleContext,
|
||||
generateThreadTitle,
|
||||
|
|
@ -219,6 +221,8 @@ describe("ThreadRenameEditorActions", () => {
|
|||
await flushPromises();
|
||||
actions.updateDraft("thread", "Changed while saving");
|
||||
context.resolve(readyContext);
|
||||
await flushPromises();
|
||||
expect(stateStore.getState().ui.rename.kind).toBe("saving");
|
||||
saved.reject(new Error("Rename failed."));
|
||||
await saving;
|
||||
await flushPromises();
|
||||
|
|
@ -251,7 +255,7 @@ describe("ThreadRenameEditorActions", () => {
|
|||
expect(addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not clear a newer inline rename when an older save finishes", async () => {
|
||||
it("blocks starting another inline rename while a save is pending", async () => {
|
||||
const saved = deferred<object>();
|
||||
const renameThreadRequest = vi.fn(() => saved.promise);
|
||||
const { actions, stateStore, notifyThreadRenamed } = actionsFixture({
|
||||
|
|
@ -259,19 +263,21 @@ describe("ThreadRenameEditorActions", () => {
|
|||
});
|
||||
|
||||
actions.start("thread");
|
||||
actions.updateDraft("thread", "Saved title");
|
||||
const save = actions.save("thread", " Saved title ");
|
||||
await flushPromises();
|
||||
|
||||
actions.cancel("thread");
|
||||
actions.start("thread");
|
||||
actions.updateDraft("thread", "New draft");
|
||||
expect(actions.editState("thread")).toEqual({ draft: "Saved title", generating: false });
|
||||
saved.resolve({});
|
||||
await save;
|
||||
|
||||
expect(renameThreadRequest).toHaveBeenCalledWith({ threadId: "thread", name: "Saved title" });
|
||||
expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Saved title");
|
||||
expect(notifyThreadRenamed).toHaveBeenCalledWith("thread", "Saved title");
|
||||
expect(actions.editState("thread")).toEqual({ draft: "New draft", generating: false });
|
||||
expect(actions.editState("thread")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores draft updates while auto-name generation is active", async () => {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,38 @@ describe("chat panel surface projections", () => {
|
|||
unmountUiRoot(parent);
|
||||
});
|
||||
|
||||
it("disables other rename actions while a rename save is pending", () => {
|
||||
let state = chatStateFixture({
|
||||
activeThread: { id: "thread" },
|
||||
threadList: { listedThreads: [threadFixture("thread", "Thread"), threadFixture("other", "Other")] },
|
||||
});
|
||||
state = chatStateWith(state, {
|
||||
ui: {
|
||||
toolbarPanel: "history",
|
||||
rename: {
|
||||
kind: "saving",
|
||||
threadId: "thread",
|
||||
draft: "Thread",
|
||||
autoName: { kind: "unavailable" },
|
||||
saveToken: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const parent = renderWithShellModels(state, (models) =>
|
||||
h(ChatPanelToolbar, {
|
||||
model: models.toolbar,
|
||||
stateStore: createChatStateStore(state),
|
||||
surface: toolbarSurfaceFixture(),
|
||||
actions: toolbarActionsFixture(),
|
||||
}),
|
||||
);
|
||||
|
||||
const renameButtons = [...parent.querySelectorAll<HTMLButtonElement>('[aria-label="Rename thread"]')];
|
||||
expect(renameButtons).toHaveLength(1);
|
||||
expect(renameButtons[0]?.disabled).toBe(true);
|
||||
unmountUiRoot(parent);
|
||||
});
|
||||
|
||||
it("disables subagent chat actions except starting a new chat", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
|
|
|
|||
|
|
@ -256,15 +256,23 @@ describe("Toolbar decisions", () => {
|
|||
historyOpen: true,
|
||||
openPanel: "history",
|
||||
threads: [
|
||||
{ title: "Thread", threadId: "thread", selected: true, disabled: false, archiveDisabled: false, canArchive: true, rename: null },
|
||||
{
|
||||
title: "Thread",
|
||||
threadId: "thread",
|
||||
selected: true,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: null,
|
||||
},
|
||||
{
|
||||
title: "Editing",
|
||||
threadId: "editing",
|
||||
selected: false,
|
||||
disabled: false,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: { draft: "Draft title", generating: false, autoNameDisabled: false },
|
||||
rename: { draft: "Draft title", generating: false, saving: false, autoNameDisabled: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -291,15 +299,23 @@ describe("Toolbar decisions", () => {
|
|||
historyOpen: true,
|
||||
openPanel: "history",
|
||||
threads: [
|
||||
{ title: "Thread", threadId: "thread", selected: true, disabled: false, archiveDisabled: false, canArchive: true, rename: null },
|
||||
{
|
||||
title: "Thread",
|
||||
threadId: "thread",
|
||||
selected: true,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: null,
|
||||
},
|
||||
{
|
||||
title: "Editing",
|
||||
threadId: "editing",
|
||||
selected: false,
|
||||
disabled: false,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: { draft: "New title", generating: false, autoNameDisabled: false },
|
||||
rename: { draft: "New title", generating: false, saving: false, autoNameDisabled: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -335,10 +351,10 @@ describe("Toolbar decisions", () => {
|
|||
title: "Editing",
|
||||
threadId: "editing",
|
||||
selected: false,
|
||||
disabled: false,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: { draft: "Draft title", generating: true, autoNameDisabled: false },
|
||||
rename: { draft: "Draft title", generating: true, saving: false, autoNameDisabled: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -363,10 +379,10 @@ describe("Toolbar decisions", () => {
|
|||
title: "Editing",
|
||||
threadId: "editing",
|
||||
selected: false,
|
||||
disabled: false,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: { draft: "Draft title", generating: false, autoNameDisabled: false },
|
||||
rename: { draft: "Draft title", generating: false, saving: false, autoNameDisabled: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -376,6 +392,44 @@ describe("Toolbar decisions", () => {
|
|||
parent.remove();
|
||||
});
|
||||
|
||||
it("locks rename controls while saving", () => {
|
||||
const parent = document.createElement("div");
|
||||
const saveRenameThread = vi.fn();
|
||||
const cancelRenameThread = vi.fn();
|
||||
const autoNameThread = vi.fn();
|
||||
|
||||
mountToolbar(
|
||||
parent,
|
||||
toolbarModel({
|
||||
historyOpen: true,
|
||||
openPanel: "history",
|
||||
threads: [
|
||||
{
|
||||
title: "Editing",
|
||||
threadId: "editing",
|
||||
selected: false,
|
||||
renameDisabled: true,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: { draft: "Draft title", generating: false, saving: true, autoNameDisabled: false },
|
||||
},
|
||||
],
|
||||
}),
|
||||
toolbarActions({ saveRenameThread, cancelRenameThread, autoNameThread }),
|
||||
);
|
||||
|
||||
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input"));
|
||||
expect(input.disabled).toBe(true);
|
||||
input.dispatchEvent(new FocusEvent("blur"));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
const autoName = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]'));
|
||||
expect(autoName.disabled).toBe(true);
|
||||
autoName.click();
|
||||
expect(saveRenameThread).not.toHaveBeenCalled();
|
||||
expect(cancelRenameThread).not.toHaveBeenCalled();
|
||||
expect(autoNameThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables auto-name while title context is unavailable", () => {
|
||||
const parent = document.createElement("div");
|
||||
const autoNameThread = vi.fn();
|
||||
|
|
@ -390,10 +444,10 @@ describe("Toolbar decisions", () => {
|
|||
title: "Editing",
|
||||
threadId: "editing",
|
||||
selected: false,
|
||||
disabled: false,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: { draft: "Draft title", generating: false, autoNameDisabled: true },
|
||||
rename: { draft: "Draft title", generating: false, saving: false, autoNameDisabled: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -421,7 +475,7 @@ describe("Toolbar decisions", () => {
|
|||
title: "Thread",
|
||||
threadId: "thread",
|
||||
selected: true,
|
||||
disabled: false,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
archiveConfirm: { active: true, defaultSaveMarkdown: true },
|
||||
|
|
@ -461,7 +515,7 @@ describe("Toolbar decisions", () => {
|
|||
title: "Thread",
|
||||
threadId: "thread",
|
||||
selected: true,
|
||||
disabled: false,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: true,
|
||||
canArchive: true,
|
||||
archiveConfirm: { active: true, defaultSaveMarkdown: true },
|
||||
|
|
@ -558,7 +612,15 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
|
|||
debugDetails: () => "{}",
|
||||
openPanel: null,
|
||||
threads: [
|
||||
{ title: "Thread", threadId: "thread", selected: true, disabled: false, archiveDisabled: false, canArchive: true, rename: null },
|
||||
{
|
||||
title: "Thread",
|
||||
threadId: "thread",
|
||||
selected: true,
|
||||
renameDisabled: false,
|
||||
archiveDisabled: false,
|
||||
canArchive: true,
|
||||
rename: null,
|
||||
},
|
||||
],
|
||||
connectLabel: "Reconnect",
|
||||
permissionsAndApprovals: [{ title: "Permissions", rows: [{ label: "Thread", value: "(none)" }] }],
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function rowFixture(overrides: Partial<ThreadsRowModel> = {}): ThreadsRowModel {
|
|||
title,
|
||||
live: null,
|
||||
selected: false,
|
||||
rename: { active: false, draft: title, generating: false, autoNameDisabled: true },
|
||||
rename: { active: false, draft: title, generating: false, saving: false, autoNameDisabled: true },
|
||||
archiveConfirm: { active: false, defaultSaveMarkdown: false },
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -196,7 +196,7 @@ describe("threads view renderer decisions", () => {
|
|||
const actions = threadsViewActions();
|
||||
const row = rowFixture({
|
||||
title: "Old name",
|
||||
rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false },
|
||||
rename: { active: true, draft: "Old name", generating: false, saving: false, autoNameDisabled: false },
|
||||
});
|
||||
|
||||
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
|
||||
|
|
@ -212,7 +212,7 @@ describe("threads view renderer decisions", () => {
|
|||
{
|
||||
status: null,
|
||||
loading: false,
|
||||
rows: [{ ...row, rename: { active: true, draft: "New name", generating: false, autoNameDisabled: false } }],
|
||||
rows: [{ ...row, rename: { active: true, draft: "New name", generating: false, saving: false, autoNameDisabled: false } }],
|
||||
},
|
||||
actions,
|
||||
);
|
||||
|
|
@ -237,7 +237,7 @@ describe("threads view renderer decisions", () => {
|
|||
const actions = threadsViewActions();
|
||||
const row = rowFixture({
|
||||
title: "Old name",
|
||||
rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false },
|
||||
rename: { active: true, draft: "Old name", generating: false, saving: false, autoNameDisabled: false },
|
||||
});
|
||||
|
||||
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
|
||||
|
|
@ -253,7 +253,7 @@ describe("threads view renderer decisions", () => {
|
|||
const actions = threadsViewActions();
|
||||
const row = rowFixture({
|
||||
title: "Old name",
|
||||
rename: { active: true, draft: "Old name", generating: true, autoNameDisabled: false },
|
||||
rename: { active: true, draft: "Old name", generating: true, saving: false, autoNameDisabled: false },
|
||||
});
|
||||
|
||||
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
|
||||
|
|
@ -265,6 +265,28 @@ describe("threads view renderer decisions", () => {
|
|||
expect(actions.cancelAutoName).toHaveBeenCalledWith("thread");
|
||||
});
|
||||
|
||||
it("locks threads view rename controls while saving", () => {
|
||||
const parent = document.createElement("div");
|
||||
const actions = threadsViewActions();
|
||||
const row = rowFixture({
|
||||
title: "Old name",
|
||||
rename: { active: true, draft: "Old name", generating: false, saving: true, autoNameDisabled: false },
|
||||
});
|
||||
|
||||
renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions);
|
||||
|
||||
const input = expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input"));
|
||||
expect(input.disabled).toBe(true);
|
||||
input.dispatchEvent(new FocusEvent("blur"));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
const autoName = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]'));
|
||||
expect(autoName.disabled).toBe(true);
|
||||
autoName.click();
|
||||
expect(actions.saveRename).not.toHaveBeenCalled();
|
||||
expect(actions.cancelRename).not.toHaveBeenCalled();
|
||||
expect(actions.autoNameThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables history expansion during any shared thread fetch", () => {
|
||||
const parent = document.createElement("div");
|
||||
const actions = threadsViewActions();
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ describe("CodexThreadsView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not clear a newer rename edit when an older save finishes", async () => {
|
||||
it("keeps the rename editor locked until a save finishes", async () => {
|
||||
const saved = deferred<object>();
|
||||
const renameThreadRequest = vi.fn(() => saved.promise);
|
||||
connectionMock.state.client = clientFixture({
|
||||
|
|
@ -471,10 +471,8 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
firstInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
const secondInput = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
|
||||
expect(secondInput).not.toBeNull();
|
||||
if (!secondInput) return;
|
||||
changeInputValue(secondInput, "New draft");
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(true);
|
||||
expect(view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')).toBeNull();
|
||||
|
||||
saved.resolve({});
|
||||
|
||||
|
|
@ -484,11 +482,11 @@ describe("CodexThreadsView", () => {
|
|||
threadId: "thread",
|
||||
name: "Saved title",
|
||||
});
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("New draft");
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report an older save failure over a newer rename edit", async () => {
|
||||
it("restores the same editor when a locked rename save fails", async () => {
|
||||
const saved = deferred<object>();
|
||||
const renameThreadRequest = vi.fn(() => saved.promise);
|
||||
connectionMock.state.client = clientFixture({
|
||||
|
|
@ -507,16 +505,14 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
firstInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
const secondInput = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
|
||||
if (!secondInput) throw new Error("Missing newer rename input");
|
||||
changeInputValue(secondInput, "New draft");
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(true);
|
||||
|
||||
saved.reject(new Error("Older save failed."));
|
||||
saved.reject(new Error("Rename failed."));
|
||||
for (let index = 0; index < 10; index += 1) await Promise.resolve();
|
||||
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("New draft");
|
||||
expect(view.containerEl.textContent).not.toContain("Older save failed.");
|
||||
expect(notices).not.toContain("Older save failed.");
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Saved title");
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(false);
|
||||
expect(notices).toContain("Rename failed.");
|
||||
});
|
||||
|
||||
it("notifies a current rename failure without adding list status", async () => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ describe("thread row core projection", () => {
|
|||
active: false,
|
||||
draft: "Preview title",
|
||||
generating: false,
|
||||
saving: false,
|
||||
autoNameDisabled: true,
|
||||
});
|
||||
expect(threadRowCoreProjection({ thread: thread({ name: null, preview: "" }), selected: false }).rename.draft).toBe("");
|
||||
|
|
|
|||
Loading…
Reference in a new issue