Fix disappearing chats when Sidebar Folders is disabled

Another plugin could import the disabled folders store, registering a
replacement list view even though the folders UI was absent. This hid
the standard chat and task lists while their data remained intact.

Register view and sorting callbacks when each list extension mounts and
remove them on unmount, preserving other plugins' callbacks. Reuse the
existing component lifecycle hooks without adding work per chat or snapshot.

Add regression coverage for indirect imports, filter cleanup, remounting,
and preservation of other plugins' sorting. Document the lifecycle contract.

Validation: frontend checks and 13 focused pytest tests passed. Live WebUI
checks covered disable/re-enable, folder/flat switching, nested selection,
and component unmount cleanup at localhost:32081.
This commit is contained in:
Alessandro 2026-10-08 00:37:29 +02:00
parent 33b5397023
commit cb93ed055b
7 changed files with 57 additions and 9 deletions

View file

@ -40,6 +40,7 @@
- Sidebar menus share their pointer scope with the opening row or header. Dismiss after leaving both, allowing a short gap-crossing delay; also dismiss when focus leaves, the surrounding list scrolls, the window resizes, or the header extension unmounts. Mouse cleanup must not interrupt touch interaction or scrolling inside a menu.
- Resolve the core row menu through its `rowActionsMenu` reference, not shared menu styling classes that community plugins may also use. Header integrations must preserve the core header and its `chats-header-controls` extension point.
- The two list-view extension points are display-only alternatives. Disabling the plugin restores the core lists and existing chat/task controls.
- Register view and sorting callbacks when each list-view extension mounts, and unregister them when it unmounts. Importing the store from another plugin must not hide or filter the core lists while this plugin is disabled.
## Work Guidance

View file

@ -1,7 +1,8 @@
<html>
<head><script type="module" src="/plugins/_sidebar_folders/webui/sidebar-folders-store.js"></script></head>
<body>
<div x-data="{ kind: 'chat' }" class="sidebar-folder-host">
<div x-data="{ kind: 'chat' }" class="sidebar-folder-host"
x-create="$store.sidebarFolders.mountList('chat')" x-destroy="$store.sidebarFolders.unmountList('chat')">
<x-component path="/plugins/_sidebar_folders/webui/folder-list.html"></x-component>
</div>
</body>

View file

@ -1,7 +1,8 @@
<html>
<head><script type="module" src="/plugins/_sidebar_folders/webui/sidebar-folders-store.js"></script></head>
<body>
<div x-data="{ kind: 'task' }" class="sidebar-folder-host">
<div x-data="{ kind: 'task' }" class="sidebar-folder-host"
x-create="$store.sidebarFolders.mountList('task')" x-destroy="$store.sidebarFolders.unmountList('task')">
<x-component path="/plugins/_sidebar_folders/webui/folder-list.html"></x-component>
</div>
</body>

View file

@ -10,8 +10,10 @@ const contexts = [
];
const pins = { chat: new Set(), task: new Set(), project: new Set() };
const apiCalls = [];
const sidebarSource = await readFile(new URL("../../../webui/components/sidebar/sidebar-store.js", import.meta.url), "utf8");
const sidebarModel = new Function(sidebarSource.slice(sidebarSource.indexOf("const model ="), sidebarSource.indexOf("export const store")) + "\nreturn model;")();
globalThis.__foldersTest = {
sidebar: { rowMenuOpenId: "", rowMenuClose() { this.rowMenuOpenId = ""; } },
sidebar: sidebarModel,
chats: { contexts, displayName: (row) => row.name || row.id },
tasks: { tasks: [{ id: "task", project: { name: "alpha" } }] },
projects: { projectList: [{ name: "alpha", title: "Alpha" }, { name: "beta", title: "Beta" }] },
@ -487,4 +489,34 @@ projectResponse[0].title = "Renamed";
await projectStore.loadProjectsList();
assert.notEqual(projectStore.projectList, unchangedProjects);
assert.equal(projectStore.projectList[0].title, "Renamed", "project edits remain visible after refresh");
const refresh = store.refresh;
store.refresh = async () => {};
localStorage.getItem = () => null;
globalThis.window = { addEventListener() {} };
await store.init();
assert.equal(sidebar.hasListView("chat"), false, "another plugin importing the folders store cannot hide the core chats list");
assert.equal(sidebar.hasListView("task"), false, "another plugin importing the folders store cannot hide the core tasks list");
const roots = contexts.filter((row) => !row.parent_context_id);
store.config.project_filter = "missing-project";
for (const kind of ["chat", "task"]) {
const rows = kind === "chat" ? roots : tasks.tasks;
sidebar.registerRowListExtension(kind, "other-plugin", { sort: (items) => [...items].reverse() });
const fallback = sidebar.sortRows(kind, rows);
store.mountList(kind);
assert.equal(sidebar.hasListView(kind), true, "a mounted folders component owns its list view");
assert.deepEqual(sidebar.sortRows(kind, rows), [], "mounted folder filters still apply");
store.unmountList(kind);
assert.equal(sidebar.hasListView(kind), false, "unmounting folders restores the core list");
assert.deepEqual(sidebar.sortRows(kind, rows), fallback, "unmounting removes folder filters while retaining other plugins' sorting");
store.mountList(kind);
assert.equal(sidebar.hasListView(kind), true, "remounting restores the saved folder view");
assert.equal(store.config.project_filter, "missing-project", "unmounting preserves saved preferences");
store.unmountList(kind);
sidebar.unregisterRowListExtension(kind, "other-plugin");
const extension = await readFile(new URL(`../extensions/webui/sidebar-${kind === "chat" ? "chats" : "tasks"}-list-view/folders.html`, import.meta.url), "utf8");
assert.ok(extension.includes(`x-create="$store.sidebarFolders.mountList('${kind}')"`));
assert.ok(extension.includes(`x-destroy="$store.sidebarFolders.unmountList('${kind}')"`));
}
store.refresh = refresh;
console.log("Folder view grouping, sorting, pins, movement, selection, drop, menu-scope, and preference checks passed.");

View file

@ -38,16 +38,21 @@ export const store = createStore("sidebarFolders", {
} catch (error) {
console.error("Could not restore sidebar folders", error);
}
for (const kind of ["chat", "task"]) {
sidebar.registerRowListExtension(kind, PLUGIN, {
hasView: () => this.config.folder_view,
sort: (rows) => this.sortRows(kind, rows),
});
}
window.addEventListener("focus", () => this.refresh());
await this.refresh();
},
mountList(kind) {
sidebar.registerRowListExtension(kind, PLUGIN, {
hasView: () => this.config.folder_view,
sort: (rows) => this.sortRows(kind, rows),
});
},
unmountList(kind) {
sidebar.unregisterRowListExtension(kind, PLUGIN);
},
async refresh() {
await Promise.all([this.loadConfig(), this.loadOrder(), projects.loadProjectsList()]);
},