@jxsuite/studio 0.34.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.
@@ -26,7 +26,7 @@ function ensureGhost(doc: Document): HTMLElement {
26
26
  el.style.cssText =
27
27
  "position:fixed;left:0;top:0;z-index:9999;pointer-events:none;display:none;" +
28
28
  "padding:2px 8px;border-radius:4px;font-size:12px;line-height:1.4;white-space:nowrap;" +
29
- "background:var(--accent,#3b82f6);color:#fff;box-shadow:0 2px 8px rgba(0,0,0,0.25);" +
29
+ "background:var(--accent,#3b82f6);color:var(--accent-fg,#fff);box-shadow:0 2px 8px rgba(0,0,0,0.25);" +
30
30
  "transform:translate(8px,8px)";
31
31
  doc.body.append(el);
32
32
  ghostEl = el;
@@ -23,6 +23,7 @@ import { view } from "../view";
23
23
  import { showConfirmDialog, showDialog } from "../ui/layers";
24
24
  import { statusMessage } from "./statusbar";
25
25
  import { publishToGithub } from "../github/github-publish";
26
+ import { pullWithPackageSync } from "../packages/pull-package-sync";
26
27
 
27
28
  interface GitLogEntry {
28
29
  hash: string;
@@ -141,6 +142,20 @@ async function gitAction(action: string, body?: unknown) {
141
142
  }
142
143
  }
143
144
 
145
+ /** Pull via the package-aware orchestrator; same loading/error contract as gitAction. */
146
+ async function doPull() {
147
+ updateUi("gitLoading", true);
148
+ updateUi("gitError", null);
149
+ try {
150
+ await pullWithPackageSync();
151
+ await refreshGitStatus();
152
+ } catch (error) {
153
+ updateUi("gitError", errorMessage(error));
154
+ updateUi("gitLoading", false);
155
+ renderOnly("leftPanel");
156
+ }
157
+ }
158
+
144
159
  let _pollTimer = null as ReturnType<typeof setInterval> | null;
145
160
  let _lastUpdated = null as Date | null;
146
161
  let _gitSubTab = "changes";
@@ -315,7 +330,7 @@ export function renderGitPanel(
315
330
  </sp-action-button>
316
331
  <sp-action-button
317
332
  title="Pull${status?.behind ? ` (${status.behind} behind)` : ""}"
318
- @click=${() => gitAction("gitPull")}
333
+ @click=${() => void doPull()}
319
334
  ?disabled=${loading}
320
335
  >
321
336
  <sp-icon-arrow-down slot="icon" size="xs"></sp-icon-arrow-down>
@@ -10,7 +10,8 @@ import { rightPanel, updateUi } from "../store";
10
10
  import { effect, effectScope } from "../reactivity";
11
11
  import { createPanelScheduler } from "./panel-scheduler";
12
12
  import type { PanelScheduler } from "./panel-scheduler";
13
- import { activeTab } from "../workspace/workspace";
13
+ import { activeTab, workspace } from "../workspace/workspace";
14
+ import { consumePendingAgentPrompt, hasPendingAgentPrompt } from "../services/agent-seed";
14
15
  import { tabIcon } from "./activity-bar";
15
16
  import { eventsSidebarTemplate } from "./events-panel";
16
17
  import { isCustomElementDoc } from "./signals-panel";
@@ -22,9 +23,9 @@ import { renderPropertiesPanelTemplate } from "./properties-panel";
22
23
  import type { EffectScope } from "@vue/reactivity";
23
24
  import {
24
25
  renderAiPanelTemplate,
26
+ bindAiPanelHost,
25
27
  mountAiPanel,
26
- mountQuikChat,
27
- registerRightPanelRender,
28
+ seedAssistantPrompt,
28
29
  } from "./ai-panel";
29
30
 
30
31
  interface RightPanelCtx {
@@ -47,7 +48,6 @@ let _scheduler: PanelScheduler | null = null;
47
48
  export function mount(ctx: RightPanelCtx) {
48
49
  _ctx = ctx;
49
50
  mountAiPanel();
50
- registerRightPanelRender(render);
51
51
  _scheduler = createPanelScheduler({
52
52
  blockWhile: isColorPopoverOpen,
53
53
  render: _doRender,
@@ -116,6 +116,9 @@ function _ensureContainers() {
116
116
  _assistantContainer = document.createElement("div");
117
117
  _assistantContainer.className = "panel-body";
118
118
  _assistantContainer.style.cssText = "display:flex;flex-direction:column;overflow:hidden";
119
+ // The AI panel owns a focus-guard-free rAF render loop into this container so
120
+ // Streaming repaints while the composer is focused (see ai-panel.ts).
121
+ bindAiPanelHost(_assistantContainer);
119
122
  }
120
123
 
121
124
  function _doRender() {
@@ -135,6 +138,12 @@ function _doRender() {
135
138
  selection: aTab.session.selection,
136
139
  ui: aTab.session.ui,
137
140
  };
141
+ // A pending agent prompt (stored by the New Project flow, possibly from another window)
142
+ // Forces the Assistant tab open — same updateUi mechanism the automation hook uses.
143
+ const root = workspace.projectRoot;
144
+ if (root && S.ui.rightTab !== "assistant" && hasPendingAgentPrompt(root)) {
145
+ updateUi("rightTab", "assistant");
146
+ }
138
147
  const tab = S.ui.rightTab;
139
148
 
140
149
  // Render tabs header
@@ -217,9 +226,16 @@ function _doRender() {
217
226
  }
218
227
  } else if (tab === "assistant") {
219
228
  litRender(renderAiPanelTemplate(), _assistantContainer!);
229
+ if (root) {
230
+ // Consume-on-read keeps repeated renders idempotent; seed after the panel template
231
+ // Has rendered so the assistant machinery is in place.
232
+ const prompt = consumePendingAgentPrompt(root);
233
+ if (prompt) {
234
+ requestAnimationFrame(() => void seedAssistantPrompt(prompt));
235
+ }
236
+ }
220
237
  }
221
238
  } catch (error) {
222
239
  console.error("right-panel render error:", error);
223
240
  }
224
- requestAnimationFrame(() => mountQuikChat());
225
241
  }
@@ -8,8 +8,16 @@
8
8
  * See spec/desktop.md §8 for the full specification.
9
9
  */
10
10
 
11
+ import { streamImport } from "../services/import-client";
11
12
  import type { ProjectConfig } from "@jxsuite/schema/types";
12
- import type { DirEntry, FsEvent, RenameResult } from "../types";
13
+ import type {
14
+ DirEntry,
15
+ FsEvent,
16
+ ImportProgressEvent,
17
+ ImportSiteOptions,
18
+ RenameResult,
19
+ StarterInfo,
20
+ } from "../types";
13
21
 
14
22
  /** A directory entry from the server, tolerating extra wire fields. */
15
23
  type WireDirEntry = DirEntry & Record<string, unknown>;
@@ -215,6 +223,9 @@ export function createDevServerPlatform() {
215
223
  url?: string;
216
224
  adapter?: string;
217
225
  directory: string;
226
+ starter?: string;
227
+ template?: string;
228
+ design?: Record<string, unknown>;
218
229
  }) {
219
230
  const res = await fetch("/__studio/create-project", {
220
231
  body: JSON.stringify(opts),
@@ -228,6 +239,24 @@ export function createDevServerPlatform() {
228
239
  return await res.json();
229
240
  },
230
241
 
242
+ /** List starter templates from the dev server. */
243
+ async listStarters(): Promise<StarterInfo[]> {
244
+ const res = await fetch("/__studio/starters");
245
+ if (!res.ok) {
246
+ throw new Error("Failed to load starters");
247
+ }
248
+ return await readJson<StarterInfo[]>(res);
249
+ },
250
+
251
+ /** AI-guided site import: NDJSON progress stream from the dev server's import endpoint. */
252
+ async importSite(
253
+ opts: ImportSiteOptions,
254
+ onProgress: (evt: ImportProgressEvent) => void,
255
+ signal?: AbortSignal,
256
+ ) {
257
+ return await streamImport("/__studio/import-site", opts, onProgress, signal);
258
+ },
259
+
231
260
  // ─── File operations ──────────────────────────────────────────────────
232
261
 
233
262
  /** @param {string} dir */
@@ -0,0 +1,91 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Agent-seed.ts — cross-window handoff of a pending AI assistant prompt.
4
+ *
5
+ * The New Project flow stores the user's project brief here; the Studio window that opens the
6
+ * project consumes it and seeds the Assistant tab. localStorage (not module state) is deliberate:
7
+ * on desktop the new project opens in a NEW window, and the per-root localStorage key is the
8
+ * cross-window channel (precedent: the chat-history key `jx-ai-chat-history:<root>`).
9
+ *
10
+ * @license MIT
11
+ */
12
+
13
+ const PENDING_PROMPT_PREFIX = "jx.ai.pendingAgentPrompt";
14
+
15
+ /** Entries older than this are considered stale and dropped on read. */
16
+ const MAX_AGE_MS = 15 * 60 * 1000;
17
+
18
+ function storageKey(root: string) {
19
+ return `${PENDING_PROMPT_PREFIX}:${root}`;
20
+ }
21
+
22
+ /**
23
+ * Read the pending entry for a project root, dropping stale or malformed entries.
24
+ *
25
+ * @param {string} root
26
+ * @returns {{ prompt: string; ts: number } | null}
27
+ */
28
+ function readEntry(root: string): { prompt: string; ts: number } | null {
29
+ try {
30
+ const raw = globalThis.localStorage?.getItem(storageKey(root));
31
+ if (!raw) {
32
+ return null;
33
+ }
34
+ const parsed = JSON.parse(raw) as { prompt?: unknown; ts?: unknown };
35
+ const valid =
36
+ typeof parsed.prompt === "string" &&
37
+ typeof parsed.ts === "number" &&
38
+ Date.now() - parsed.ts <= MAX_AGE_MS;
39
+ if (!valid) {
40
+ // Stale or malformed — remove so it never seeds a later session.
41
+ globalThis.localStorage?.removeItem(storageKey(root));
42
+ return null;
43
+ }
44
+ return { prompt: parsed.prompt as string, ts: parsed.ts as number };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Store a pending assistant prompt for the project at `root`.
52
+ *
53
+ * @param {string} root
54
+ * @param {string} prompt
55
+ */
56
+ export function setPendingAgentPrompt(root: string, prompt: string): void {
57
+ try {
58
+ globalThis.localStorage?.setItem(storageKey(root), JSON.stringify({ prompt, ts: Date.now() }));
59
+ } catch {
60
+ /* LocalStorage unavailable — the prompt is simply not handed off. */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Whether a fresh (younger than 15 min) pending prompt exists for `root`.
66
+ *
67
+ * @param {string} root
68
+ */
69
+ export function hasPendingAgentPrompt(root: string): boolean {
70
+ return readEntry(root) !== null;
71
+ }
72
+
73
+ /**
74
+ * Read AND delete the pending prompt for `root` (consume-on-read keeps repeated renders
75
+ * idempotent). Returns null when there is no fresh entry.
76
+ *
77
+ * @param {string} root
78
+ * @returns {string | null}
79
+ */
80
+ export function consumePendingAgentPrompt(root: string): string | null {
81
+ const entry = readEntry(root);
82
+ if (!entry) {
83
+ return null;
84
+ }
85
+ try {
86
+ globalThis.localStorage?.removeItem(storageKey(root));
87
+ } catch {
88
+ /* LocalStorage unavailable — nothing to delete. */
89
+ }
90
+ return entry.prompt;
91
+ }
@@ -0,0 +1,64 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ai-models.js — available-model listing from the studio AI proxy.
4
+ *
5
+ * Fetches the proxy's /models endpoint (the chat endpoint's sibling), forwarding the
6
+ * stored API key as X-Api-Key and the endpoint override as X-Api-Base-URL so the proxy
7
+ * lists models from the user's chosen provider. Shared by the credentials form and the
8
+ * chat composer's model picker; results are cached module-wide until invalidated (e.g.
9
+ * after credentials change).
10
+ *
11
+ * @license MIT
12
+ */
13
+
14
+ import { getPlatform } from "../platform";
15
+ import { getBaseUrl, getOpenAiKey } from "./ai-settings";
16
+
17
+ export interface AiModel {
18
+ id: string;
19
+ name: string;
20
+ }
21
+
22
+ let cache: AiModel[] | null = null;
23
+
24
+ /** Drop the cached model list (call after credentials/endpoint changes). */
25
+ export function invalidateModelCache() {
26
+ cache = null;
27
+ }
28
+
29
+ /**
30
+ * Fetch the models available through the AI proxy. Returns the cached list when present unless
31
+ * `force` is set; throws on HTTP/network failure (callers surface the error).
32
+ *
33
+ * @param {{ apiKey?: string; baseUrl?: string; force?: boolean }} [opts] - Credential overrides for
34
+ * drafts not yet saved to ai-settings.
35
+ * @returns {Promise<AiModel[]>}
36
+ */
37
+ export async function fetchAvailableModels(
38
+ opts: { apiKey?: string; baseUrl?: string; force?: boolean } = {},
39
+ ): Promise<AiModel[]> {
40
+ if (cache && !opts.force) {
41
+ return cache;
42
+ }
43
+ const plat = getPlatform();
44
+ const chatUrl = await Promise.resolve(plat.aiChatUrl());
45
+ const modelsUrl = chatUrl.replace(/\/chat$/, "/models");
46
+
47
+ const headers: Record<string, string> = {};
48
+ const apiKey = opts.apiKey || getOpenAiKey();
49
+ if (apiKey) {
50
+ headers["X-Api-Key"] = apiKey;
51
+ }
52
+ const baseUrl = opts.baseUrl || getBaseUrl();
53
+ if (baseUrl) {
54
+ headers["X-Api-Base-URL"] = baseUrl;
55
+ }
56
+
57
+ const resp = await fetch(modelsUrl, { headers });
58
+ if (!resp.ok) {
59
+ throw new Error(`HTTP ${resp.status}`);
60
+ }
61
+ const data = (await resp.json()) as { models?: { id: string; name?: string }[] };
62
+ cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
63
+ return cache;
64
+ }
@@ -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
  }