@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.
@@ -2,73 +2,101 @@
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 { createAiCredentialsForm } from "../ui/ai-credentials-form";
25
+ import { clearMarkdownCache } from "./ai-chat/chat-markdown";
26
+ import { renderChatHeader, renderMessageList } from "./ai-chat/chat-view";
27
+ import { createComposer } from "./ai-chat/composer";
28
+ import { renderSessionsList } from "./ai-chat/sessions-view";
29
+
26
30
  import type { EffectScope } from "@vue/reactivity";
27
31
 
28
32
  // ─── State (module-level, persists across tab switches) ─────────────────────
29
33
 
30
34
  let mounted = false;
31
35
 
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). */
36
+ /** Whether the OpenAI key form is showing (gate when no key, or re-edit via the gear). */
55
37
  let keyEditing = false;
56
- let keyDraft = "";
57
- let baseUrlDraft = "";
58
- let modelDraft = "";
59
38
 
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 = "";
39
+ /** Which pane the panel shows once the key gate is passed. */
40
+ let view: "chat" | "sessions" = "chat";
64
41
 
65
42
  /** Document AST assistant session — created lazily, persists across tab switches. */
66
43
  const assistant = createDocumentAssistant();
67
44
  (globalThis as Record<string, unknown>).assistant = assistant;
68
45
  let assistantScope: EffectScope | null = null;
69
- let assistantRenderedCount = 0;
70
- let assistantStreamingMsgId = null as number | null;
71
- let assistantStreamedLen = 0;
46
+
47
+ // ─── Render loop ────────────────────────────────────────────────────────────
48
+
49
+ /** The assistant tab's `.panel-body` container, bound once by the right panel. */
50
+ let hostEl: HTMLElement | null = null;
51
+ let renderQueued = false;
52
+
53
+ /**
54
+ * Bind the panel's render host and start the streaming watcher. Called once from the right panel's
55
+ * container setup; replaces the old global render-bridge.
56
+ *
57
+ * @param {HTMLElement} el
58
+ */
59
+ export function bindAiPanelHost(el: HTMLElement) {
60
+ hostEl = el;
61
+ watchAssistant();
62
+ }
63
+
64
+ /** Coalesce a re-render of the whole panel onto the next animation frame. */
65
+ function scheduleAiRender() {
66
+ if (renderQueued || !hostEl) {
67
+ return;
68
+ }
69
+ renderQueued = true;
70
+ requestAnimationFrame(() => {
71
+ renderQueued = false;
72
+ if (hostEl) {
73
+ litRender(renderAiPanelTemplate(), hostEl);
74
+ maintainScroll();
75
+ }
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Reactively repaint on chat-state changes. Tracks the message count, the tail message's growth
81
+ * (streaming deltas / tool calls), status, and errors; the actual DOM work happens in the
82
+ * rAF-coalesced render, so token rate never exceeds frame rate.
83
+ */
84
+ function watchAssistant() {
85
+ assistantScope?.stop();
86
+ assistantScope = effectScope();
87
+ assistantScope.run(() => {
88
+ effect(() => {
89
+ const cs = assistant.chatState;
90
+ void cs.messages.length;
91
+ const last = cs.messages.at(-1);
92
+ void last?.content;
93
+ void last?.toolCalls?.length;
94
+ void cs.status;
95
+ void cs.error;
96
+ scheduleAiRender();
97
+ });
98
+ });
99
+ }
72
100
 
73
101
  // ─── Lifecycle ──────────────────────────────────────────────────────────────
74
102
 
@@ -79,474 +107,180 @@ export function mountAiPanel() {
79
107
  mounted = true;
80
108
  }
81
109
 
82
- const _g = globalThis as unknown as {
83
- __jxRightPanelRender?: { render: () => void };
84
- };
110
+ // ─── Auto-scroll (stick to bottom unless the user scrolled up) ──────────────
85
111
 
86
- function rerenderPanel() {
87
- const { render } = _g.__jxRightPanelRender || {};
88
- if (render) {
89
- render();
90
- }
91
- requestAnimationFrame(() => mountQuikChat());
92
- }
112
+ let messagesEl: HTMLElement | null = null;
113
+ let stickToBottom = true;
93
114
 
94
- export function registerRightPanelRender(fn: () => void) {
95
- _g.__jxRightPanelRender = { render: fn };
96
- }
115
+ /** How close (px) to the bottom still counts as "at the bottom". */
116
+ const STICK_THRESHOLD = 48;
97
117
 
98
- // ─── QuikChat Mount ────────────────────────────────────────────────────────
118
+ function onMessagesScroll(e: Event) {
119
+ const el = e.target as HTMLElement;
120
+ stickToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
121
+ }
99
122
 
100
- export function mountQuikChat() {
101
- const container = _quikChatEl;
102
- if (!container) {
103
- return;
104
- }
105
- if (chatInstance && chatContainerEl === container) {
106
- return;
107
- }
123
+ function onMessagesListRef(el: Element | undefined) {
124
+ messagesEl = (el as HTMLElement | undefined) ?? null;
125
+ maintainScroll();
126
+ }
108
127
 
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);
128
+ function maintainScroll() {
129
+ if (messagesEl && stickToBottom) {
130
+ messagesEl.scrollTop = messagesEl.scrollHeight;
130
131
  }
131
132
  }
132
133
 
133
134
  // ─── OpenAI key settings ─────────────────────────────────────────────────────
134
135
 
136
+ /** The panel's shared credentials form (draft/model state lives inside the form's closure). */
137
+ const credsForm = createAiCredentialsForm({
138
+ onCancel: () => {
139
+ keyEditing = false;
140
+ },
141
+ onSaved: () => {
142
+ keyEditing = false;
143
+ },
144
+ requestRender: scheduleAiRender,
145
+ });
146
+
135
147
  /** Open the key form, pre-filled with the current settings. */
136
148
  function startEditApiKey() {
137
- keyDraft = getOpenAiKey();
138
- baseUrlDraft = getBaseUrl();
139
- modelDraft = getModel();
140
149
  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
- }
150
+ credsForm.startEdit();
212
151
  }
213
152
 
214
153
  /** The OpenAI (or compatible) key + endpoint settings form, shown as a gate when no key is set. */
215
154
  function renderKeyGate() {
216
- const haveKey = hasOpenAiKey();
217
155
  return html`
218
156
  <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>
157
+ <div class="ai-status-center">${credsForm.render()}</div>
294
158
  </div>
295
159
  `;
296
160
  }
297
161
 
298
- // ─── Document assistant rendering ────────────────────────────────────────────
162
+ // ─── Sending ────────────────────────────────────────────────────────────────
299
163
 
300
164
  /** Send a message through the document assistant agent loop. */
301
165
  async function handleAssistantSend(text: string) {
302
166
  if (!text.trim() || assistant.chatState.status === "streaming") {
303
167
  return;
304
168
  }
305
- chatInstance?.inputAreaSetEnabled(false);
169
+ // A send always lands in the chat view, pinned to the newest message.
170
+ view = "chat";
171
+ stickToBottom = true;
172
+ scheduleAiRender();
306
173
  try {
307
174
  await assistant.sendMessage(text);
308
175
  } catch {
309
176
  // 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;
177
+ // Own try/catch calls chatState.setError(), which the watcher renders.
367
178
  }
368
179
  }
369
180
 
370
181
  /**
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.
182
+ * Seed the assistant with a prompt programmatically (e.g. the New Project flow handing off a
183
+ * project brief). Delegates to the same send path as the composer. Safe to call right after the
184
+ * Assistant tab renders — the reactive watcher paints chat-state into the panel whenever it
185
+ * mounts.
373
186
  */
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
- });
187
+ export async function seedAssistantPrompt(text: string): Promise<void> {
188
+ await handleAssistantSend(text);
436
189
  }
437
190
 
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;
191
+ // ─── Controls ─────────────────────────────────────────────────────────────────
192
+
193
+ function stop() {
194
+ assistant.stop();
457
195
  }
458
196
 
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}`;
197
+ function newChat() {
198
+ assistant.newChat();
199
+ clearMarkdownCache();
200
+ view = "chat";
201
+ stickToBottom = true;
202
+ scheduleAiRender();
476
203
  }
477
204
 
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 "";
205
+ function openSession(id: string) {
206
+ assistant.openSession(id);
207
+ clearMarkdownCache();
208
+ view = "chat";
209
+ stickToBottom = true;
210
+ scheduleAiRender();
500
211
  }
501
212
 
502
- // ─── Controls ─────────────────────────────────────────────────────────────────
213
+ function deleteSession(id: string) {
214
+ assistant.deleteSession(id);
215
+ scheduleAiRender();
216
+ }
503
217
 
504
- function stop() {
505
- assistant.stop();
218
+ function showSessions() {
219
+ view = "sessions";
220
+ scheduleAiRender();
506
221
  }
507
222
 
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);
223
+ /** The open session's title for the chat header (null → "New chat"). */
224
+ function activeSessionTitle(): string | null {
225
+ const id = assistant.activeSessionId();
226
+ if (!id) {
227
+ return null;
516
228
  }
517
- rerenderPanel();
229
+ return assistant.listSessions().find((s) => s.id === id)?.title ?? null;
518
230
  }
519
231
 
232
+ // ─── Composer ───────────────────────────────────────────────────────────────
233
+
234
+ const composer = createComposer({
235
+ isStreaming: () => assistant.chatState.status === "streaming",
236
+ onOpenSettings: startEditApiKey,
237
+ onSend: (text) => {
238
+ void handleAssistantSend(text);
239
+ },
240
+ onStop: stop,
241
+ requestRender: scheduleAiRender,
242
+ });
243
+
520
244
  // ─── Template ───────────────────────────────────────────────────────────────
521
245
 
522
- /** @returns {import("lit-html").TemplateResult} */
523
- export function renderAiPanelTemplate() {
246
+ /** @returns {TemplateResult} */
247
+ export function renderAiPanelTemplate(): TemplateResult {
524
248
  // The document assistant authenticates via the AI proxy (an OpenAI-compatible key). Gate the chat
525
249
  // Behind the key form until one is stored locally.
526
250
  if (!hasOpenAiKey() || keyEditing) {
527
251
  return renderKeyGate();
528
252
  }
529
253
 
530
- const busy = assistant.chatState.status === "streaming";
254
+ if (view === "sessions") {
255
+ return html`
256
+ <div class="ai-tab-body">
257
+ ${renderSessionsList({
258
+ onDelete: deleteSession,
259
+ onNew: newChat,
260
+ onOpen: openSession,
261
+ sessions: assistant.listSessions(),
262
+ })}
263
+ </div>
264
+ `;
265
+ }
531
266
 
267
+ const cs = assistant.chatState;
532
268
  return html`
533
269
  <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>
270
+ ${renderChatHeader({
271
+ onNewChat: newChat,
272
+ onShowSessions: showSessions,
273
+ streaming: cs.status === "streaming",
274
+ title: activeSessionTitle(),
275
+ })}
276
+ ${renderMessageList({
277
+ error: cs.error,
278
+ listRef: onMessagesListRef,
279
+ messages: cs.messages,
280
+ onScroll: onMessagesScroll,
281
+ status: cs.status,
282
+ })}
283
+ ${composer.render()}
550
284
  </div>
551
285
  `;
552
286
  }