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).