Skip to content

Commit 08ba916

Browse files
authored
fix: 支持删除创建失败的临时会话 (#3003)
1 parent b8c29fe commit 08ba916

7 files changed

Lines changed: 339 additions & 41 deletions

File tree

‎apps/codex-plus-manager/src/renderer-inject.test.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ describe("session more menu lifecycle", () => {
3232
matches: () => false, remove: () => { group.isConnected = false; },
3333
};
3434
const row = {
35-
isConnected: true, dataset: {}, querySelectorAll: () => [group],
35+
isConnected: true, dataset: {}, querySelectorAll: (selector?: string) => selector === "button" ? [] : [group],
3636
contains: (node: unknown) => node === group, classList: { remove: (name: string) => classes.delete(name) },
37+
addEventListener: () => {}, appendChild: (node: unknown) => { row.rebuilt = node; },
38+
rebuilt: null as unknown,
3739
};
3840
const menu = {
3941
...events(), hidden: false, removed: false, isConnected: true, classes, row, group, button,
@@ -49,11 +51,19 @@ describe("session more menu lifecycle", () => {
4951
const api = new Function("document", "window", "codexPlusSettings", "codexPlusBackendSettings",
5052
"moreMenuClass", "moreButtonClass", "buttonClass", "actionGroupClass", "exportButtonClass",
5153
"codexDeleteVersion", "codexActionGroupVersion", "actionGroupFromRow", "sessionRefFromRow",
52-
"codexPlusRegistry", "codexPlusExtensionItems",
54+
"codexPlusRegistry", "codexPlusExtensionItems", "HTMLElement", "actionButtonClass",
55+
"appendCodexPlusExtensionRowActions", "syncActionGroupLayout", "setTimeout",
5356
`${source.split(" function tryAttachButton(row)")[0]}
5457
return { pruneSessionMoreMenus, pruneRemovedSessionActions, attachButton, closeSessionMoreMenus, installSessionMoreMenuAutoClose };`,
5558
)(
56-
{ querySelectorAll: () => { menuQueries++; return menus.filter((menu) => !menu.removed); }, activeElement: null },
59+
{
60+
querySelectorAll: () => { menuQueries++; return menus.filter((menu) => !menu.removed); },
61+
activeElement: null, body: { appendChild: () => {} },
62+
createElement: () => ({
63+
dataset: {}, setAttribute: () => {}, removeAttribute: () => {}, appendChild: () => {},
64+
addEventListener: () => {}, removeEventListener: () => {},
65+
}),
66+
},
5767
{
5868
setTimeout: (callback: () => void) => {
5969
const id = ++timerId;
@@ -64,10 +74,11 @@ describe("session more menu lifecycle", () => {
6474
},
6575
() => ({ sessionDelete: true, markdownExport: true }), { enhancementsEnabled: true },
6676
"more-menu", "more-button", "delete-button", "actions", "export-button", "7", "7",
67-
(row: { querySelectorAll: () => unknown[] }) => row.querySelectorAll()[0], () => ({}),
77+
(row: { querySelectorAll: () => unknown[] }) => row.querySelectorAll()[0], () => ({ session_id: "thread-1" }),
6878
// 内置的删除(行内按钮)与导出(菜单项)都是注册进来的会话行操作。
6979
{ rowActions: new Map([["export", { id: "export", label: "导出" }], ["delete", { id: "delete", label: "删除", placement: "inline" }]]) },
70-
(registry: Map<string, unknown>) => [...registry.values()],
80+
(registry: Map<string, unknown>) => [...registry.values()], class {}, "action-button",
81+
() => {}, () => {}, () => 0,
7182
);
7283
return {
7384
...api, timers, menuQueries: () => menuQueries,
@@ -123,6 +134,7 @@ describe("session more menu lifecycle", () => {
123134
else menu.__codexSessionMoreGroup = live.group;
124135
api.attachButton(menu.row);
125136
assert.equal(menu.group.isConnected, false, `${damage} must rebuild the retained group`);
137+
assert.ok(menu.row.rebuilt, `${damage} must create a replacement group`);
126138
}
127139
});
128140

‎apps/codex-plus-manager/src/session-delete-runtime.test.ts‎

Lines changed: 203 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import vm from "node:vm";
66
const shard = (name: string) => readFileSync(new URL(`../../../assets/inject/renderer-inject/${name}`, import.meta.url), "utf8");
77
const refs = shard("60-plugin-marketplace.js");
88
const deletion = shard("80-session-share.js");
9+
const actionGroups = shard("90-action-groups.js");
10+
const extensionHost = shard("92-extension-host.js");
911
const refHelpers = refs.slice(refs.indexOf(" function isClientNewThreadId(value)"), refs.indexOf(" if (window.__CODEX_PLUS_TEST_SESSION_REF__)"));
1012
const singleFunction = (source: string, name: string) => source.match(new RegExp(`^ (?:async )?function ${name}\\([^]*?^ \\}`, "m"))?.[0] ?? "";
1113
const uuid = "11111111-1111-4111-8111-111111111111";
@@ -119,11 +121,14 @@ function deleteFixture() {
119121
activate(uuid);
120122
// 删除后隐藏行用的样式表(#2614):stub 只需支持按 id 取回与追加到 head。
121123
const styles = new Map<string, Element & { id?: string }>();
124+
const listeners = new Map<string, Function>();
122125
const head = { appendChild: (node: Element & { id?: string }) => { node.removed = false; node.remove = () => { node.removed = true; styles.delete(node.id ?? ""); }; styles.set(node.id ?? "", node); return node; } };
123126
const context = vm.createContext({ Element, HTMLElement: Element, URL, window: { location },
124127
CSS: { escape: (value: string) => value.replace(/["\\]/g, "\\$&") },
125128
document: {
126129
activeElement: null, querySelectorAll: () => buttons, head,
130+
addEventListener: (name: string, callback: Function) => listeners.set(name, callback),
131+
removeEventListener: (name: string, callback: Function) => { if (listeners.get(name) === callback) listeners.delete(name); },
127132
querySelector: (selector: string) => rows.find((element) => element.isConnected && selector.includes("data-codex-plus-deleted-session-row") && element.getAttribute("data-codex-plus-deleted-session-row") !== null) ?? null,
128133
getElementById: (id: string) => styles.get(id) ?? null,
129134
createElement: () => new Element(),
@@ -139,12 +144,208 @@ function deleteFixture() {
139144
setTimeout: (_fn: Function, delay: number) => { timers.push(delay); return 1; },
140145
rpc: { sendRequest: (method: string, params: any) => { refreshes.push({ method, params }); return Promise.resolve({}); } },
141146
});
142-
vm.runInContext(`${refHelpers}\n${singleFunction(refs, "locationThreadId")}\n${singleFunction(refs, "currentSessionRef")}\n${deletion.slice(deletion.indexOf(" function rowHref(row)"), deletion.indexOf(" async function exportMarkdown(ref)"))}\n${singleFunction(deletion, "refreshRecentConversationsForHost")}\nfunction signal(e,t){return rpc.sendRequest(e,t)}; loadOptionalCodexAppModule=async()=>({signal});`, context);
147+
vm.runInContext(`${refHelpers}\n${singleFunction(refs, "locationThreadId")}\n${singleFunction(refs, "currentSessionRef")}\n${deletion.slice(deletion.indexOf(" function rowHref(row)"), deletion.indexOf(" async function exportMarkdown(ref)"))}\n${singleFunction(deletion, "uncreatedSessionActionFromRow")}\n${singleFunction(deletion, "deleteUncreatedSession")}\n${singleFunction(deletion, "installDeleteButtonEventDelegation")}\n${singleFunction(deletion, "refreshRecentConversationsForHost")}\nfunction signal(e,t){return rpc.sendRequest(e,t)}; loadOptionalCodexAppModule=async()=>({signal});`, context);
143148
const begin = async (element = rows[0]) => { context.openDeleteConfirmForRow(element, button, context.sessionRefFromRow(element), { preventDefault() {}, stopPropagation() {}, stopImmediatePropagation() {} }); await flush(); };
144149
const finish = async (result = { status: "local_deleted", session_id: requests[0]?.session_id, message: "deleted" }) => { request.resolve(result); await flush(); };
145-
return { context, settings, rows, native, buttons, requests, confirmations, diagnostics, timers, toasts, refreshes, styles, begin, finish, activate, setLocation, reloads: () => reloads };
150+
return { context, settings, rows, native, buttons, button, listeners, requests, confirmations, diagnostics, timers, toasts, refreshes, styles, begin, finish, activate, setLocation, reloads: () => reloads };
146151
}
147152

153+
function uncreatedFixture(prefix = "local:") {
154+
const f = deleteFixture();
155+
const clientId = `client-new-thread:${uuid}`;
156+
const rawId = `${prefix}${clientId}`;
157+
const pending = { clientThreadId: clientId, hostId: "local", status: "failed" };
158+
const calls: string[] = [];
159+
const binding = {
160+
dataAttributes: { "data-app-action-sidebar-thread-id": rawId },
161+
onArchive: () => { calls.push(clientId); },
162+
};
163+
const owner = { pending };
164+
const element = row(rawId, "local");
165+
element.__reactFiber$fixture.return = {
166+
memoizedProps: binding,
167+
return: { memoizedProps: owner, return: null },
168+
};
169+
f.rows[0] = element;
170+
return { ...f, element, binding, owner, pending, calls, clientId, rawId };
171+
}
172+
173+
function attachButtonFixture(id: string) {
174+
class ActionElement extends Element {
175+
dataset: Record<string, string> = {};
176+
children: ActionElement[] = [];
177+
className = "";
178+
appendChild(child: ActionElement) {
179+
child.parentElement = this;
180+
this.children.push(child);
181+
return child;
182+
}
183+
}
184+
const element = new ActionElement({ "data-app-action-sidebar-thread-id": id });
185+
const body = new ActionElement();
186+
const mounted: string[] = [];
187+
const items = [
188+
{ id: "delete", scriptKey: "builtin:session-delete", placement: "inline" },
189+
{ id: "export", scriptKey: "builtin:session-export", placement: "menu" },
190+
{ id: "custom-inline", scriptKey: "fixture", placement: "inline" },
191+
].map((item) => ({ ...item, onMount: () => mounted.push(item.id), onActivate() {} }));
192+
const context = vm.createContext({
193+
Element: ActionElement,
194+
selectors: { threadTitle: ".title" },
195+
document: { body, createElement: () => new ActionElement() },
196+
codexPlusBackendSettings: {},
197+
codexPlusRegistry: { rowActions: items },
198+
codexPlusExtensionItems: (registered: unknown) => registered,
199+
actionGroupClass: "actions",
200+
actionButtonClass: "action",
201+
moreButtonClass: "more",
202+
moreMenuClass: "menu",
203+
codexActionGroupVersion: "fixture",
204+
removeActionGroups: () => { element.children = []; },
205+
actionGroupFromRow: () => null,
206+
configureSvgActionButton() {},
207+
ellipsisIconSvg: () => "",
208+
sessionRowActionIconMarkup: () => "",
209+
applyNativeSessionActionStyle() {},
210+
installMoreButtonEvents() {},
211+
installSessionMoreMenuAutoClose() {},
212+
installActionButtonEvents() {},
213+
syncNativeSessionActionStyle() {},
214+
installNativeSessionActionStyleSync() {},
215+
syncActionGroupLayout() {},
216+
setTimeout() {},
217+
runCodexPlusExtensionCallback: (_key: string, _name: string, callback: Function) => ({ ok: true, value: callback() }),
218+
markCodexPlusExtensionNode: (node: ActionElement, key: string) => node.setAttribute("data-codex-plus-ext", key),
219+
createSessionMoreMenuItem: () => new ActionElement(),
220+
});
221+
vm.runInContext(`${refHelpers}\n${singleFunction(actionGroups, "sessionRowActionItems")}\n${singleFunction(extensionHost, "appendCodexPlusExtensionRowActions")}\n${singleFunction(actionGroups, "attachButton")}`, context);
222+
context.attachButton(element);
223+
const inline = element.children.flatMap((group) => group.children)
224+
.map((button) => button.getAttribute("data-codex-plus-row-action")).filter(Boolean);
225+
const menu = body.children.flatMap((container) => container.children)
226+
.map((item) => item.dataset.codexPlusExtensionItem);
227+
return { element, inline, menu, mounted };
228+
}
229+
230+
test("attachButton mounts only native deletion on an uncreated row", () => {
231+
for (const prefix of ["", "local:"]) {
232+
const f = attachButtonFixture(`${prefix}client-new-thread:${uuid}`);
233+
assert.deepEqual(f.inline, ["delete"]);
234+
assert.deepEqual(f.menu, []);
235+
assert.deepEqual(f.mounted, ["delete"]);
236+
assert.equal(f.element.children.length, 1);
237+
}
238+
});
239+
240+
test("attachButton leaves unknown rows without actions and preserves formal row actions", () => {
241+
const unknown = attachButtonFixture("");
242+
assert.deepEqual(unknown.inline, []);
243+
assert.deepEqual(unknown.menu, []);
244+
assert.equal(unknown.element.children.length, 0);
245+
const formal = attachButtonFixture(uuid);
246+
assert.deepEqual(formal.inline, ["delete", "custom-inline"]);
247+
assert.deepEqual(formal.menu, ["export"]);
248+
assert.deepEqual(formal.mounted, ["delete", "custom-inline"]);
249+
});
250+
251+
test("uncreated deletion invokes the native pending action without deleting its UUID suffix", async () => {
252+
for (const prefix of ["", "local:"]) {
253+
const f = uncreatedFixture(prefix);
254+
// 草稿没有撤销备份,即使普通本机会话启用快速删除也仍要确认。
255+
f.settings.sessionDeleteSkipConfirm = true;
256+
await f.begin();
257+
assert.deepEqual(f.calls, [f.clientId]);
258+
assert.equal(f.confirmations.length, 1);
259+
assert.equal(f.requests.length, 0, "临时 id 及其 UUID 后缀都不能发给 /delete");
260+
assert.equal(f.refreshes.length, 0);
261+
assert.equal(f.styles.size, 0, "草稿不能写入按正式 UUID 匹配的隐藏表");
262+
assert.equal(f.element.removed, false, "清理交给原生状态操作,不直接移除 DOM");
263+
assert.match(f.toasts.join(" "), /已关闭未创建成功/);
264+
}
265+
});
266+
267+
test("uncreated deletion cancellation leaves the native pending state untouched", async () => {
268+
const f = uncreatedFixture();
269+
f.context.confirmDelete = () => Promise.resolve(false);
270+
await f.begin();
271+
assert.equal(f.calls.length, 0);
272+
assert.equal(f.requests.length, 0);
273+
assert.equal(f.element.removed, false);
274+
assert.equal(f.toasts.length, 0);
275+
});
276+
277+
test("uncreated deletion rechecks identity after confirmation", async () => {
278+
const changes: Array<(f: ReturnType<typeof uncreatedFixture>) => void> = [
279+
(f) => { f.element.setAttribute("href", `/thread/${uuid}`); },
280+
(f) => { f.element.setAttribute("data-app-action-sidebar-thread-id", uuid); },
281+
(f) => { f.owner.pending = { ...f.pending }; },
282+
(f) => { f.element.setAttribute("data-app-action-sidebar-thread-host-id", "ssh:fixture"); },
283+
(f) => { f.element.removed = true; },
284+
];
285+
for (const change of changes) {
286+
const f = uncreatedFixture();
287+
const confirmation = deferred<boolean>();
288+
f.context.confirmDelete = () => confirmation.promise;
289+
await f.begin();
290+
assert.equal(f.calls.length, 0);
291+
change(f);
292+
confirmation.resolve(true);
293+
await flush();
294+
assert.equal(f.calls.length, 0);
295+
assert.equal(f.requests.length, 0, "不能把确认期间生成的正式会话当作原草稿删除");
296+
assert.match(f.toasts.join(" "), /会话已变化/);
297+
}
298+
});
299+
300+
test("uncreated deletion refuses callbacks without a matching pending row identity", async () => {
301+
const changes: Array<(f: ReturnType<typeof uncreatedFixture>) => void> = [
302+
(f) => { f.owner.pending = { ...f.pending, clientThreadId: `client-new-thread:${otherUuid}` }; },
303+
(f) => { f.owner.pending = { ...f.pending, hostId: "ssh:fixture" }; },
304+
(f) => { f.owner.pending = { ...f.pending, hostId: "" }; },
305+
(f) => { f.binding.dataAttributes["data-app-action-sidebar-thread-id"] = `local:client-new-thread:${otherUuid}`; },
306+
(f) => { f.binding.onArchive = undefined as any; },
307+
(f) => { f.element.__reactFiber$fixture.return = null; },
308+
];
309+
for (const change of changes) {
310+
const f = uncreatedFixture();
311+
change(f);
312+
await f.begin();
313+
assert.equal(f.calls.length, 0);
314+
assert.equal(f.requests.length, 0);
315+
assert.equal(f.confirmations.length, 0);
316+
assert.match(f.toasts.join(" "), /未找到.*原生关闭入口/);
317+
}
318+
});
319+
320+
test("uncreated deletion reports native callback failures explicitly", async () => {
321+
const f = uncreatedFixture();
322+
f.binding.onArchive = () => { throw new Error("pending dismiss failed"); };
323+
await f.begin();
324+
assert.match(f.toasts.join(" "), /关闭待创建会话失败:pending dismiss failed/);
325+
assert.doesNotMatch(f.toasts.join(" "), /已关闭/);
326+
assert.equal(f.requests.length, 0);
327+
assert.equal(f.element.removed, false);
328+
});
329+
330+
test("delete button document delegation reaches the uncreated native action", async () => {
331+
const f = uncreatedFixture();
332+
f.context.buttonClass = "codex-delete-button";
333+
const stopped: string[] = [];
334+
const event = {
335+
target: { closest: (selector: string) => selector === ".codex-delete-button" ? f.button : null },
336+
preventDefault() { stopped.push("default"); },
337+
stopPropagation() { stopped.push("propagation"); },
338+
stopImmediatePropagation() { stopped.push("immediate"); },
339+
};
340+
f.button.closest = (selector: string) => selector === "[data-app-action-sidebar-thread-id]" ? f.element : null;
341+
f.context.installDeleteButtonEventDelegation();
342+
f.listeners.get("click")!(event);
343+
await flush();
344+
assert.deepEqual(f.calls, [f.clientId]);
345+
assert.deepEqual(stopped, ["default", "propagation", "immediate"]);
346+
assert.equal(f.requests.length, 0);
347+
});
348+
148349
test("local current deletion uses native navigation and refresh without a page reload", async () => {
149350
const f = deleteFixture(); await f.begin(); await f.finish();
150351
assert.equal(f.native.clicks, 1);

0 commit comments

Comments
 (0)