@jxsuite/studio 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/iframe-entry.js +6238 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +30972 -17222
  4. package/dist/studio.js.map +328 -83
  5. package/package.json +9 -9
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +5 -1
  41. package/src/new-project/design-fields.ts +260 -0
  42. package/src/new-project/import-tab.ts +322 -0
  43. package/src/new-project/new-project-modal.ts +472 -89
  44. package/src/new-project/templates.ts +61 -0
  45. package/src/packages/ensure-deps.ts +6 -0
  46. package/src/packages/pull-package-sync.ts +339 -0
  47. package/src/page-params.ts +383 -0
  48. package/src/panels/ai-chat/attached-context.ts +49 -0
  49. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  50. package/src/panels/ai-chat/chat-view.ts +250 -0
  51. package/src/panels/ai-chat/composer.ts +315 -0
  52. package/src/panels/ai-chat/sessions-view.ts +98 -0
  53. package/src/panels/ai-panel.ts +189 -457
  54. package/src/panels/block-action-bar.ts +296 -138
  55. package/src/panels/canvas-dnd-bridge.ts +397 -0
  56. package/src/panels/component-preview.ts +56 -0
  57. package/src/panels/dnd.ts +41 -17
  58. package/src/panels/drag-ghost.ts +62 -0
  59. package/src/panels/editors.ts +1 -1
  60. package/src/panels/git-panel.ts +16 -1
  61. package/src/panels/overlays.ts +10 -125
  62. package/src/panels/properties-panel.ts +210 -0
  63. package/src/panels/right-panel.ts +21 -7
  64. package/src/panels/signals-panel.ts +136 -22
  65. package/src/panels/stylebook-doc.ts +373 -0
  66. package/src/panels/stylebook-panel.ts +46 -689
  67. package/src/panels/tab-bar.ts +159 -13
  68. package/src/panels/toolbar.ts +3 -2
  69. package/src/platforms/devserver.ts +45 -1
  70. package/src/services/agent-seed.ts +91 -0
  71. package/src/services/ai-models.ts +64 -0
  72. package/src/services/ai-session-store.ts +250 -0
  73. package/src/services/ai-settings.ts +5 -0
  74. package/src/services/automation.ts +140 -0
  75. package/src/services/document-assistant.ts +98 -38
  76. package/src/services/import-client.ts +134 -0
  77. package/src/services/monaco-setup.ts +12 -0
  78. package/src/services/render-critic.ts +9 -9
  79. package/src/services/settings-store.ts +93 -0
  80. package/src/settings/css-vars-editor.ts +2 -2
  81. package/src/store.ts +4 -62
  82. package/src/studio.ts +115 -40
  83. package/src/tabs/doc-op-apply.ts +89 -0
  84. package/src/tabs/patch-ops.ts +6 -2
  85. package/src/tabs/tab.ts +23 -4
  86. package/src/tabs/transact.ts +2 -74
  87. package/src/types.ts +98 -18
  88. package/src/ui/ai-credentials-form.ts +205 -0
  89. package/src/ui/jx-theme.ts +63 -0
  90. package/src/ui/media-picker.ts +6 -4
  91. package/src/ui/spectrum.ts +13 -0
  92. package/src/utils/canvas-media.ts +0 -137
  93. package/src/utils/edit-display.ts +23 -3
  94. package/src/utils/geometry.ts +43 -0
  95. package/src/utils/link-target.ts +93 -0
  96. package/src/utils/strip-events.ts +54 -0
  97. package/src/view.ts +0 -23
  98. package/src/workspace/workspace.ts +14 -1
  99. package/src/canvas/canvas-subtree-render.ts +0 -113
  100. package/src/editor/component-inline-edit.ts +0 -349
  101. package/src/editor/content-inline-edit.ts +0 -207
  102. package/src/editor/insertion-helper.ts +0 -308
  103. package/src/panels/canvas-dnd.ts +0 -329
  104. package/src/panels/panel-events.ts +0 -306
  105. package/src/panels/preview-render.ts +0 -132
  106. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,250 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ai-session-store.js — project-scoped multi-session chat history in localStorage.
4
+ *
5
+ * Pure storage CRUD for the assistant's conversations: a small index of session
6
+ * metadata plus one payload key per session, so per-send writes touch a single
7
+ * conversation. No reactivity and no chat-state knowledge — document-assistant
8
+ * owns the live session; this module only loads/saves plain message arrays.
9
+ *
10
+ * Cross-window note: multiple studio windows share these keys; index writes are
11
+ * last-write-wins, which is accepted for chat history.
12
+ *
13
+ * @license MIT
14
+ */
15
+
16
+ const INDEX_KEY_PREFIX = "jx-ai-chat-sessions";
17
+ const PAYLOAD_KEY_PREFIX = "jx-ai-chat-session";
18
+ const LEGACY_KEY_PREFIX = "jx-ai-chat-history";
19
+
20
+ /** Most sessions kept per project — oldest by updatedAt evicted beyond this. */
21
+ export const MAX_SESSIONS = 20;
22
+ /** Most messages persisted per session (matches the pre-session store's cap). */
23
+ export const MAX_PERSIST_MESSAGES = 50;
24
+
25
+ const DEFAULT_TITLE = "New chat";
26
+ const MAX_TITLE_LENGTH = 60;
27
+
28
+ export interface SessionMeta {
29
+ id: string;
30
+ title: string;
31
+ createdAt: number;
32
+ updatedAt: number;
33
+ messageCount: number;
34
+ }
35
+
36
+ interface SessionIndex {
37
+ version: 1;
38
+ activeId: string | null;
39
+ /** Sorted by updatedAt descending. */
40
+ sessions: SessionMeta[];
41
+ }
42
+
43
+ /** The persisted message shape — structurally the chat-state Message JSON. */
44
+ export interface PersistedMessage {
45
+ id?: string;
46
+ role: string;
47
+ content: string;
48
+ toolCalls?: unknown[];
49
+ toolCallId?: string;
50
+ timestamp?: number;
51
+ }
52
+
53
+ // ─── Keys ───────────────────────────────────────────────────────────────────
54
+
55
+ /** Project-scoped index key; falls back to a shared key when no project is open. */
56
+ function indexKey(root: string) {
57
+ return root ? `${INDEX_KEY_PREFIX}:${root}` : INDEX_KEY_PREFIX;
58
+ }
59
+
60
+ function payloadKey(root: string, id: string) {
61
+ return root ? `${PAYLOAD_KEY_PREFIX}:${root}:${id}` : `${PAYLOAD_KEY_PREFIX}:${id}`;
62
+ }
63
+
64
+ function legacyKey(root: string) {
65
+ return root ? `${LEGACY_KEY_PREFIX}:${root}` : LEGACY_KEY_PREFIX;
66
+ }
67
+
68
+ // ─── Storage helpers (defensive: quota/unavailable/corrupt → absent) ────────
69
+
70
+ function readJson<T>(key: string): T | null {
71
+ try {
72
+ const raw = globalThis.localStorage?.getItem(key);
73
+ return raw ? (JSON.parse(raw) as T) : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function writeJson(key: string, value: unknown) {
80
+ try {
81
+ globalThis.localStorage?.setItem(key, JSON.stringify(value));
82
+ } catch {
83
+ // Storage full or unavailable — chat history is not critical.
84
+ }
85
+ }
86
+
87
+ function removeKey(key: string) {
88
+ try {
89
+ globalThis.localStorage?.removeItem(key);
90
+ } catch {
91
+ /* Ignore */
92
+ }
93
+ }
94
+
95
+ // ─── Index ──────────────────────────────────────────────────────────────────
96
+
97
+ function emptyIndex(): SessionIndex {
98
+ return { version: 1, activeId: null, sessions: [] };
99
+ }
100
+
101
+ /**
102
+ * Read the session index for a project, migrating the pre-session single-conversation store
103
+ * (`jx-ai-chat-history:<root>`) into the first session on first access.
104
+ */
105
+ function readIndex(root: string): SessionIndex {
106
+ const stored = readJson<SessionIndex>(indexKey(root));
107
+ if (stored && Array.isArray(stored.sessions)) {
108
+ return { ...emptyIndex(), ...stored };
109
+ }
110
+ return migrateLegacy(root) ?? emptyIndex();
111
+ }
112
+
113
+ function writeIndex(root: string, index: SessionIndex) {
114
+ index.sessions.sort((a, b) => b.updatedAt - a.updatedAt);
115
+ writeJson(indexKey(root), index);
116
+ }
117
+
118
+ /** Convert a legacy single-conversation store into a one-session index, if present. */
119
+ function migrateLegacy(root: string): SessionIndex | null {
120
+ const msgs = readJson<PersistedMessage[]>(legacyKey(root));
121
+ removeKey(legacyKey(root));
122
+ if (!Array.isArray(msgs) || msgs.length === 0) {
123
+ return null;
124
+ }
125
+ const timestamps = msgs.map((m) => m.timestamp).filter((t): t is number => typeof t === "number");
126
+ const firstUser = msgs.find((m) => m.role === "user");
127
+ const meta: SessionMeta = {
128
+ id: newSessionId(),
129
+ title: deriveTitle(firstUser?.content ?? ""),
130
+ createdAt: timestamps.length > 0 ? Math.min(...timestamps) : Date.now(),
131
+ updatedAt: timestamps.length > 0 ? Math.max(...timestamps) : Date.now(),
132
+ messageCount: msgs.length,
133
+ };
134
+ writeJson(payloadKey(root, meta.id), msgs.slice(-MAX_PERSIST_MESSAGES));
135
+ const index: SessionIndex = { version: 1, activeId: meta.id, sessions: [meta] };
136
+ writeIndex(root, index);
137
+ return index;
138
+ }
139
+
140
+ // ─── Public API ─────────────────────────────────────────────────────────────
141
+
142
+ function newSessionId() {
143
+ return `s_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
144
+ }
145
+
146
+ /**
147
+ * Derive a session title from its first user message: first non-empty line, whitespace-collapsed,
148
+ * capped at {@link MAX_TITLE_LENGTH} characters.
149
+ *
150
+ * @param {string} text
151
+ * @returns {string}
152
+ */
153
+ export function deriveTitle(text: string): string {
154
+ const line =
155
+ text
156
+ .split("\n")
157
+ .map((l) => l.replaceAll(/\s+/g, " ").trim())
158
+ .find((l) => l.length > 0) ?? "";
159
+ if (!line) {
160
+ return DEFAULT_TITLE;
161
+ }
162
+ return line.length > MAX_TITLE_LENGTH ? `${line.slice(0, MAX_TITLE_LENGTH - 1)}…` : line;
163
+ }
164
+
165
+ /** List a project's sessions, most recently updated first. */
166
+ export function listSessions(root: string): SessionMeta[] {
167
+ return readIndex(root).sessions;
168
+ }
169
+
170
+ /** The session restored on startup, or null for a fresh unsaved chat. */
171
+ export function getActiveSessionId(root: string): string | null {
172
+ return readIndex(root).activeId;
173
+ }
174
+
175
+ /** Point the index at the session to restore next startup (null = fresh chat). */
176
+ export function setActiveSession(root: string, id: string | null) {
177
+ const index = readIndex(root);
178
+ index.activeId = id;
179
+ writeIndex(root, index);
180
+ }
181
+
182
+ /**
183
+ * Create a new session (as the active one), evicting the oldest sessions beyond
184
+ * {@link MAX_SESSIONS}. The title derives from the first user message when given.
185
+ */
186
+ export function createSession(root: string, firstUserText?: string): SessionMeta {
187
+ const index = readIndex(root);
188
+ const now = Date.now();
189
+ const meta: SessionMeta = {
190
+ id: newSessionId(),
191
+ title: deriveTitle(firstUserText ?? ""),
192
+ createdAt: now,
193
+ updatedAt: now,
194
+ messageCount: 0,
195
+ };
196
+ index.sessions.unshift(meta);
197
+ while (index.sessions.length > MAX_SESSIONS) {
198
+ let oldest = 0;
199
+ for (let i = 1; i < index.sessions.length; i++) {
200
+ if (index.sessions[i]!.updatedAt < index.sessions[oldest]!.updatedAt) {
201
+ oldest = i;
202
+ }
203
+ }
204
+ const [evicted] = index.sessions.splice(oldest, 1);
205
+ removeKey(payloadKey(root, evicted!.id));
206
+ }
207
+ index.activeId = meta.id;
208
+ writeIndex(root, index);
209
+ return meta;
210
+ }
211
+
212
+ /** Load a session's messages, or null when it doesn't exist / is corrupt. */
213
+ export function loadSession(root: string, id: string): PersistedMessage[] | null {
214
+ const msgs = readJson<PersistedMessage[]>(payloadKey(root, id));
215
+ return Array.isArray(msgs) ? msgs : null;
216
+ }
217
+
218
+ /**
219
+ * Persist a session's messages (trimmed to {@link MAX_PERSIST_MESSAGES}) and refresh its index
220
+ * entry (updatedAt, messageCount, and the title while still the default).
221
+ */
222
+ export function saveSession(root: string, id: string, messages: PersistedMessage[]) {
223
+ const trimmed = messages.slice(-MAX_PERSIST_MESSAGES);
224
+ writeJson(payloadKey(root, id), trimmed);
225
+ const index = readIndex(root);
226
+ const meta = index.sessions.find((s) => s.id === id);
227
+ if (!meta) {
228
+ return;
229
+ }
230
+ meta.updatedAt = Date.now();
231
+ meta.messageCount = trimmed.length;
232
+ if (meta.title === DEFAULT_TITLE) {
233
+ const firstUser = trimmed.find((m) => m.role === "user");
234
+ if (firstUser) {
235
+ meta.title = deriveTitle(firstUser.content);
236
+ }
237
+ }
238
+ writeIndex(root, index);
239
+ }
240
+
241
+ /** Delete a session's payload and index entry; clears activeId when it was active. */
242
+ export function deleteSession(root: string, id: string) {
243
+ removeKey(payloadKey(root, id));
244
+ const index = readIndex(root);
245
+ index.sessions = index.sessions.filter((s) => s.id !== id);
246
+ if (index.activeId === id) {
247
+ index.activeId = null;
248
+ }
249
+ writeIndex(root, index);
250
+ }
@@ -9,6 +9,8 @@
9
9
  * @license MIT
10
10
  */
11
11
 
12
+ import { persistSettings } from "./settings-store";
13
+
12
14
  const KEY_STORAGE = "jx.ai.openaiKey";
13
15
  const BASE_URL_STORAGE = "jx.ai.baseUrl";
14
16
  const MODEL_STORAGE = "jx.ai.model";
@@ -36,6 +38,7 @@ export function setOpenAiKey(key: string) {
36
38
  } else {
37
39
  globalThis.localStorage?.removeItem(KEY_STORAGE);
38
40
  }
41
+ persistSettings();
39
42
  } catch {
40
43
  /* LocalStorage unavailable — settings are not persisted. */
41
44
  }
@@ -71,6 +74,7 @@ export function setBaseUrl(url: string) {
71
74
  } else {
72
75
  globalThis.localStorage?.removeItem(BASE_URL_STORAGE);
73
76
  }
77
+ persistSettings();
74
78
  } catch {
75
79
  /* LocalStorage unavailable — settings are not persisted. */
76
80
  }
@@ -101,6 +105,7 @@ export function setModel(modelId: string) {
101
105
  } else {
102
106
  globalThis.localStorage?.removeItem(MODEL_STORAGE);
103
107
  }
108
+ persistSettings();
104
109
  } catch {
105
110
  /* LocalStorage unavailable — settings are not persisted. */
106
111
  }
@@ -0,0 +1,140 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Automation hook — a small, gated scripting surface for driving Studio from a browser-automation
4
+ * runner (see scripts/screenshots/ at the repo root). Installed as `window.__jxAutomation` only
5
+ * when the page URL carries `?automation=1`; without the flag production behavior is untouched.
6
+ *
7
+ * The API mutates the same reactive state the real UI mutates (canvas mode, activity tab,
8
+ * selection, inspector tab, function editor) instead of clicking through Spectrum shadow DOM, so
9
+ * shot definitions stay stable across chrome refactors.
10
+ */
11
+
12
+ import { renderOnly, updateUi } from "../store";
13
+ import { activeTab, workspace } from "../workspace/workspace";
14
+ import { applyPanelCollapse, view } from "../view";
15
+ import type { JxPath } from "../state";
16
+
17
+ /** Callbacks injected from studio.ts (module-local helpers or imports kept out of this module). */
18
+ export interface AutomationDeps {
19
+ getCanvasMode: () => string;
20
+ openBrowseModal: () => void;
21
+ openNewProjectModal: () => void;
22
+ render: () => void;
23
+ renderActivityBar: () => void;
24
+ setCanvasMode: (mode: string) => void;
25
+ statusMessage: (text: string) => void;
26
+ }
27
+
28
+ export interface AutomationApi {
29
+ editDef: (defName: string) => void;
30
+ editFunction: (path: JxPath, eventKey: string) => void;
31
+ openBrowse: () => void;
32
+ openNewProject: () => void;
33
+ getState: () => {
34
+ activeTabId: string | null;
35
+ canvasMode: string;
36
+ canvasStatus: string | null;
37
+ leftTab: string;
38
+ };
39
+ select: (path: JxPath | null) => void;
40
+ setActivity: (tab: string) => void;
41
+ setCanvasMode: (mode: string) => void;
42
+ setRightTab: (tab: string) => void;
43
+ setStatus: (text: string) => void;
44
+ setTheme: (color: string) => void;
45
+ setZoom: (zoom: number) => void;
46
+ waitForCanvasReady: (timeoutMs?: number) => Promise<void>;
47
+ }
48
+
49
+ /** The hook is opt-in per page load: only `?automation=1` installs it. */
50
+ export function shouldInstallAutomation(search: string): boolean {
51
+ return new URLSearchParams(search).get("automation") === "1";
52
+ }
53
+
54
+ export function createAutomationApi(deps: AutomationDeps): AutomationApi {
55
+ return {
56
+ editDef(defName: string) {
57
+ updateUi("editingFunction", { defName, type: "def" });
58
+ deps.render();
59
+ },
60
+ editFunction(path: JxPath, eventKey: string) {
61
+ updateUi("editingFunction", { eventKey, path, type: "event" });
62
+ deps.render();
63
+ },
64
+ openBrowse() {
65
+ deps.openBrowseModal();
66
+ },
67
+ openNewProject() {
68
+ deps.openNewProjectModal();
69
+ },
70
+ getState() {
71
+ return {
72
+ activeTabId: workspace.activeTabId,
73
+ canvasMode: deps.getCanvasMode(),
74
+ canvasStatus: activeTab.value?.session.canvas.status ?? null,
75
+ leftTab: view.leftTab,
76
+ };
77
+ },
78
+ select(path: JxPath | null) {
79
+ const tab = activeTab.value;
80
+ if (tab) {
81
+ tab.session.selection = path;
82
+ }
83
+ deps.render();
84
+ },
85
+ setActivity(tab: string) {
86
+ view.leftTab = tab;
87
+ view.leftPanelCollapsed = false;
88
+ applyPanelCollapse();
89
+ renderOnly("leftPanel");
90
+ deps.renderActivityBar();
91
+ },
92
+ setCanvasMode(mode: string) {
93
+ deps.setCanvasMode(mode);
94
+ deps.render();
95
+ },
96
+ setRightTab(tab: string) {
97
+ updateUi("rightTab", tab);
98
+ deps.render();
99
+ },
100
+ setStatus(text: string) {
101
+ deps.statusMessage(text);
102
+ },
103
+ setTheme(color: string) {
104
+ document.querySelector("sp-theme")?.setAttribute("color", color);
105
+ },
106
+ setZoom(zoom: number) {
107
+ updateUi("zoom", zoom);
108
+ deps.render();
109
+ },
110
+ waitForCanvasReady(timeoutMs = 30_000) {
111
+ return new Promise((resolve, reject) => {
112
+ const started = Date.now();
113
+ const tick = () => {
114
+ if (activeTab.value?.session.canvas.status === "ready") {
115
+ resolve();
116
+ return;
117
+ }
118
+ if (Date.now() - started > timeoutMs) {
119
+ reject(new Error(`canvas not ready after ${timeoutMs}ms`));
120
+ return;
121
+ }
122
+ setTimeout(tick, 50);
123
+ };
124
+ tick();
125
+ });
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Install the hook on globalThis when the current URL opts in. Returns whether it installed, so
132
+ * callers (and tests) can assert the gate.
133
+ */
134
+ export function installAutomationHook(deps: AutomationDeps): boolean {
135
+ if (!shouldInstallAutomation(location.search)) {
136
+ return false;
137
+ }
138
+ (globalThis as Record<string, unknown>).__jxAutomation = createAutomationApi(deps);
139
+ return true;
140
+ }
@@ -22,20 +22,16 @@ import { getBaseUrl, getModel, getOpenAiKey } from "./ai-settings";
22
22
  import { trimContext } from "./context-manager";
23
23
  import { renderCheck } from "./render-critic";
24
24
  import { openFileInTab } from "../files/files";
25
-
26
- const PERSIST_KEY_PREFIX = "jx-ai-chat-history";
27
- const MAX_PERSIST_MESSAGES = 50;
25
+ import * as sessionStore from "./ai-session-store";
28
26
 
29
27
  /**
30
- * Project-scoped localStorage key (uses the project root) so conversations don't bleed across
31
- * projects (ADR §11.5 / §14.2). Falls back to a shared key when no project is open.
28
+ * Project root scoping the session store (ADR §11.5 / §14.2 conversations don't bleed across
29
+ * projects). Falls back to a shared unscoped store when no project is open.
32
30
  *
33
31
  * @returns {string}
34
32
  */
35
- function persistKey() {
36
- return workspace.projectRoot
37
- ? `${PERSIST_KEY_PREFIX}:${workspace.projectRoot}`
38
- : PERSIST_KEY_PREFIX;
33
+ function projectRoot() {
34
+ return workspace.projectRoot || "";
39
35
  }
40
36
 
41
37
  /**
@@ -46,6 +42,10 @@ function persistKey() {
46
42
  * sendMessage: (text: string) => Promise<void>;
47
43
  * stop: () => void;
48
44
  * newChat: () => void;
45
+ * listSessions: () => import("./ai-session-store").SessionMeta[];
46
+ * openSession: (id: string) => void;
47
+ * deleteSession: (id: string) => void;
48
+ * activeSessionId: () => string | null;
49
49
  * }}
50
50
  */
51
51
  export function createDocumentAssistant() {
@@ -69,6 +69,9 @@ export function createDocumentAssistant() {
69
69
 
70
70
  let controller: AbortController | null = null;
71
71
 
72
+ /** The persisted session backing the live chat; null = fresh unsaved chat. */
73
+ let sessionId: string | null = null;
74
+
72
75
  function buildPrompt() {
73
76
  const tab = activeTab.value;
74
77
  return buildSystemPrompt({
@@ -84,6 +87,12 @@ export function createDocumentAssistant() {
84
87
  return;
85
88
  }
86
89
 
90
+ // Lazily create the backing session on the first message so empty "New Chat"
91
+ // Clicks never pollute the session list.
92
+ if (!sessionId) {
93
+ sessionId = sessionStore.createSession(projectRoot(), text).id;
94
+ }
95
+
87
96
  chatState.sendMessage(text);
88
97
 
89
98
  // Trim context before streaming to keep the conversation within token limits.
@@ -93,7 +102,7 @@ export function createDocumentAssistant() {
93
102
  }
94
103
 
95
104
  // Persist after trimming so the saved history reflects what's actually sent.
96
- persistChat(chatState);
105
+ persistChat();
97
106
 
98
107
  try {
99
108
  const plat = getPlatform();
@@ -127,6 +136,9 @@ export function createDocumentAssistant() {
127
136
  chatState.setError(error instanceof Error ? error.message : String(error));
128
137
  } finally {
129
138
  controller = null;
139
+ // Persist again once the stream settled so the completed reply (or the state
140
+ // After an error/abort cleanup) survives a reload without another send.
141
+ persistChat();
130
142
  }
131
143
  }
132
144
 
@@ -138,46 +150,94 @@ export function createDocumentAssistant() {
138
150
  function newChat() {
139
151
  stop();
140
152
  chatState.clearChat();
141
- persistChat(chatState);
153
+ sessionId = null;
154
+ sessionStore.setActiveSession(projectRoot(), null);
155
+ }
156
+
157
+ // ── Sessions ──────────────────────────────────────────────────────────
158
+
159
+ /** The project's persisted sessions, most recently updated first. */
160
+ function listSessions() {
161
+ return sessionStore.listSessions(projectRoot());
162
+ }
163
+
164
+ /** Replace the live chat with a persisted session's messages. */
165
+ function openSession(id: string) {
166
+ const msgs = sessionStore.loadSession(projectRoot(), id);
167
+ if (!msgs) {
168
+ return;
169
+ }
170
+ stop();
171
+ chatState.clearChat();
172
+ pushRestoredMessages(msgs);
173
+ sessionId = id;
174
+ sessionStore.setActiveSession(projectRoot(), id);
175
+ }
176
+
177
+ /** Delete a persisted session; deleting the open one leaves a fresh unsaved chat. */
178
+ function deleteSession(id: string) {
179
+ sessionStore.deleteSession(projectRoot(), id);
180
+ if (sessionId === id) {
181
+ stop();
182
+ chatState.clearChat();
183
+ sessionId = null;
184
+ }
185
+ }
186
+
187
+ function activeSessionId() {
188
+ return sessionId;
142
189
  }
143
190
 
144
191
  // ── Persistence ───────────────────────────────────────────────────────
145
192
 
146
- /** Persist recent messages to localStorage (non-blocking). */
147
- function persistChat(cs: ReturnType<typeof createChatState>) {
148
- try {
149
- const msgs = cs.messages.slice(-MAX_PERSIST_MESSAGES);
150
- localStorage.setItem(persistKey(), JSON.stringify(msgs));
151
- } catch {
152
- // Storage full or unavailable — not critical.
193
+ /** Persist the live conversation into its backing session (non-blocking). */
194
+ function persistChat() {
195
+ if (!sessionId) {
196
+ return;
153
197
  }
198
+ // Skip a still-empty streaming placeholder so reloads don't restore blank bubbles.
199
+ const msgs = chatState.messages.filter(
200
+ (m) => m.role !== "assistant" || m.content || (m.toolCalls?.length ?? 0) > 0,
201
+ );
202
+ sessionStore.saveSession(projectRoot(), sessionId, msgs);
154
203
  }
155
204
 
156
- /** Restore messages from a previous session, if any. */
205
+ /** Push persisted messages into chat state, synthesizing ids where missing. */
206
+ function pushRestoredMessages(msgs: sessionStore.PersistedMessage[]) {
207
+ for (const m of msgs) {
208
+ chatState.messages.push({
209
+ ...m,
210
+ id: m.id || `restored_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
211
+ } as (typeof chatState.messages)[number]);
212
+ }
213
+ }
214
+
215
+ /** Restore the last-active session once on creation, if any. */
157
216
  function restoreChat() {
158
- try {
159
- const raw = localStorage.getItem(persistKey());
160
- if (!raw) {
161
- return;
162
- }
163
- const msgs = JSON.parse(raw) as typeof chatState.messages;
164
- if (!Array.isArray(msgs) || msgs.length === 0) {
165
- return;
166
- }
167
- // Push into chat state (skips streaming state)
168
- for (const m of msgs) {
169
- chatState.messages.push({
170
- ...m,
171
- id: m.id || `restored_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
172
- });
173
- }
174
- } catch {
175
- // Corrupt or empty — ignore.
217
+ const root = projectRoot();
218
+ const activeId = sessionStore.getActiveSessionId(root);
219
+ if (!activeId) {
220
+ return;
221
+ }
222
+ const msgs = sessionStore.loadSession(root, activeId);
223
+ if (!msgs || msgs.length === 0) {
224
+ return;
176
225
  }
226
+ pushRestoredMessages(msgs);
227
+ sessionId = activeId;
177
228
  }
178
229
 
179
230
  // Restore once on creation.
180
231
  restoreChat();
181
232
 
182
- return { chatState, sendMessage, stop, newChat };
233
+ return {
234
+ chatState,
235
+ sendMessage,
236
+ stop,
237
+ newChat,
238
+ listSessions,
239
+ openSession,
240
+ deleteSession,
241
+ activeSessionId,
242
+ };
183
243
  }