@jxsuite/studio 0.34.0 → 0.36.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 (41) hide show
  1. package/dist/iframe-entry.js +9 -1
  2. package/dist/iframe-entry.js.map +3 -3
  3. package/dist/studio.js +18382 -3686
  4. package/dist/studio.js.map +265 -26
  5. package/package.json +9 -6
  6. package/src/files/files.ts +3 -0
  7. package/src/new-project/design-fields.ts +260 -0
  8. package/src/new-project/import-tab.ts +322 -0
  9. package/src/new-project/new-project-modal.ts +472 -89
  10. package/src/new-project/templates.ts +61 -0
  11. package/src/packages/ensure-deps.ts +6 -0
  12. package/src/packages/pull-package-sync.ts +339 -0
  13. package/src/panels/ai-chat/attached-context.ts +49 -0
  14. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  15. package/src/panels/ai-chat/chat-view.ts +250 -0
  16. package/src/panels/ai-chat/composer.ts +315 -0
  17. package/src/panels/ai-chat/sessions-view.ts +98 -0
  18. package/src/panels/ai-panel.ts +214 -458
  19. package/src/panels/drag-ghost.ts +1 -1
  20. package/src/panels/git-panel.ts +16 -1
  21. package/src/panels/right-panel.ts +21 -5
  22. package/src/panels/toolbar.ts +2 -0
  23. package/src/panels/welcome-screen.ts +26 -0
  24. package/src/platforms/cloud.ts +774 -0
  25. package/src/platforms/devserver.ts +100 -1
  26. package/src/project-list.ts +38 -0
  27. package/src/publish/pages-service.ts +186 -0
  28. package/src/publish/publish-panel.ts +360 -0
  29. package/src/services/agent-seed.ts +91 -0
  30. package/src/services/ai-models.ts +93 -0
  31. package/src/services/ai-session-store.ts +250 -0
  32. package/src/services/ai-settings.ts +5 -0
  33. package/src/services/automation.ts +140 -0
  34. package/src/services/cf-settings.ts +58 -0
  35. package/src/services/document-assistant.ts +98 -38
  36. package/src/services/import-client.ts +134 -0
  37. package/src/services/settings-store.ts +99 -0
  38. package/src/studio.ts +33 -0
  39. package/src/types.ts +130 -133
  40. package/src/ui/ai-credentials-form.ts +205 -0
  41. package/src/ui/spectrum.ts +8 -0
@@ -2,73 +2,119 @@
2
2
  /**
3
3
  * Ai-panel.ts — AI assistant tab for the right panel (Stack B document assistant).
4
4
  *
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).
5
+ * Native lit-html chat UI (VSCode-Copilot style) over the reactive document-assistant
6
+ * session: a view-state machine (key gate sessions list chat), a message list with
7
+ * stick-to-bottom scrolling, and the sticky composer.
8
+ *
9
+ * Rendering: this panel owns a private rAF-coalesced render loop into the assistant
10
+ * `.panel-body` container (bound once via {@link bindAiPanelHost}). It deliberately
11
+ * bypasses the right-panel scheduler's focus guard — streaming must repaint while the
12
+ * composer is focused — which is safe because nothing here is value-bound (the composer
13
+ * textarea is uncontrolled). The right panel renders the same template into the same
14
+ * container on tab switches; lit reconciles both paths through one part cache.
15
+ *
16
+ * @license MIT
7
17
  */
8
18
 
9
- import { html, nothing } from "lit-html";
10
- import { ref } from "lit-html/directives/ref.js";
11
- import quikchat from "quikchat/md";
12
- import { getPlatform } from "../platform";
19
+ import { html, render as litRender } from "lit-html";
20
+ import type { TemplateResult } from "lit-html";
13
21
  import { effect, effectScope } from "../reactivity";
14
22
  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";
23
+ import { hasOpenAiKey } from "../services/ai-settings";
24
+ import { fetchAvailableModels, isProxyConfigured } from "../services/ai-models";
25
+ import { createAiCredentialsForm } from "../ui/ai-credentials-form";
26
+ import { clearMarkdownCache } from "./ai-chat/chat-markdown";
27
+ import { renderChatHeader, renderMessageList } from "./ai-chat/chat-view";
28
+ import { createComposer } from "./ai-chat/composer";
29
+ import { renderSessionsList } from "./ai-chat/sessions-view";
30
+
26
31
  import type { EffectScope } from "@vue/reactivity";
27
32
 
28
33
  // ─── State (module-level, persists across tab switches) ─────────────────────
29
34
 
30
35
  let mounted = false;
31
36
 
32
- /** Minimal surface of the untyped quikchat library that this panel uses. */
33
- interface QuikChatInstance {
34
- messageAddNew: (text: string, sender: string, side: string, role?: string) => number;
35
- messageAddTypingIndicator: (text: string) => number;
36
- messageReplaceContent: (id: number, text: string) => void;
37
- messageAppendContent: (id: number, text: string) => void;
38
- inputAreaSetEnabled: (enabled: boolean) => void;
39
- historyImport: (history: unknown[]) => void;
40
- }
41
-
42
- type QuikChatCtor = new (
43
- container: HTMLElement,
44
- onSend: (chat: unknown, msg: string) => void,
45
- opts: Record<string, unknown>,
46
- ) => QuikChatInstance;
47
-
48
- const QuikChat = quikchat as unknown as QuikChatCtor;
49
-
50
- let chatInstance: QuikChatInstance | null = null;
51
- let chatContainerEl: Element | null = null;
52
- let _quikChatEl: HTMLElement | null = null;
53
-
54
- /** Whether the OpenAI key form is showing (gate when no key, or re-edit via the toolbar). */
37
+ /** Whether the OpenAI key form is showing (gate when no key, or re-edit via the gear). */
55
38
  let keyEditing = false;
56
- let keyDraft = "";
57
- let baseUrlDraft = "";
58
- let modelDraft = "";
59
39
 
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 = "";
40
+ /**
41
+ * One-time proxy probe: managed platforms (cloud Workers AI) and env-keyed dev servers report
42
+ * `configured` from /models, unlocking the assistant without a locally stored key. Fired lazily on
43
+ * the first gated render.
44
+ */
45
+ let proxyProbe: Promise<void> | null = null;
46
+
47
+ function ensureProxyProbe() {
48
+ proxyProbe ??= fetchAvailableModels({ force: true })
49
+ .catch(() => {
50
+ // Unreachable proxy — the key gate stays up.
51
+ })
52
+ .then(() => {
53
+ scheduleAiRender();
54
+ });
55
+ }
56
+
57
+ /** Which pane the panel shows once the key gate is passed. */
58
+ let view: "chat" | "sessions" = "chat";
64
59
 
65
60
  /** Document AST assistant session — created lazily, persists across tab switches. */
66
61
  const assistant = createDocumentAssistant();
67
62
  (globalThis as Record<string, unknown>).assistant = assistant;
68
63
  let assistantScope: EffectScope | null = null;
69
- let assistantRenderedCount = 0;
70
- let assistantStreamingMsgId = null as number | null;
71
- let assistantStreamedLen = 0;
64
+
65
+ // ─── Render loop ────────────────────────────────────────────────────────────
66
+
67
+ /** The assistant tab's `.panel-body` container, bound once by the right panel. */
68
+ let hostEl: HTMLElement | null = null;
69
+ let renderQueued = false;
70
+
71
+ /**
72
+ * Bind the panel's render host and start the streaming watcher. Called once from the right panel's
73
+ * container setup; replaces the old global render-bridge.
74
+ *
75
+ * @param {HTMLElement} el
76
+ */
77
+ export function bindAiPanelHost(el: HTMLElement) {
78
+ hostEl = el;
79
+ watchAssistant();
80
+ }
81
+
82
+ /** Coalesce a re-render of the whole panel onto the next animation frame. */
83
+ function scheduleAiRender() {
84
+ if (renderQueued || !hostEl) {
85
+ return;
86
+ }
87
+ renderQueued = true;
88
+ requestAnimationFrame(() => {
89
+ renderQueued = false;
90
+ if (hostEl) {
91
+ litRender(renderAiPanelTemplate(), hostEl);
92
+ maintainScroll();
93
+ }
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Reactively repaint on chat-state changes. Tracks the message count, the tail message's growth
99
+ * (streaming deltas / tool calls), status, and errors; the actual DOM work happens in the
100
+ * rAF-coalesced render, so token rate never exceeds frame rate.
101
+ */
102
+ function watchAssistant() {
103
+ assistantScope?.stop();
104
+ assistantScope = effectScope();
105
+ assistantScope.run(() => {
106
+ effect(() => {
107
+ const cs = assistant.chatState;
108
+ void cs.messages.length;
109
+ const last = cs.messages.at(-1);
110
+ void last?.content;
111
+ void last?.toolCalls?.length;
112
+ void cs.status;
113
+ void cs.error;
114
+ scheduleAiRender();
115
+ });
116
+ });
117
+ }
72
118
 
73
119
  // ─── Lifecycle ──────────────────────────────────────────────────────────────
74
120
 
@@ -79,474 +125,184 @@ export function mountAiPanel() {
79
125
  mounted = true;
80
126
  }
81
127
 
82
- const _g = globalThis as unknown as {
83
- __jxRightPanelRender?: { render: () => void };
84
- };
128
+ // ─── Auto-scroll (stick to bottom unless the user scrolled up) ──────────────
85
129
 
86
- function rerenderPanel() {
87
- const { render } = _g.__jxRightPanelRender || {};
88
- if (render) {
89
- render();
90
- }
91
- requestAnimationFrame(() => mountQuikChat());
92
- }
130
+ let messagesEl: HTMLElement | null = null;
131
+ let stickToBottom = true;
93
132
 
94
- export function registerRightPanelRender(fn: () => void) {
95
- _g.__jxRightPanelRender = { render: fn };
96
- }
133
+ /** How close (px) to the bottom still counts as "at the bottom". */
134
+ const STICK_THRESHOLD = 48;
97
135
 
98
- // ─── QuikChat Mount ────────────────────────────────────────────────────────
136
+ function onMessagesScroll(e: Event) {
137
+ const el = e.target as HTMLElement;
138
+ stickToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
139
+ }
99
140
 
100
- export function mountQuikChat() {
101
- const container = _quikChatEl;
102
- if (!container) {
103
- return;
104
- }
105
- if (chatInstance && chatContainerEl === container) {
106
- return;
107
- }
141
+ function onMessagesListRef(el: Element | undefined) {
142
+ messagesEl = (el as HTMLElement | undefined) ?? null;
143
+ maintainScroll();
144
+ }
108
145
 
109
- chatInstance = new QuikChat(
110
- container,
111
- (_chat: unknown, msg: string) => {
112
- void handleAssistantSend(msg);
113
- },
114
- {
115
- messagesArea: { alternating: false },
116
- showTimestamps: false,
117
- theme: "quikchat-theme-dark",
118
- titleArea: { show: false },
119
- },
120
- );
121
- chatContainerEl = container;
122
-
123
- assistantRenderedCount = 0;
124
- assistantStreamingMsgId = null;
125
- assistantStreamedLen = 0;
126
- replayAssistantMessages();
127
- watchAssistant();
128
- if (assistant.chatState.status === "streaming") {
129
- chatInstance?.inputAreaSetEnabled(false);
146
+ function maintainScroll() {
147
+ if (messagesEl && stickToBottom) {
148
+ messagesEl.scrollTop = messagesEl.scrollHeight;
130
149
  }
131
150
  }
132
151
 
133
152
  // ─── OpenAI key settings ─────────────────────────────────────────────────────
134
153
 
154
+ /** The panel's shared credentials form (draft/model state lives inside the form's closure). */
155
+ const credsForm = createAiCredentialsForm({
156
+ onCancel: () => {
157
+ keyEditing = false;
158
+ },
159
+ onSaved: () => {
160
+ keyEditing = false;
161
+ },
162
+ requestRender: scheduleAiRender,
163
+ });
164
+
135
165
  /** Open the key form, pre-filled with the current settings. */
136
166
  function startEditApiKey() {
137
- keyDraft = getOpenAiKey();
138
- baseUrlDraft = getBaseUrl();
139
- modelDraft = getModel();
140
167
  keyEditing = true;
141
- rerenderPanel();
142
- // Auto-fetch available models if not already loaded.
143
- if (availableModels.length === 0 && !modelsLoading) {
144
- void fetchModels();
145
- }
146
- }
147
-
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 = [];
159
- rerenderPanel();
160
- }
161
-
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
- }
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();
180
- try {
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
- }
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;
195
- }
196
-
197
- const resp = await fetch(modelsUrl, { headers });
198
- if (!resp.ok) {
199
- throw new Error(`HTTP ${resp.status}`);
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;
210
- rerenderPanel();
211
- }
168
+ credsForm.startEdit();
212
169
  }
213
170
 
214
171
  /** The OpenAI (or compatible) key + endpoint settings form, shown as a gate when no key is set. */
215
172
  function renderKeyGate() {
216
- const haveKey = hasOpenAiKey();
217
173
  return html`
218
174
  <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>
175
+ <div class="ai-status-center">${credsForm.render()}</div>
294
176
  </div>
295
177
  `;
296
178
  }
297
179
 
298
- // ─── Document assistant rendering ────────────────────────────────────────────
180
+ // ─── Sending ────────────────────────────────────────────────────────────────
299
181
 
300
182
  /** Send a message through the document assistant agent loop. */
301
183
  async function handleAssistantSend(text: string) {
302
184
  if (!text.trim() || assistant.chatState.status === "streaming") {
303
185
  return;
304
186
  }
305
- chatInstance?.inputAreaSetEnabled(false);
187
+ // A send always lands in the chat view, pinned to the newest message.
188
+ view = "chat";
189
+ stickToBottom = true;
190
+ scheduleAiRender();
306
191
  try {
307
192
  await assistant.sendMessage(text);
308
193
  } catch {
309
194
  // 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);
318
- }
319
- }
320
-
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");
340
- }
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
- }
350
-
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;
364
- }
365
- renderAssistantMessage(m);
366
- assistantRenderedCount = i + 1;
195
+ // Own try/catch calls chatState.setError(), which the watcher renders.
367
196
  }
368
197
  }
369
198
 
370
199
  /**
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.
200
+ * Seed the assistant with a prompt programmatically (e.g. the New Project flow handing off a
201
+ * project brief). Delegates to the same send path as the composer. Safe to call right after the
202
+ * Assistant tab renders — the reactive watcher paints chat-state into the panel whenever it
203
+ * mounts.
373
204
  */
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;
384
- }
385
-
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;
418
- }
419
-
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
- });
435
- });
205
+ export async function seedAssistantPrompt(text: string): Promise<void> {
206
+ await handleAssistantSend(text);
436
207
  }
437
208
 
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 };
452
- }
453
- } catch {
454
- /* Not JSON — not a tool result */
455
- }
456
- return null;
209
+ // ─── Controls ─────────────────────────────────────────────────────────────────
210
+
211
+ function stop() {
212
+ assistant.stop();
457
213
  }
458
214
 
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 */
474
- }
475
- return `🔧 ${tc.name}${detail}`;
215
+ function newChat() {
216
+ assistant.newChat();
217
+ clearMarkdownCache();
218
+ view = "chat";
219
+ stickToBottom = true;
220
+ scheduleAiRender();
476
221
  }
477
222
 
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.";
489
- }
490
- if (lower.includes("network error") || lower.includes("fetch")) {
491
- return "Check that the dev server is running and reachable.";
492
- }
493
- if (lower.includes("429") || lower.includes("rate limit")) {
494
- return "The API rate limit was hit. Wait a moment and try again.";
495
- }
496
- if (lower.includes("500") || lower.includes("internal")) {
497
- return "The upstream API returned a server error. Try again in a moment.";
498
- }
499
- return "";
223
+ function openSession(id: string) {
224
+ assistant.openSession(id);
225
+ clearMarkdownCache();
226
+ view = "chat";
227
+ stickToBottom = true;
228
+ scheduleAiRender();
500
229
  }
501
230
 
502
- // ─── Controls ─────────────────────────────────────────────────────────────────
231
+ function deleteSession(id: string) {
232
+ assistant.deleteSession(id);
233
+ scheduleAiRender();
234
+ }
503
235
 
504
- function stop() {
505
- assistant.stop();
236
+ function showSessions() {
237
+ view = "sessions";
238
+ scheduleAiRender();
506
239
  }
507
240
 
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);
241
+ /** The open session's title for the chat header (null → "New chat"). */
242
+ function activeSessionTitle(): string | null {
243
+ const id = assistant.activeSessionId();
244
+ if (!id) {
245
+ return null;
516
246
  }
517
- rerenderPanel();
247
+ return assistant.listSessions().find((s) => s.id === id)?.title ?? null;
518
248
  }
519
249
 
250
+ // ─── Composer ───────────────────────────────────────────────────────────────
251
+
252
+ const composer = createComposer({
253
+ isStreaming: () => assistant.chatState.status === "streaming",
254
+ onOpenSettings: startEditApiKey,
255
+ onSend: (text) => {
256
+ void handleAssistantSend(text);
257
+ },
258
+ onStop: stop,
259
+ requestRender: scheduleAiRender,
260
+ });
261
+
520
262
  // ─── Template ───────────────────────────────────────────────────────────────
521
263
 
522
- /** @returns {import("lit-html").TemplateResult} */
523
- export function renderAiPanelTemplate() {
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) {
264
+ /** @returns {TemplateResult} */
265
+ export function renderAiPanelTemplate(): TemplateResult {
266
+ /* The document assistant authenticates via the AI proxy. Gate the chat
267
+ behind the key form until a key is stored locally OR the proxy reports
268
+ itself configured (managed platforms, env-keyed dev servers). */
269
+ if ((!hasOpenAiKey() && !isProxyConfigured()) || keyEditing) {
270
+ if (!keyEditing) {
271
+ ensureProxyProbe();
272
+ }
527
273
  return renderKeyGate();
528
274
  }
529
275
 
530
- const busy = assistant.chatState.status === "streaming";
276
+ if (view === "sessions") {
277
+ return html`
278
+ <div class="ai-tab-body">
279
+ ${renderSessionsList({
280
+ onDelete: deleteSession,
281
+ onNew: newChat,
282
+ onOpen: openSession,
283
+ sessions: assistant.listSessions(),
284
+ })}
285
+ </div>
286
+ `;
287
+ }
531
288
 
289
+ const cs = assistant.chatState;
532
290
  return html`
533
291
  <div class="ai-tab-body">
534
- <div class="ai-toolbar">
535
- ${busy ? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>` : nothing}
536
- <sp-action-button size="xs" quiet @click=${newChat}>
537
- <sp-icon-add slot="icon"></sp-icon-add>
538
- New Chat
539
- </sp-action-button>
540
- <sp-action-button size="xs" quiet title="API key & endpoint" @click=${startEditApiKey}>
541
- 🔑
542
- </sp-action-button>
543
- </div>
544
- <div
545
- id="ai-quikchat"
546
- ${ref((el) => {
547
- _quikChatEl = (el as HTMLElement | null) || null;
548
- })}
549
- ></div>
292
+ ${renderChatHeader({
293
+ onNewChat: newChat,
294
+ onShowSessions: showSessions,
295
+ streaming: cs.status === "streaming",
296
+ title: activeSessionTitle(),
297
+ })}
298
+ ${renderMessageList({
299
+ error: cs.error,
300
+ listRef: onMessagesListRef,
301
+ messages: cs.messages,
302
+ onScroll: onMessagesScroll,
303
+ status: cs.status,
304
+ })}
305
+ ${composer.render()}
550
306
  </div>
551
307
  `;
552
308
  }