Preserve unchanged chat-list state

Keep the Alpine contexts array stable when a filtered and sorted snapshot is byte-equivalent, while continuing selection and tree synchronization. Cover unchanged, renamed, nested, and deletion flows in the sidebar store regression.

Alessandro committed Aug 23, 2026 at 14:16 UTC 392611f9d9eac92f704a2b62e850b75089250cb2
3 files changed +39 -1
tests/test_webui_chat_deletion.py
+31
@@ -57,6 +57,7 @@ function assert(condition, message) {{
57
58 function reset(contexts, selected) {{
59 model.contexts = contexts.map((context) => ({{ ...context }}));
60 + model.contextsJson = "";
61 model.selected = selected;
62 model.selectedContext = model.contexts.find((context) => context.id === selected);
63 model.deletedContextIds = {{}};
@@ -69,6 +70,36 @@ const chats = [
70 {{ id: "c", created_at: 10 }},
71 ];
72
73 +reset(chats, "");
74 +model.applyContexts(chats);
75 +const unchangedContexts = model.contexts;
76 +model.applyContexts(chats.map((context) => ({{ ...context }})));
77 +assert(
78 + model.contexts === unchangedContexts,
79 + "unchanged snapshots must preserve the Alpine contexts array",
80 +);
81 +model.applyContexts([{{ ...chats[0], name: "Renamed" }}, ...chats.slice(1)]);
82 +assert(
83 + model.contexts !== unchangedContexts && model.contexts[0].name === "Renamed",
84 + "changed context metadata must replace the contexts array",
85 +);
86 +
87 +const tree = [
88 + {{ id: "parent", created_at: 20 }},
89 + {{ id: "child", parent_context_id: "parent", created_at: 10 }},
90 +];
91 +reset(tree, "");
92 +model.applyContexts(tree);
93 +const unchangedTree = model.contexts;
94 +model.selected = "parent";
95 +model.expandedParents = {{}};
96 +model.applyContexts(tree.map((context) => ({{ ...context }})));
97 +assert(model.contexts === unchangedTree, "unchanged chat trees must preserve identity");
98 +assert(
99 + model.expandedParents.parent === true,
100 + "selection synchronization must still run for unchanged contexts",
101 +);
102 +
103 reset(chats, "b");
104 globalThis.__context = "a";
105 await model.selectChat("a");
webui/components/sidebar/AGENTS.md
+1
@@ -32,6 +32,7 @@
32 - The utility-message preference controls both individual utility steps and utility-only process-group chrome so hidden utility runs cannot leave empty headers in the transcript.
33 - Chat deletion removes the sidebar row optimistically in the same render batch as fallback selection. Keep successful local deletion tombstones for the page session so out-of-order poll or push snapshots cannot reinsert rows; restore the row and clear its tombstone if the delete request fails.
34 - Chat selection must synchronize the sidebar store even when the low-level context has already switched to the requested ID.
35 +- Unchanged context snapshots preserve the Alpine contexts-array identity to avoid chat-list reconciliation, while selection and parent-expansion synchronization still run; changed metadata and deletion tombstones must still replace the visible list.
36
37 ## Work Guidance
38
webui/components/sidebar/chats/chats-store.js
+7 -1
@@ -17,6 +17,7 @@ import { store as chatInputStore } from "/components/chat/input/input-store.js";
17
18 const model = {
19 contexts: [],
20 + contextsJson: "",
21 selected: "",
22 selectedContext: null,
23 loggedIn: false,
@@ -58,9 +59,14 @@ const model = {
59 const incomingContexts = Array.isArray(contextsList) ? contextsList : [];
60
61 // Sort by created_at time (newer first)
61 - this.contexts = incomingContexts
62 + const nextContexts = incomingContexts
63 .filter((context) => !this.deletedContextIds[context?.id])
64 .sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
65 + const contextsJson = JSON.stringify(nextContexts);
66 + if (contextsJson !== this.contextsJson) {
67 + this.contextsJson = contextsJson;
68 + this.contexts = nextContexts;
69 + }
70
71 // Keep selectedContext in sync when the currently selected context's
72 // metadata changes (e.g. project activation/deactivation).