@jxsuite/studio 0.32.0 → 0.34.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 (94) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +22791 -13709
  4. package/dist/studio.js.map +181 -74
  5. package/package.json +12 -7
  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 +2 -1
  41. package/src/page-params.ts +383 -0
  42. package/src/panels/ai-panel.ts +386 -328
  43. package/src/panels/block-action-bar.ts +296 -138
  44. package/src/panels/canvas-dnd-bridge.ts +397 -0
  45. package/src/panels/component-preview.ts +56 -0
  46. package/src/panels/dnd.ts +41 -17
  47. package/src/panels/drag-ghost.ts +62 -0
  48. package/src/panels/editors.ts +1 -1
  49. package/src/panels/overlays.ts +10 -125
  50. package/src/panels/properties-panel.ts +210 -0
  51. package/src/panels/right-panel.ts +0 -2
  52. package/src/panels/signals-panel.ts +136 -22
  53. package/src/panels/stylebook-doc.ts +373 -0
  54. package/src/panels/stylebook-panel.ts +46 -689
  55. package/src/panels/tab-bar.ts +159 -13
  56. package/src/panels/toolbar.ts +3 -2
  57. package/src/platforms/devserver.ts +18 -47
  58. package/src/services/ai-settings.ts +107 -0
  59. package/src/services/ai-system-prompt.ts +617 -0
  60. package/src/services/ai-tools.ts +854 -0
  61. package/src/services/context-manager.ts +200 -0
  62. package/src/services/document-assistant.ts +183 -0
  63. package/src/services/jx-validate.ts +65 -0
  64. package/src/services/monaco-setup.ts +12 -0
  65. package/src/services/render-critic.ts +75 -0
  66. package/src/services/token-lint.ts +140 -0
  67. package/src/services/tool-executor.ts +156 -0
  68. package/src/settings/css-vars-editor.ts +2 -2
  69. package/src/state.ts +29 -0
  70. package/src/store.ts +4 -62
  71. package/src/studio.ts +90 -40
  72. package/src/tabs/doc-op-apply.ts +89 -0
  73. package/src/tabs/patch-ops.ts +6 -2
  74. package/src/tabs/tab.ts +23 -4
  75. package/src/tabs/transact.ts +33 -69
  76. package/src/types.ts +16 -24
  77. package/src/ui/jx-theme.ts +63 -0
  78. package/src/ui/media-picker.ts +6 -4
  79. package/src/ui/spectrum.ts +5 -0
  80. package/src/utils/canvas-media.ts +0 -137
  81. package/src/utils/edit-display.ts +23 -3
  82. package/src/utils/geometry.ts +43 -0
  83. package/src/utils/link-target.ts +93 -0
  84. package/src/utils/strip-events.ts +54 -0
  85. package/src/view.ts +0 -23
  86. package/src/workspace/workspace.ts +14 -1
  87. package/src/canvas/canvas-subtree-render.ts +0 -113
  88. package/src/editor/component-inline-edit.ts +0 -349
  89. package/src/editor/content-inline-edit.ts +0 -207
  90. package/src/editor/insertion-helper.ts +0 -308
  91. package/src/panels/canvas-dnd.ts +0 -329
  92. package/src/panels/panel-events.ts +0 -306
  93. package/src/panels/preview-render.ts +0 -132
  94. package/src/panels/pseudo-preview.ts +0 -75
@@ -1,36 +1,32 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Ai-panel.jsClaude AI Assistant tab for the right panel
3
+ * Ai-panel.ts — AI assistant tab for the right panel (Stack B document assistant).
4
4
  *
5
- * Uses QuikChat for the chat UI with Claude Agent SDK streaming via SSE.
5
+ * Uses QuikChat for the chat UI, driven by the reactive document-assistant session which streams
6
+ * from the OpenAI-compatible proxy and edits the live document via tools (with undo support).
6
7
  */
7
8
 
8
9
  import { html, nothing } from "lit-html";
9
10
  import { ref } from "lit-html/directives/ref.js";
10
11
  import quikchat from "quikchat/md";
11
12
  import { getPlatform } from "../platform";
12
- import { reloadFileInTab } from "../files/files";
13
+ import { effect, effectScope } from "../reactivity";
14
+ import { createDocumentAssistant } from "../services/document-assistant";
15
+ import {
16
+ getBaseUrl,
17
+ getModel,
18
+ getOpenAiKey,
19
+ hasOpenAiKey,
20
+ setBaseUrl,
21
+ setModel,
22
+ setOpenAiKey,
23
+ } from "../services/ai-settings";
24
+
25
+ import type { ChatState } from "@jxsuite/ai/chat-state";
26
+ import type { EffectScope } from "@vue/reactivity";
13
27
 
14
28
  // ─── State (module-level, persists across tab switches) ─────────────────────
15
29
 
16
- /**
17
- * @type {{
18
- * role: string;
19
- * content: string;
20
- * toolUse?: { tool: string; input?: Record<string, unknown> };
21
- * }[]}
22
- */
23
- let messages: {
24
- role: string;
25
- content: string;
26
- toolUse?: { tool: string; input?: Record<string, unknown> };
27
- }[] = [];
28
- let streaming = false;
29
- let sessionId = null as string | null;
30
- let authStatus = "unknown" as "authenticated" | "unauthenticated" | "checking" | "unknown";
31
- let authError = "";
32
- let currentAssistantText = "";
33
- let eventSource = null as EventSource | null;
34
30
  let mounted = false;
35
31
 
36
32
  /** Minimal surface of the untyped quikchat library that this panel uses. */
@@ -54,9 +50,25 @@ const QuikChat = quikchat as unknown as QuikChatCtor;
54
50
  let chatInstance: QuikChatInstance | null = null;
55
51
  let chatContainerEl: Element | null = null;
56
52
  let _quikChatEl: HTMLElement | null = null;
57
- let currentStreamMsgId = null as number | null;
58
- let streamStarted = false;
59
- const pendingFileReloads = new Set<string>();
53
+
54
+ /** Whether the OpenAI key form is showing (gate when no key, or re-edit via the toolbar). */
55
+ let keyEditing = false;
56
+ let keyDraft = "";
57
+ let baseUrlDraft = "";
58
+ let modelDraft = "";
59
+
60
+ /** Fetched from /__studio/ai/models (proxied to the upstream provider). */
61
+ let availableModels: { id: string; name: string }[] = [];
62
+ let modelsLoading = false;
63
+ let modelsError = "";
64
+
65
+ /** Document AST assistant session — created lazily, persists across tab switches. */
66
+ const assistant = createDocumentAssistant();
67
+ (globalThis as Record<string, unknown>).assistant = assistant;
68
+ let assistantScope: EffectScope | null = null;
69
+ let assistantRenderedCount = 0;
70
+ let assistantStreamingMsgId = null as number | null;
71
+ let assistantStreamedLen = 0;
60
72
 
61
73
  // ─── Lifecycle ──────────────────────────────────────────────────────────────
62
74
 
@@ -65,7 +77,6 @@ export function mountAiPanel() {
65
77
  return;
66
78
  }
67
79
  mounted = true;
68
- void checkAuth();
69
80
  }
70
81
 
71
82
  const _g = globalThis as unknown as {
@@ -98,7 +109,7 @@ export function mountQuikChat() {
98
109
  chatInstance = new QuikChat(
99
110
  container,
100
111
  (_chat: unknown, msg: string) => {
101
- void handleUserSend(msg);
112
+ void handleAssistantSend(msg);
102
113
  },
103
114
  {
104
115
  messagesArea: { alternating: false },
@@ -109,379 +120,426 @@ export function mountQuikChat() {
109
120
  );
110
121
  chatContainerEl = container;
111
122
 
112
- replayMessages();
113
-
114
- if (streaming) {
123
+ assistantRenderedCount = 0;
124
+ assistantStreamingMsgId = null;
125
+ assistantStreamedLen = 0;
126
+ replayAssistantMessages();
127
+ watchAssistant();
128
+ if (assistant.chatState.status === "streaming") {
115
129
  chatInstance?.inputAreaSetEnabled(false);
116
130
  }
117
131
  }
118
132
 
119
- function replayMessages() {
120
- if (!chatInstance || messages.length === 0) {
121
- return;
122
- }
123
- for (const msg of messages) {
124
- if (msg.role === "user") {
125
- chatInstance.messageAddNew(msg.content, "You", "right", "user");
126
- } else if (msg.role === "tool" && msg.toolUse) {
127
- chatInstance.messageAddNew(
128
- formatToolLabel(msg.toolUse.tool, msg.toolUse.input),
129
- "",
130
- "left",
131
- "tool",
132
- );
133
- } else {
134
- chatInstance.messageAddNew(msg.content, "", "left", "assistant");
135
- }
136
- }
137
- if (streaming && currentAssistantText) {
138
- currentStreamMsgId = chatInstance.messageAddNew(currentAssistantText, "", "left", "assistant");
139
- streamStarted = true;
140
- }
141
- }
142
-
143
- // ─── Auth ───────────────────────────────────────────────────────────────────
133
+ // ─── OpenAI key settings ─────────────────────────────────────────────────────
144
134
 
145
- async function checkAuth() {
146
- authStatus = "checking";
135
+ /** Open the key form, pre-filled with the current settings. */
136
+ function startEditApiKey() {
137
+ keyDraft = getOpenAiKey();
138
+ baseUrlDraft = getBaseUrl();
139
+ modelDraft = getModel();
140
+ keyEditing = true;
147
141
  rerenderPanel();
148
- try {
149
- const plat = getPlatform();
150
- const result = await plat.aiAuthStatus();
151
- authStatus = result.authenticated ? "authenticated" : "unauthenticated";
152
- authError = result.error || "";
153
- } catch (error) {
154
- authStatus = "unauthenticated";
155
- authError = String(error);
142
+ // Auto-fetch available models if not already loaded.
143
+ if (availableModels.length === 0 && !modelsLoading) {
144
+ void fetchModels();
156
145
  }
157
- rerenderPanel();
158
146
  }
159
147
 
160
- // ─── Messaging ──────────────────────────────────────────────────────────────
161
-
162
- /** @param {string} text */
163
- async function handleUserSend(text: string) {
164
- if (!text.trim() || streaming) {
165
- return;
166
- }
167
-
168
- messages.push({ content: text, role: "user" });
169
- streaming = true;
170
- currentAssistantText = "";
171
- streamStarted = false;
172
-
173
- if (chatInstance) {
174
- currentStreamMsgId = chatInstance.messageAddTypingIndicator("");
175
- chatInstance.inputAreaSetEnabled(false);
176
- }
177
-
148
+ /** Persist the drafted key + endpoint and return to the chat. */
149
+ function saveApiKey() {
150
+ setOpenAiKey(keyDraft);
151
+ setBaseUrl(baseUrlDraft);
152
+ setModel(modelDraft);
153
+ keyDraft = "";
154
+ baseUrlDraft = "";
155
+ modelDraft = "";
156
+ keyEditing = false;
157
+ // Clear fetched models so they're re-fetched with the new credentials next time.
158
+ availableModels = [];
178
159
  rerenderPanel();
160
+ }
179
161
 
180
- const plat = getPlatform();
162
+ /** Dismiss the key form without saving (only offered when a key already exists). */
163
+ function cancelEditApiKey() {
164
+ keyDraft = "";
165
+ baseUrlDraft = "";
166
+ modelDraft = "";
167
+ keyEditing = false;
168
+ rerenderPanel();
169
+ }
181
170
 
171
+ /**
172
+ * Fetch available models from the proxy's /__studio/ai/models endpoint. Sends the stored API key as
173
+ * X-Api-Key so the proxy can forward to the upstream provider. Falls back to the proxy's hardcoded
174
+ * default list when no key is configured.
175
+ */
176
+ async function fetchModels() {
177
+ modelsLoading = true;
178
+ modelsError = "";
179
+ rerenderPanel();
182
180
  try {
183
- if (!sessionId) {
184
- const result = await plat.aiCreateSession({ message: text });
185
- sessionId = result.id;
186
- await connectStream(sessionId!);
187
- } else {
188
- await plat.aiSendMessage(sessionId, text);
181
+ const plat = getPlatform();
182
+ const chatUrl = await Promise.resolve(plat.aiChatUrl());
183
+ const modelsUrl = chatUrl.replace(/\/chat$/, "/models");
184
+
185
+ const headers: Record<string, string> = {};
186
+ const storedKey = getOpenAiKey() || keyDraft;
187
+ if (storedKey) {
188
+ headers["X-Api-Key"] = storedKey;
189
189
  }
190
- } catch (error) {
191
- if (chatInstance && currentStreamMsgId != null) {
192
- chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${error}`);
190
+ // Forward the chosen endpoint so the proxy lists models from THAT provider, not the
191
+ // Default OpenAI host otherwise a non-OpenAI key only ever gets the hardcoded fallback.
192
+ const baseUrl = baseUrlDraft || getBaseUrl();
193
+ if (baseUrl) {
194
+ headers["X-Api-Base-URL"] = baseUrl;
193
195
  }
194
- messages.push({ content: `Error: ${error}`, role: "assistant" });
195
- streaming = false;
196
- if (chatInstance) {
197
- chatInstance.inputAreaSetEnabled(true);
196
+
197
+ const resp = await fetch(modelsUrl, { headers });
198
+ if (!resp.ok) {
199
+ throw new Error(`HTTP ${resp.status}`);
198
200
  }
201
+ const data = (await resp.json()) as { models?: { id: string; name?: string }[] };
202
+ availableModels = (data.models || []).map((m: { id: string; name?: string }) => ({
203
+ id: m.id,
204
+ name: m.name || m.id,
205
+ }));
206
+ } catch (error: unknown) {
207
+ modelsError = (error as Error).message || "Failed to fetch models";
208
+ } finally {
209
+ modelsLoading = false;
199
210
  rerenderPanel();
200
211
  }
201
212
  }
202
213
 
203
- function stop() {
204
- if (!sessionId) {
205
- return;
206
- }
207
- const plat = getPlatform();
208
- void plat.aiStopSession(sessionId);
209
- finishStream();
210
- }
211
-
212
- function newChat() {
213
- if (sessionId) {
214
- const plat = getPlatform();
215
- void plat.aiDeleteSession(sessionId);
216
- }
217
- disconnectStream();
218
- messages = [];
219
- sessionId = null;
220
- streaming = false;
221
- currentAssistantText = "";
222
- currentStreamMsgId = null;
223
- streamStarted = false;
224
- pendingFileReloads.clear();
225
- if (chatInstance) {
226
- chatInstance.historyImport([]);
227
- chatInstance.inputAreaSetEnabled(true);
228
- }
229
- rerenderPanel();
230
- }
231
-
232
- // ─── SSE Stream ─────────────────────────────────────────────────────────────
233
-
234
- /** SSE `stream_event` payload (subset used here). */
235
- interface StreamEventData {
236
- event?: { type?: string; delta?: { type?: string; text?: string } };
237
- }
238
-
239
- /** SSE `result` payload (subset used here). */
240
- interface ResultData {
241
- result?: string;
242
- is_error?: boolean;
214
+ /** The OpenAI (or compatible) key + endpoint settings form, shown as a gate when no key is set. */
215
+ function renderKeyGate() {
216
+ const haveKey = hasOpenAiKey();
217
+ return html`
218
+ <div class="ai-tab-body">
219
+ <div class="ai-status-center" style="gap:10px;max-width:320px;text-align:left">
220
+ <div style="font-weight:600;align-self:center">AI provider key</div>
221
+ <div style="font-size:11px;color:var(--fg-dim)">
222
+ Any OpenAI-compatible key works. Stored locally in this browser; sent only to the Studio
223
+ proxy (never to a third party except your chosen endpoint).
224
+ </div>
225
+ <input
226
+ type="password"
227
+ style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
228
+ placeholder="sk-… or any compatible key"
229
+ .value=${keyDraft}
230
+ @input=${(e: Event) => {
231
+ keyDraft = (e.target as HTMLInputElement).value;
232
+ }}
233
+ />
234
+ <div style="font-weight:500;font-size:11px;margin-top:4px">Model</div>
235
+ ${availableModels.length > 0
236
+ ? html`
237
+ <sp-combobox
238
+ size="s"
239
+ allows-custom-value
240
+ .value=${modelDraft}
241
+ @change=${(e: Event) => {
242
+ modelDraft = (e.target as HTMLInputElement).value;
243
+ }}
244
+ @input=${(e: Event) => {
245
+ modelDraft = (e.target as HTMLInputElement).value;
246
+ }}
247
+ >
248
+ ${availableModels.map(
249
+ (m) => html`<sp-menu-item value=${m.id}>${m.name}</sp-menu-item>`,
250
+ )}
251
+ </sp-combobox>
252
+ `
253
+ : html`
254
+ <input
255
+ type="text"
256
+ style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
257
+ placeholder="Model ID (e.g. gpt-4o, claude-sonnet-4-20250514, etc.)"
258
+ .value=${modelDraft}
259
+ @input=${(e: Event) => {
260
+ modelDraft = (e.target as HTMLInputElement).value;
261
+ }}
262
+ />
263
+ `}
264
+ <div style="display:flex;gap:8px;align-items:center">
265
+ <sp-button size="s" variant="secondary" ?disabled=${modelsLoading} @click=${fetchModels}>
266
+ ${modelsLoading
267
+ ? "Fetching…"
268
+ : availableModels.length > 0
269
+ ? "Refresh models"
270
+ : "Fetch models"}
271
+ </sp-button>
272
+ ${modelsError
273
+ ? html`<span style="font-size:10px;color:var(--danger)">${modelsError}</span>`
274
+ : nothing}
275
+ </div>
276
+ <input
277
+ type="text"
278
+ style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
279
+ placeholder="Endpoint (optional, e.g. http://localhost:11434/v1)"
280
+ .value=${baseUrlDraft}
281
+ @input=${(e: Event) => {
282
+ baseUrlDraft = (e.target as HTMLInputElement).value;
283
+ }}
284
+ />
285
+ <div style="display:flex;gap:8px;align-self:flex-end">
286
+ ${haveKey
287
+ ? html`<sp-button size="s" variant="secondary" @click=${cancelEditApiKey}
288
+ >Cancel</sp-button
289
+ >`
290
+ : nothing}
291
+ <sp-button size="s" variant="primary" @click=${saveApiKey}>Save</sp-button>
292
+ </div>
293
+ </div>
294
+ </div>
295
+ `;
243
296
  }
244
297
 
245
- /** SSE `error` payload (subset used here). */
246
- interface ErrorData {
247
- error?: string;
248
- }
298
+ // ─── Document assistant rendering ────────────────────────────────────────────
249
299
 
250
- /** Parse SSE JSON into the expected shape. Returns null on malformed input. */
251
- function parseSse<T>(raw: unknown): T | null {
300
+ /** Send a message through the document assistant agent loop. */
301
+ async function handleAssistantSend(text: string) {
302
+ if (!text.trim() || assistant.chatState.status === "streaming") {
303
+ return;
304
+ }
305
+ chatInstance?.inputAreaSetEnabled(false);
252
306
  try {
253
- return JSON.parse(String(raw)) as T;
307
+ await assistant.sendMessage(text);
254
308
  } catch {
255
- return null;
309
+ // Synchronous failure (e.g. network unreachable) — the DocumentAssistant's
310
+ // Own try/catch calls chatState.setError(), which watchAssistant() displays.
311
+ // Re-enable input here as a safety net.
312
+ }
313
+ // Always re-enable input after the send attempt completes (or fails).
314
+ // WatchAssistant() also re-enables it when status !== "streaming", but if
315
+ // The assistant threw before chatState entered "streaming", we need this.
316
+ if ((assistant.chatState.status as ChatState) !== "streaming") {
317
+ chatInstance?.inputAreaSetEnabled(true);
256
318
  }
257
319
  }
258
320
 
259
- async function connectStream(id: string) {
260
- disconnectStream();
261
- const plat = getPlatform();
262
- const url = await Promise.resolve(plat.aiStreamUrl(id));
263
- eventSource = new EventSource(url);
264
-
265
- eventSource.addEventListener("stream_event", (e) => {
266
- const data = parseSse<StreamEventData>((e as MessageEvent).data);
267
- const evt = data?.event;
268
- if (evt?.type === "content_block_delta" && evt.delta?.type === "text_delta") {
269
- const token = evt.delta.text ?? "";
270
- currentAssistantText += token;
271
- if (chatInstance && currentStreamMsgId != null) {
272
- if (!streamStarted) {
273
- chatInstance.messageReplaceContent(currentStreamMsgId, currentAssistantText);
274
- streamStarted = true;
275
- } else {
276
- chatInstance.messageAppendContent(currentStreamMsgId, token);
277
- }
278
- }
321
+ /** Render a single finalized chat-state message into QuikChat. */
322
+ function renderAssistantMessage(msg: {
323
+ role: string;
324
+ content: string;
325
+ toolCalls?: { name: string; arguments: string }[];
326
+ }) {
327
+ if (!chatInstance) {
328
+ return;
329
+ }
330
+ if (msg.role === "user") {
331
+ chatInstance.messageAddNew(msg.content, "You", "right", "user");
332
+ return;
333
+ }
334
+ if (msg.role === "tool") {
335
+ // Show only failed tool results so the user knows why an edit didn't land
336
+ // (ADR §11.3). Successful tool results stay hidden to reduce noise.
337
+ const parsed = tryParseToolResult(msg.content);
338
+ if (parsed && !parsed.success) {
339
+ chatInstance.messageAddNew(`⚠️ ${parsed.error || "Tool call failed"}`, "", "left", "tool");
279
340
  }
280
- });
341
+ return;
342
+ }
343
+ if (msg.content) {
344
+ chatInstance.messageAddNew(msg.content, "", "left", "assistant");
345
+ }
346
+ for (const tc of msg.toolCalls ?? []) {
347
+ chatInstance.messageAddNew(formatAssistantToolLabel(tc), "", "left", "tool");
348
+ }
349
+ }
281
350
 
282
- eventSource.addEventListener("assistant", (e) => {
283
- const data = parseSse<AssistantMessageData>((e as MessageEvent).data);
284
- if (data) {
285
- handleAssistantMessage(data);
351
+ /** Replay the assistant's existing history into a freshly mounted QuikChat instance. */
352
+ function replayAssistantMessages() {
353
+ assistantRenderedCount = 0;
354
+ assistantStreamingMsgId = null;
355
+ assistantStreamedLen = 0;
356
+ const msgs = assistant.chatState.messages;
357
+ const isStreaming = assistant.chatState.status === "streaming";
358
+ // Render every message except a still-streaming trailing assistant message.
359
+ const lastIdx = msgs.length - 1;
360
+ for (let i = 0; i < msgs.length; i++) {
361
+ const m = msgs[i]!;
362
+ if (isStreaming && i === lastIdx && m.role === "assistant") {
363
+ break;
286
364
  }
287
- });
365
+ renderAssistantMessage(m);
366
+ assistantRenderedCount = i + 1;
367
+ }
368
+ }
288
369
 
289
- eventSource.addEventListener("result", (e) => {
290
- const data = parseSse<ResultData>((e as MessageEvent).data);
291
- if (data?.result && data.is_error) {
292
- if (chatInstance && currentStreamMsgId != null) {
293
- chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.result}`);
370
+ /**
371
+ * Reactively sync the assistant's chat-state into QuikChat. Newly-finalized messages are appended;
372
+ * the in-progress streaming message is updated incrementally so text flows token-by-token.
373
+ */
374
+ function watchAssistant() {
375
+ assistantScope?.stop();
376
+ assistantScope = effectScope();
377
+ assistantScope.run(() => {
378
+ effect(() => {
379
+ const cs = assistant.chatState;
380
+ const msgs = cs.messages;
381
+ const { status } = cs;
382
+ if (!chatInstance) {
383
+ return;
294
384
  }
295
- currentAssistantText = `Error: ${data.result}`;
296
- }
297
- finishStream();
298
- });
299
-
300
- eventSource.addEventListener("done", () => {
301
- finishStream();
302
- });
303
385
 
304
- eventSource.addEventListener("error", (e) => {
305
- const data = parseSse<ErrorData>((e as MessageEvent).data);
306
- if (data?.error) {
307
- if (chatInstance && currentStreamMsgId != null) {
308
- chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.error}`);
386
+ const lastIdx = msgs.length - 1;
387
+ for (let i = assistantRenderedCount; i < msgs.length; i++) {
388
+ const m = msgs[i]!;
389
+ const isStreamingTail = status === "streaming" && i === lastIdx && m.role === "assistant";
390
+ if (isStreamingTail) {
391
+ // Stream this message's text into a live bubble rather than finalizing it.
392
+ if (assistantStreamingMsgId == null) {
393
+ assistantStreamingMsgId = chatInstance.messageAddNew(
394
+ m.content || "",
395
+ "",
396
+ "left",
397
+ "assistant",
398
+ );
399
+ assistantStreamedLen = (m.content || "").length;
400
+ } else if ((m.content || "").length > assistantStreamedLen) {
401
+ chatInstance.messageAppendContent(
402
+ assistantStreamingMsgId,
403
+ m.content.slice(assistantStreamedLen),
404
+ );
405
+ assistantStreamedLen = m.content.length;
406
+ }
407
+ break;
408
+ }
409
+ if (assistantStreamingMsgId != null && m.role === "assistant") {
410
+ // The streaming bubble already contains the full text — just finalize it in place.
411
+ chatInstance.messageReplaceContent(assistantStreamingMsgId, m.content || "");
412
+ } else {
413
+ renderAssistantMessage(m);
414
+ }
415
+ assistantRenderedCount = i + 1;
416
+ assistantStreamingMsgId = null;
417
+ assistantStreamedLen = 0;
309
418
  }
310
- currentAssistantText = `Error: ${data.error}`;
311
- }
312
- finishStream();
313
- });
314
419
 
315
- eventSource.addEventListener("error", () => {
316
- finishStream();
420
+ if (status !== "streaming") {
421
+ assistantStreamingMsgId = null;
422
+ assistantStreamedLen = 0;
423
+ chatInstance.inputAreaSetEnabled(true);
424
+ if (cs.error) {
425
+ const advice = formatErrorAdvice(cs.error);
426
+ chatInstance.messageAddNew(
427
+ `❌ ${cs.error}${advice ? `\n\n${advice}` : ""}`,
428
+ "",
429
+ "left",
430
+ "assistant",
431
+ );
432
+ }
433
+ }
434
+ });
317
435
  });
318
436
  }
319
437
 
320
- function finishStream() {
321
- if (!streaming) {
322
- return;
323
- }
324
- if (currentAssistantText) {
325
- messages.push({ content: currentAssistantText, role: "assistant" });
326
- currentAssistantText = "";
327
- }
328
- streaming = false;
329
- currentStreamMsgId = null;
330
- streamStarted = false;
331
- if (chatInstance) {
332
- chatInstance.inputAreaSetEnabled(true);
333
- }
334
-
335
- if (pendingFileReloads.size > 0) {
336
- for (const fp of pendingFileReloads) {
337
- void reloadFileInTab(fp);
438
+ /**
439
+ * Parse a tool result message content (JSON string) into its success/error shape. Returns null if
440
+ * the content isn't a valid tool result.
441
+ *
442
+ * @param {string} content
443
+ * @returns {{ success: boolean; error?: string } | null}
444
+ */
445
+ function tryParseToolResult(
446
+ content: string,
447
+ ): { success: boolean; error?: string; summary?: string } | null {
448
+ try {
449
+ const parsed = JSON.parse(content) as { success?: unknown; error?: string; summary?: string };
450
+ if (parsed && typeof parsed.success === "boolean") {
451
+ return parsed as { success: boolean; error?: string; summary?: string };
338
452
  }
339
- pendingFileReloads.clear();
453
+ } catch {
454
+ /* Not JSON — not a tool result */
340
455
  }
341
-
342
- rerenderPanel();
456
+ return null;
343
457
  }
344
458
 
345
- function disconnectStream() {
346
- if (eventSource) {
347
- eventSource.close();
348
- eventSource = null;
459
+ /** @param {{ name: string; arguments: string }} tc */
460
+ function formatAssistantToolLabel(tc: { name: string; arguments: string }) {
461
+ let detail = "";
462
+ try {
463
+ const args = (tc.arguments ? JSON.parse(tc.arguments) : {}) as {
464
+ path?: unknown;
465
+ parentPath?: unknown;
466
+ };
467
+ if (Array.isArray(args.path)) {
468
+ detail = `: ${JSON.stringify(args.path)}`;
469
+ } else if (Array.isArray(args.parentPath)) {
470
+ detail = `: ${JSON.stringify(args.parentPath)}`;
471
+ }
472
+ } catch {
473
+ /* Partial/unparsed args — show name only */
349
474
  }
475
+ return `🔧 ${tc.name}${detail}`;
350
476
  }
351
477
 
352
- /** SSE payload from the assistant stream: message blocks or a bare content array. */
353
- interface AssistantMessageData {
354
- message?: { content?: AssistantBlock[] };
355
- content?: AssistantBlock[];
356
- }
357
-
358
- interface AssistantBlock {
359
- type?: string;
360
- text?: string;
361
- name?: string;
362
- input?: Record<string, unknown>;
363
- [key: string]: unknown;
364
- }
365
-
366
- /** @param {AssistantMessageData} data */
367
- function handleAssistantMessage(data: AssistantMessageData) {
368
- const content = data.message?.content || data.content;
369
- if (!content) {
370
- return;
478
+ /**
479
+ * Return actionable advice for common AI assistant errors so the user knows how to recover instead
480
+ * of just seeing a raw error message.
481
+ *
482
+ * @param {string} error
483
+ * @returns {string}
484
+ */
485
+ function formatErrorAdvice(error: string) {
486
+ const lower = error.toLowerCase();
487
+ if (lower.includes("no api key") || lower.includes("401")) {
488
+ return "Click the 🔑 button in the toolbar to add an OpenAI-compatible API key.";
371
489
  }
372
-
373
- let text = "";
374
- const toolBlocks = [];
375
-
376
- for (const block of content) {
377
- if (block.type === "text") {
378
- text += block.text;
379
- } else if (block.type === "tool_use" && typeof block.name === "string") {
380
- toolBlocks.push({ tool: block.name, ...(block.input ? { input: block.input } : {}) });
381
- if ((block.name === "Edit" || block.name === "Write") && block.input) {
382
- const fp = block.input.file_path || block.input.path;
383
- if (fp) {
384
- pendingFileReloads.add(String(fp));
385
- }
386
- }
387
- }
490
+ if (lower.includes("network error") || lower.includes("fetch")) {
491
+ return "Check that the dev server is running and reachable.";
388
492
  }
389
-
390
- if (toolBlocks.length > 0) {
391
- if (currentAssistantText) {
392
- messages.push({ content: currentAssistantText, role: "assistant" });
393
- }
394
- for (const t of toolBlocks) {
395
- messages.push({ content: "", role: "tool", toolUse: t });
396
- if (chatInstance) {
397
- chatInstance.messageAddNew(formatToolLabel(t.tool, t.input), "", "left", "tool");
398
- }
399
- }
493
+ if (lower.includes("429") || lower.includes("rate limit")) {
494
+ return "The API rate limit was hit. Wait a moment and try again.";
400
495
  }
401
-
402
- currentAssistantText = text;
403
- if (chatInstance) {
404
- currentStreamMsgId = chatInstance.messageAddNew(text || "", "", "left", "assistant");
405
- streamStarted = Boolean(text);
496
+ if (lower.includes("500") || lower.includes("internal")) {
497
+ return "The upstream API returned a server error. Try again in a moment.";
406
498
  }
499
+ return "";
407
500
  }
408
501
 
409
- // ─── Tool Label Formatting ─────────────────────────────────────────────────
502
+ // ─── Controls ─────────────────────────────────────────────────────────────────
410
503
 
411
- /**
412
- * @param {string} tool
413
- * @param {Record<string, unknown>} [input]
414
- */
415
- function formatToolLabel(tool: string, input?: Record<string, unknown>) {
416
- switch (tool) {
417
- case "Edit":
418
- case "Write": {
419
- return `📝 ${tool}: ${input?.file_path || input?.path || "file"}`;
420
- }
421
- case "Read": {
422
- return `📖 Read: ${input?.file_path || input?.path || "file"}`;
423
- }
424
- case "Bash": {
425
- return `⚡ Run: ${truncate(String(input?.command || ""), 50)}`;
426
- }
427
- case "Glob": {
428
- return `🔍 Glob: ${input?.pattern || ""}`;
429
- }
430
- case "Grep": {
431
- return `🔍 Grep: ${truncate(String(input?.pattern || ""), 40)}`;
432
- }
433
- default: {
434
- return `🔧 ${tool}`;
435
- }
436
- }
504
+ function stop() {
505
+ assistant.stop();
437
506
  }
438
507
 
439
- /** @param {string} s @param {number} max */
440
- function truncate(s: string, max: number) {
441
- return s.length > max ? `${s.slice(0, max)}…` : s;
508
+ function newChat() {
509
+ assistant.newChat();
510
+ assistantRenderedCount = 0;
511
+ assistantStreamingMsgId = null;
512
+ assistantStreamedLen = 0;
513
+ if (chatInstance) {
514
+ chatInstance.historyImport([]);
515
+ chatInstance.inputAreaSetEnabled(true);
516
+ }
517
+ rerenderPanel();
442
518
  }
443
519
 
444
520
  // ─── Template ───────────────────────────────────────────────────────────────
445
521
 
446
522
  /** @returns {import("lit-html").TemplateResult} */
447
523
  export function renderAiPanelTemplate() {
448
- if (authStatus === "checking" || authStatus === "unknown") {
449
- return html`<div class="ai-tab-body">
450
- <div class="ai-status-center">Checking authentication...</div>
451
- </div>`;
524
+ // The document assistant authenticates via the AI proxy (an OpenAI-compatible key). Gate the chat
525
+ // Behind the key form until one is stored locally.
526
+ if (!hasOpenAiKey() || keyEditing) {
527
+ return renderKeyGate();
452
528
  }
453
529
 
454
- if (authStatus === "unauthenticated") {
455
- return html`
456
- <div class="ai-tab-body">
457
- <div class="ai-status-center">
458
- <sp-icon-artboard style="font-size:32px"></sp-icon-artboard>
459
- <div>Claude authentication required</div>
460
- <div
461
- style="font-size:var(--spectrum-font-size-50, 11px);color:var(--spectrum-gray-600, #808080)"
462
- >
463
- Run the following in your terminal:
464
- </div>
465
- <code class="ai-code-snippet">npx @anthropic-ai/claude-code login</code>
466
- ${authError
467
- ? html`<sp-help-text variant="negative">${authError}</sp-help-text>`
468
- : nothing}
469
- <sp-button size="s" variant="primary" @click=${checkAuth}>Retry</sp-button>
470
- </div>
471
- </div>
472
- `;
473
- }
530
+ const busy = assistant.chatState.status === "streaming";
474
531
 
475
532
  return html`
476
533
  <div class="ai-tab-body">
477
534
  <div class="ai-toolbar">
478
- ${streaming
479
- ? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>`
480
- : nothing}
535
+ ${busy ? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>` : nothing}
481
536
  <sp-action-button size="xs" quiet @click=${newChat}>
482
537
  <sp-icon-add slot="icon"></sp-icon-add>
483
538
  New Chat
484
539
  </sp-action-button>
540
+ <sp-action-button size="xs" quiet title="API key & endpoint" @click=${startEditApiKey}>
541
+ 🔑
542
+ </sp-action-button>
485
543
  </div>
486
544
  <div
487
545
  id="ai-quikchat"