@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.
@@ -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
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared client for the /__studio/import-site NDJSON stream. Every platform's importSite is "a
3
+ * streaming fetch to some URL" — the dev server posts to its own origin, the desktop platforms post
4
+ * to their token-gated loopback servers — so the request/parse/settle logic lives here once.
5
+ *
6
+ * The endpoint emits one JSON object per line: `progress` lines (forwarded to onProgress),
7
+ * `heartbeat` keep-alives (ignored), and a terminal `done` ({root, config}) or `error` line.
8
+ */
9
+
10
+ import type { ImportProgressEvent, ImportSiteOptions } from "../types";
11
+ import type { ProjectConfig } from "@jxsuite/schema/types";
12
+
13
+ interface StreamLine {
14
+ type: string;
15
+ phase?: string;
16
+ message?: string;
17
+ current?: number;
18
+ total?: number;
19
+ root?: string;
20
+ config?: ProjectConfig;
21
+ error?: string;
22
+ }
23
+
24
+ /**
25
+ * POST an import request and consume its NDJSON progress stream.
26
+ *
27
+ * @param {string} endpoint — the platform's import-site URL (may carry an auth token)
28
+ * @param {ImportSiteOptions} opts — the import request; `apiKey`/`baseUrl` travel as headers
29
+ * @param {(evt: ImportProgressEvent) => void} onProgress
30
+ * @param {AbortSignal} [signal]
31
+ */
32
+ export async function streamImport(
33
+ endpoint: string,
34
+ opts: ImportSiteOptions,
35
+ onProgress: (evt: ImportProgressEvent) => void,
36
+ signal?: AbortSignal,
37
+ ): Promise<{ root: string; config: ProjectConfig }> {
38
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
39
+ if (opts.apiKey) {
40
+ headers["X-Api-Key"] = opts.apiKey;
41
+ }
42
+ if (opts.baseUrl) {
43
+ headers["X-Api-Base-URL"] = opts.baseUrl;
44
+ }
45
+
46
+ const res = await fetch(endpoint, {
47
+ body: JSON.stringify({
48
+ url: opts.url,
49
+ directory: opts.directory,
50
+ depth: opts.depth,
51
+ maxPages: opts.maxPages,
52
+ aiComponents: opts.aiComponents,
53
+ ...(opts.model === undefined ? {} : { aiModel: opts.model }),
54
+ }),
55
+ headers,
56
+ method: "POST",
57
+ ...(signal === undefined ? {} : { signal }),
58
+ });
59
+
60
+ if (!res.ok) {
61
+ let message = `Import failed (${res.status})`;
62
+ try {
63
+ const body = (await res.json()) as { error?: string };
64
+ if (body.error) {
65
+ message = body.error;
66
+ }
67
+ } catch {
68
+ /* Non-JSON error body — keep the status message. */
69
+ }
70
+ throw new Error(message);
71
+ }
72
+ if (!res.body) {
73
+ throw new Error("Import stream had no response body");
74
+ }
75
+
76
+ const reader = res.body.getReader();
77
+ const decoder = new TextDecoder();
78
+ let buffer = "";
79
+ let result: { root: string; config: ProjectConfig } | null = null;
80
+
81
+ const handleLine = (line: string) => {
82
+ const trimmed = line.trim();
83
+ if (!trimmed) {
84
+ return;
85
+ }
86
+ let parsed: StreamLine;
87
+ try {
88
+ parsed = JSON.parse(trimmed) as StreamLine;
89
+ } catch {
90
+ return; // Tolerate a mangled line rather than killing the whole import.
91
+ }
92
+ if (parsed.type === "progress") {
93
+ onProgress({
94
+ phase: parsed.phase ?? "",
95
+ message: parsed.message ?? "",
96
+ ...(parsed.current === undefined ? {} : { current: parsed.current }),
97
+ ...(parsed.total === undefined ? {} : { total: parsed.total }),
98
+ });
99
+ } else if (parsed.type === "done" && parsed.root !== undefined && parsed.config) {
100
+ result = { root: parsed.root, config: parsed.config };
101
+ } else if (parsed.type === "error") {
102
+ throw new Error(parsed.error || "Import failed");
103
+ }
104
+ // Heartbeats and unknown line types are ignored.
105
+ };
106
+
107
+ try {
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done) {
111
+ break;
112
+ }
113
+ buffer += decoder.decode(value, { stream: true });
114
+ // Lines can split across chunks: keep the trailing partial in the buffer.
115
+ const lines = buffer.split("\n");
116
+ buffer = lines.pop() ?? "";
117
+ for (const line of lines) {
118
+ handleLine(line);
119
+ }
120
+ }
121
+ handleLine(buffer);
122
+ } finally {
123
+ try {
124
+ await reader.cancel();
125
+ } catch {
126
+ /* The stream is already closed (e.g. after an abort). */
127
+ }
128
+ }
129
+
130
+ if (!result) {
131
+ throw new Error("Import stream ended without a result");
132
+ }
133
+ return result;
134
+ }
@@ -0,0 +1,93 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Settings-store.js — sync of user settings between localStorage and the platform backend.
4
+ *
5
+ * LocalStorage stays the synchronous source of truth for every consumer (ai-settings getters read
6
+ * it directly). On platforms with a backend user-settings store (desktop/chromium — where
7
+ * localStorage does not roam across browser profiles), the persisted keys are hydrated from the
8
+ * backend at boot and written through on every change. The dev server omits the platform methods
9
+ * and settings remain localStorage-only.
10
+ *
11
+ * @license MIT
12
+ */
13
+
14
+ import { getPlatform, hasPlatform } from "../platform";
15
+
16
+ /** The localStorage keys mirrored into the platform's user-settings store. */
17
+ export const PERSISTED_SETTINGS_KEYS = ["jx.ai.openaiKey", "jx.ai.baseUrl", "jx.ai.model"] as const;
18
+
19
+ /** Read a localStorage value defensively, treating unavailable/throwing storage as empty. */
20
+ function readLocal(key: string): string {
21
+ try {
22
+ return globalThis.localStorage?.getItem(key) ?? "";
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Pull the persisted settings from the backend store into localStorage. Call once after the
30
+ * platform is registered; a no-op on platforms without a settings store (dev server).
31
+ *
32
+ * Migration: keys the backend does not know yet but localStorage does (an existing user upgrading
33
+ * from localStorage-only builds) are pushed back to the backend once, fire-and-forget.
34
+ */
35
+ export async function hydrateSettings(): Promise<void> {
36
+ if (!hasPlatform()) {
37
+ return;
38
+ }
39
+ const platform = getPlatform();
40
+ if (!platform.getSettings) {
41
+ return;
42
+ }
43
+ let stored: Record<string, string>;
44
+ try {
45
+ stored = await platform.getSettings();
46
+ } catch {
47
+ return;
48
+ }
49
+ const merged: Record<string, string> = { ...stored };
50
+ let needsMigration = false;
51
+ for (const key of PERSISTED_SETTINGS_KEYS) {
52
+ const backendValue = stored[key];
53
+ if (backendValue) {
54
+ try {
55
+ globalThis.localStorage?.setItem(key, backendValue);
56
+ } catch {
57
+ /* Storage unavailable — consumers fall back to defaults. */
58
+ }
59
+ } else {
60
+ const localValue = readLocal(key);
61
+ if (localValue) {
62
+ merged[key] = localValue;
63
+ needsMigration = true;
64
+ }
65
+ }
66
+ }
67
+ if (needsMigration && platform.saveSettings) {
68
+ void platform.saveSettings(merged).catch(() => {});
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Write the current values of the persisted keys through to the backend store, fire-and-forget.
74
+ * Call after mutating any of {@link PERSISTED_SETTINGS_KEYS} in localStorage; a no-op on platforms
75
+ * without a settings store (dev server).
76
+ */
77
+ export function persistSettings(): void {
78
+ if (!hasPlatform()) {
79
+ return;
80
+ }
81
+ const platform = getPlatform();
82
+ if (!platform.saveSettings) {
83
+ return;
84
+ }
85
+ const settings: Record<string, string> = {};
86
+ for (const key of PERSISTED_SETTINGS_KEYS) {
87
+ const value = readLocal(key);
88
+ if (value) {
89
+ settings[key] = value;
90
+ }
91
+ }
92
+ void platform.saveSettings(settings).catch(() => {});
93
+ }
package/src/studio.ts CHANGED
@@ -80,6 +80,8 @@ import { startFsSync } from "./files/fs-events";
80
80
  import { renderImportsTemplate } from "./panels/imports-panel";
81
81
  import { renderHeadTemplate } from "./panels/head-panel";
82
82
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
83
+ import { installAutomationHook } from "./services/automation";
84
+ import { openBrowseModal } from "./browse/browse-modal";
83
85
 
84
86
  import { getPlatform, hasPlatform, registerPlatform } from "./platform";
85
87
  import { parseMediaEntries } from "./utils/canvas-media";
@@ -90,6 +92,7 @@ import { defBadgeLabel, defCategory, renderSignalsTemplate } from "./panels/sign
90
92
  import { loadComponentRegistry } from "./files/components";
91
93
  import { ensureDependenciesInstalled } from "./packages/ensure-deps";
92
94
  import { maybePromptJxsuiteUpdate } from "./packages/jxsuite-update";
95
+ import { autoSyncProjectOnOpen } from "./packages/pull-package-sync";
93
96
 
94
97
  import { html, render as litRender } from "lit-html";
95
98
 
@@ -124,6 +127,7 @@ import {
124
127
  import { initCssData } from "./panels/style-utils";
125
128
  import { initQuickSearch } from "./panels/quick-search";
126
129
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
130
+ import { hydrateSettings } from "./services/settings-store";
127
131
  import { initWelcome } from "./panels/welcome-screen";
128
132
  import { openNewProjectModal } from "./new-project/new-project-modal";
129
133
  import type { DocumentStackEntry, GitDiffState } from "./types";
@@ -342,6 +346,18 @@ if (!hasPlatform()) {
342
346
  registerPlatform(createDevServerPlatform());
343
347
  }
344
348
 
349
+ // Screenshot/automation runners (scripts/screenshots/) await window.__jxAutomation right after
350
+ // Navigation, so the gated hook must install before the async deep-link project load below.
351
+ installAutomationHook({
352
+ getCanvasMode,
353
+ openBrowseModal,
354
+ openNewProjectModal,
355
+ render,
356
+ renderActivityBar,
357
+ setCanvasMode,
358
+ statusMessage,
359
+ });
360
+
345
361
  mountResizeEdges();
346
362
 
347
363
  // ─── Render loop ──────────────────────────────────────────────────────────────
@@ -619,6 +635,7 @@ if (_projectParam) {
619
635
  selectedPath: siteCtx.fileRelPath || null,
620
636
  });
621
637
 
638
+ await autoSyncProjectOnOpen();
622
639
  await ensureDependenciesInstalled();
623
640
  await loadComponentRegistry();
624
641
 
@@ -727,6 +744,13 @@ void hydrateRecentProjects().then(() => {
727
744
  render();
728
745
  });
729
746
 
747
+ // Hydrate user settings (AI connection parameters) from the backend store, then re-render so
748
+ // Key-gated surfaces (assistant gate, New Project Import/Agent tabs) see the stored key.
749
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
750
+ void hydrateSettings().then(() => {
751
+ render();
752
+ });
753
+
730
754
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
731
755
 
732
756
  function renderLeftPanel() {
@@ -787,6 +811,7 @@ async function openRecentProject(root: string) {
787
811
  selectedPath: null,
788
812
  });
789
813
 
814
+ await autoSyncProjectOnOpen();
790
815
  await ensureDependenciesInstalled();
791
816
  await loadDirectory(".");
792
817
  await loadComponentRegistry();