@jxsuite/studio 0.19.0 → 0.20.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 (93) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +12577 -12183
  3. package/dist/studio.js.map +146 -144
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +17 -6
  8. package/src/browse/browse.js +25 -19
  9. package/src/canvas/canvas-diff.js +24 -16
  10. package/src/canvas/canvas-helpers.js +24 -19
  11. package/src/canvas/canvas-live-render.js +94 -39
  12. package/src/canvas/canvas-render.js +119 -73
  13. package/src/canvas/canvas-utils.js +38 -15
  14. package/src/canvas/nested-site-style.js +50 -0
  15. package/src/editor/component-inline-edit.js +14 -13
  16. package/src/editor/content-inline-edit.js +22 -10
  17. package/src/editor/context-menu.js +1 -1
  18. package/src/editor/convert-to-component.js +12 -10
  19. package/src/editor/inline-edit.js +66 -28
  20. package/src/editor/insertion-helper.js +16 -12
  21. package/src/editor/shortcuts.js +4 -2
  22. package/src/editor/slash-menu.js +6 -4
  23. package/src/files/components.js +1 -1
  24. package/src/files/file-ops.js +37 -16
  25. package/src/files/files.js +78 -52
  26. package/src/github/github-auth.js +122 -0
  27. package/src/github/github-publish.js +115 -0
  28. package/src/markdown/md-convert.js +172 -107
  29. package/src/new-project/new-project-modal.js +204 -0
  30. package/src/panels/activity-bar.js +22 -20
  31. package/src/panels/ai-panel.js +399 -0
  32. package/src/panels/block-action-bar.js +5 -5
  33. package/src/panels/canvas-dnd.js +8 -2
  34. package/src/panels/data-explorer.js +19 -13
  35. package/src/panels/dnd.js +144 -92
  36. package/src/panels/editors.js +55 -35
  37. package/src/panels/elements-panel.js +33 -19
  38. package/src/panels/events-panel.js +15 -10
  39. package/src/panels/git-panel.js +469 -98
  40. package/src/panels/head-panel.js +121 -81
  41. package/src/panels/imports-panel.js +88 -64
  42. package/src/panels/layers-panel.js +64 -43
  43. package/src/panels/left-panel.js +85 -47
  44. package/src/panels/overlays.js +1 -0
  45. package/src/panels/panel-events.js +45 -30
  46. package/src/panels/preview-render.js +23 -18
  47. package/src/panels/properties-panel.js +365 -249
  48. package/src/panels/pseudo-preview.js +2 -2
  49. package/src/panels/quick-search.js +7 -5
  50. package/src/panels/right-panel.js +26 -2
  51. package/src/panels/shared.js +4 -4
  52. package/src/panels/signals-panel.js +295 -166
  53. package/src/panels/statusbar.js +4 -4
  54. package/src/panels/style-inputs.js +67 -35
  55. package/src/panels/style-panel.js +233 -132
  56. package/src/panels/style-utils.js +34 -16
  57. package/src/panels/stylebook-layers-panel.js +12 -10
  58. package/src/panels/stylebook-panel.js +134 -66
  59. package/src/panels/toolbar.js +333 -109
  60. package/src/panels/welcome-screen.js +121 -0
  61. package/src/platform.js +2 -4
  62. package/src/platforms/devserver.js +113 -7
  63. package/src/resize-edges.js +98 -0
  64. package/src/services/cem-export.js +12 -7
  65. package/src/services/code-services.js +30 -12
  66. package/src/settings/content-types-editor.js +7 -7
  67. package/src/settings/css-vars-editor.js +30 -24
  68. package/src/settings/defs-editor.js +12 -7
  69. package/src/settings/general-settings.js +85 -3
  70. package/src/settings/head-editor.js +30 -24
  71. package/src/settings/schema-field-ui.js +58 -39
  72. package/src/site-context.js +41 -31
  73. package/src/state.js +70 -29
  74. package/src/store.js +21 -26
  75. package/src/studio.js +103 -81
  76. package/src/tabs/tab.js +67 -43
  77. package/src/tabs/transact.js +23 -13
  78. package/src/ui/button-group.js +24 -24
  79. package/src/ui/color-selector.js +28 -23
  80. package/src/ui/field-row.js +3 -3
  81. package/src/ui/icons.js +2 -2
  82. package/src/ui/media-picker.js +7 -7
  83. package/src/ui/spectrum.js +5 -1
  84. package/src/ui/unit-selector.js +18 -16
  85. package/src/ui/value-selector.js +21 -15
  86. package/src/ui/widgets.js +22 -19
  87. package/src/utils/canvas-media.js +5 -4
  88. package/src/utils/edit-display.js +31 -20
  89. package/src/utils/google-fonts.js +11 -11
  90. package/src/utils/inherited-style.js +9 -8
  91. package/src/utils/studio-utils.js +7 -7
  92. package/src/view.js +58 -1
  93. package/src/workspace/workspace.js +10 -7
@@ -0,0 +1,204 @@
1
+ /**
2
+ * New Project modal — guides the user through creating a new Jx project. Fields mirror the CLI
3
+ * scaffolder: name, description, url, adapter.
4
+ */
5
+
6
+ import { html } from "lit-html";
7
+ import { openModal } from "../ui/layers.js";
8
+ import { getPlatform } from "../platform.js";
9
+
10
+ /** @type {ReturnType<typeof openModal> | null} */
11
+ let _handle = null;
12
+
13
+ /**
14
+ * @type {{
15
+ * name: string;
16
+ * description: string;
17
+ * url: string;
18
+ * adapter: string;
19
+ * directory: string;
20
+ * }}
21
+ */
22
+ let _form = { name: "", description: "", url: "", adapter: "static", directory: "" };
23
+
24
+ /** @type {string} */
25
+ let _error = "";
26
+
27
+ /** @type {boolean} */
28
+ let _creating = false;
29
+
30
+ /** @type {((result: { root: string; config: object } | null) => void) | null} */
31
+ let _resolve = null;
32
+
33
+ /**
34
+ * Open the New Project modal. Returns a promise that resolves with the created project info (or
35
+ * null if cancelled).
36
+ *
37
+ * @returns {Promise<{ root: string; config: object } | null>}
38
+ */
39
+ export function openNewProjectModal() {
40
+ if (_handle) return Promise.resolve(null);
41
+ _form = { name: "", description: "", url: "", adapter: "static", directory: "" };
42
+ _error = "";
43
+ _creating = false;
44
+
45
+ return new Promise((resolve) => {
46
+ _resolve = resolve;
47
+ renderModal();
48
+ });
49
+ }
50
+
51
+ export function closeNewProjectModal() {
52
+ if (!_handle) return;
53
+ _handle.close();
54
+ _handle = null;
55
+ if (_resolve) {
56
+ _resolve(null);
57
+ _resolve = null;
58
+ }
59
+ }
60
+
61
+ function renderModal() {
62
+ const onInput =
63
+ (/** @type {"name" | "description" | "url" | "adapter" | "directory"} */ field) =>
64
+ (/** @type {Event} */ e) => {
65
+ _form[field] = /** @type {HTMLInputElement} */ (e.target).value;
66
+ if (field === "name" && !_form.directory) {
67
+ // Auto-derive directory slug from name while user hasn't manually typed one
68
+ _dirDerived = true;
69
+ }
70
+ if (_dirDerived && field === "name") {
71
+ _form.directory = _form.name
72
+ .toLowerCase()
73
+ .replace(/[^a-z0-9]+/g, "-")
74
+ .replace(/^-|-$/g, "");
75
+ }
76
+ if (field === "directory") _dirDerived = false;
77
+ renderModal();
78
+ };
79
+
80
+ const onAdapterChange = (/** @type {Event} */ e) => {
81
+ _form.adapter = /** @type {HTMLInputElement} */ (e.target).value;
82
+ renderModal();
83
+ };
84
+
85
+ const onSubmit = async () => {
86
+ if (!_form.name.trim()) {
87
+ _error = "Project name is required";
88
+ renderModal();
89
+ return;
90
+ }
91
+ if (!_form.directory.trim()) {
92
+ _form.directory = _form.name
93
+ .toLowerCase()
94
+ .replace(/[^a-z0-9]+/g, "-")
95
+ .replace(/^-|-$/g, "");
96
+ }
97
+
98
+ _creating = true;
99
+ _error = "";
100
+ renderModal();
101
+
102
+ try {
103
+ const platform = getPlatform();
104
+ const result = await platform.createProject(_form);
105
+ _creating = false;
106
+ if (_handle) {
107
+ _handle.close();
108
+ _handle = null;
109
+ }
110
+ if (_resolve) {
111
+ _resolve(result);
112
+ _resolve = null;
113
+ }
114
+ } catch (/** @type {unknown} */ e) {
115
+ _creating = false;
116
+ _error = /** @type {Error} */ (e).message;
117
+ renderModal();
118
+ }
119
+ };
120
+
121
+ const tpl = html`
122
+ <sp-underlay open @close=${closeNewProjectModal}></sp-underlay>
123
+ <div
124
+ class="new-project-modal"
125
+ @keydown=${(/** @type {KeyboardEvent} */ e) => {
126
+ if (e.key === "Escape") closeNewProjectModal();
127
+ }}
128
+ >
129
+ <div class="new-project-modal-header">
130
+ <h2 class="new-project-modal-title">New Project</h2>
131
+ <sp-action-button quiet size="s" @click=${closeNewProjectModal} title="Close">
132
+ <sp-icon-close slot="icon"></sp-icon-close>
133
+ </sp-action-button>
134
+ </div>
135
+ <div class="new-project-modal-body">
136
+ <label class="new-project-field">
137
+ <span class="new-project-label">Project Name *</span>
138
+ <sp-textfield
139
+ placeholder="My Site"
140
+ .value=${_form.name}
141
+ @input=${onInput("name")}
142
+ style="width: 100%"
143
+ ></sp-textfield>
144
+ </label>
145
+
146
+ <label class="new-project-field">
147
+ <span class="new-project-label">Directory</span>
148
+ <sp-textfield
149
+ placeholder="my-site"
150
+ .value=${_form.directory}
151
+ @input=${onInput("directory")}
152
+ style="width: 100%"
153
+ ></sp-textfield>
154
+ </label>
155
+
156
+ <label class="new-project-field">
157
+ <span class="new-project-label">Description</span>
158
+ <sp-textfield
159
+ placeholder="A short description of the site"
160
+ .value=${_form.description}
161
+ @input=${onInput("description")}
162
+ style="width: 100%"
163
+ ></sp-textfield>
164
+ </label>
165
+
166
+ <label class="new-project-field">
167
+ <span class="new-project-label">Production URL</span>
168
+ <sp-textfield
169
+ placeholder="https://example.com"
170
+ .value=${_form.url}
171
+ @input=${onInput("url")}
172
+ style="width: 100%"
173
+ ></sp-textfield>
174
+ </label>
175
+
176
+ <label class="new-project-field">
177
+ <span class="new-project-label">Deployment Adapter</span>
178
+ <sp-picker label="Adapter" .value=${_form.adapter} @change=${onAdapterChange}>
179
+ <sp-menu-item value="static">Static</sp-menu-item>
180
+ <sp-menu-item value="cloudflare-pages">Cloudflare Pages</sp-menu-item>
181
+ <sp-menu-item value="node">Node</sp-menu-item>
182
+ <sp-menu-item value="bun">Bun</sp-menu-item>
183
+ </sp-picker>
184
+ </label>
185
+
186
+ ${_error ? html`<div class="new-project-error">${_error}</div>` : ""}
187
+ </div>
188
+ <div class="new-project-modal-footer">
189
+ <sp-button variant="secondary" @click=${closeNewProjectModal}>Cancel</sp-button>
190
+ <sp-button variant="accent" ?disabled=${_creating} @click=${onSubmit}>
191
+ ${_creating ? "Creating…" : "Create Project"}
192
+ </sp-button>
193
+ </div>
194
+ </div>
195
+ `;
196
+
197
+ if (!_handle) {
198
+ _handle = openModal(tpl);
199
+ } else {
200
+ _handle.update(tpl);
201
+ }
202
+ }
203
+
204
+ let _dirDerived = true;
@@ -31,7 +31,7 @@ export function unmount() {
31
31
  _scope = null;
32
32
  }
33
33
 
34
- const gitBranchIcon = (/** @type {any} */ s) => html`
34
+ const gitBranchIcon = (/** @type {string} */ s) => html`
35
35
  <svg
36
36
  slot="icon"
37
37
  xmlns="http://www.w3.org/2000/svg"
@@ -52,35 +52,37 @@ const gitBranchIcon = (/** @type {any} */ s) => html`
52
52
  `;
53
53
 
54
54
  /**
55
- * @param {any} tag
56
- * @param {any} size
55
+ * @param {string} tag
56
+ * @param {string} [size]
57
57
  */
58
58
  export function tabIcon(tag, size) {
59
- /** @type {Record<string, any>} */
59
+ /** @type {Record<string, (s: string) => import("lit-html").TemplateResult>} */
60
60
  const m = {
61
- "sp-icon-folder": (/** @type {any} */ s) =>
61
+ "sp-icon-folder": (/** @type {string} */ s) =>
62
62
  html`<sp-icon-folder slot="icon" size=${s}></sp-icon-folder>`,
63
- "sp-icon-layers": (/** @type {any} */ s) =>
63
+ "sp-icon-layers": (/** @type {string} */ s) =>
64
64
  html`<sp-icon-layers slot="icon" size=${s}></sp-icon-layers>`,
65
- "sp-icon-view-grid": (/** @type {any} */ s) =>
65
+ "sp-icon-view-grid": (/** @type {string} */ s) =>
66
66
  html`<sp-icon-view-grid slot="icon" size=${s}></sp-icon-view-grid>`,
67
- "sp-icon-brackets": (/** @type {any} */ s) =>
67
+ "sp-icon-brackets": (/** @type {string} */ s) =>
68
68
  html`<sp-icon-brackets slot="icon" size=${s}></sp-icon-brackets>`,
69
- "sp-icon-data": (/** @type {any} */ s) =>
69
+ "sp-icon-data": (/** @type {string} */ s) =>
70
70
  html`<sp-icon-data slot="icon" size=${s}></sp-icon-data>`,
71
- "sp-icon-properties": (/** @type {any} */ s) =>
71
+ "sp-icon-properties": (/** @type {string} */ s) =>
72
72
  html`<sp-icon-properties slot="icon" size=${s}></sp-icon-properties>`,
73
- "sp-icon-event": (/** @type {any} */ s) =>
73
+ "sp-icon-event": (/** @type {string} */ s) =>
74
74
  html`<sp-icon-event slot="icon" size=${s}></sp-icon-event>`,
75
- "sp-icon-brush": (/** @type {any} */ s) =>
75
+ "sp-icon-brush": (/** @type {string} */ s) =>
76
76
  html`<sp-icon-brush slot="icon" size=${s}></sp-icon-brush>`,
77
- "sp-icon-file-single-web-page": (/** @type {any} */ s) =>
77
+ "sp-icon-file-single-web-page": (/** @type {string} */ s) =>
78
78
  html`<sp-icon-file-single-web-page slot="icon" size=${s}></sp-icon-file-single-web-page>`,
79
- "sp-icon-view-all-tags": (/** @type {any} */ s) =>
79
+ "sp-icon-view-all-tags": (/** @type {string} */ s) =>
80
80
  html`<sp-icon-view-all-tags slot="icon" size=${s}></sp-icon-view-all-tags>`,
81
- "sp-icon-artboard": (/** @type {any} */ s) =>
81
+ "sp-icon-artboard": (/** @type {string} */ s) =>
82
82
  html`<sp-icon-artboard slot="icon" size=${s}></sp-icon-artboard>`,
83
- "sp-icon-box": (/** @type {any} */ s) =>
83
+ "sp-icon-chat": (/** @type {string} */ s) =>
84
+ html`<sp-icon-chat slot="icon" size=${s}></sp-icon-chat>`,
85
+ "sp-icon-box": (/** @type {string} */ s) =>
84
86
  html`<sp-icon-box slot="icon" size=${s}></sp-icon-box>`,
85
87
  "sp-icon-git-branch": gitBranchIcon,
86
88
  };
@@ -92,7 +94,7 @@ export function renderActivityBar() {
92
94
  const tab = activeTab.value;
93
95
  if (!tab) return;
94
96
  const leftTab = view.leftTab;
95
- const gitFileCount = /** @type {any} */ (tab?.session.ui.gitStatus)?.files?.length || 0;
97
+ const gitFileCount = tab?.session.ui.gitStatus?.files?.length || 0;
96
98
  const tabs = [
97
99
  { value: "files", icon: "sp-icon-folder", label: "Files" },
98
100
  { value: "layers", icon: "sp-icon-layers", label: "Layers" },
@@ -108,8 +110,8 @@ export function renderActivityBar() {
108
110
  selected=${view.leftPanelCollapsed ? "" : leftTab}
109
111
  direction="vertical"
110
112
  quiet
111
- @change=${(/** @type {any} */ e) => {
112
- const clicked = e.target.selected;
113
+ @change=${(/** @type {Event} */ e) => {
114
+ const clicked = /** @type {HTMLElement & { selected: string }} */ (e.target).selected;
113
115
  if (clicked === view.leftTab && !view.leftPanelCollapsed) {
114
116
  view.leftPanelCollapsed = true;
115
117
  applyPanelCollapse();
@@ -146,5 +148,5 @@ export function renderActivityBar() {
146
148
  </sp-action-button>
147
149
  </div>
148
150
  `;
149
- litRender(tpl, /** @type {any} */ (activityBar));
151
+ litRender(tpl, activityBar);
150
152
  }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Ai-panel.js — Claude AI Assistant tab for the right panel
3
+ *
4
+ * Uses QuikChat for the chat UI with Claude Agent SDK streaming via SSE.
5
+ */
6
+
7
+ import { html, nothing } from "lit-html";
8
+ import quikchat from "quikchat/md";
9
+ import { getPlatform } from "../platform.js";
10
+ import { rightPanel } from "../store.js";
11
+ import { reloadFileInTab } from "../files/files.js";
12
+
13
+ // ─── State (module-level, persists across tab switches) ─────────────────────
14
+
15
+ /**
16
+ * @type {{
17
+ * role: string;
18
+ * content: string;
19
+ * toolUse?: { tool: string; input?: Record<string, unknown> };
20
+ * }[]}
21
+ */
22
+ let messages = [];
23
+ let streaming = false;
24
+ let sessionId = /** @type {string | null} */ (null);
25
+ let authStatus = /** @type {"authenticated" | "unauthenticated" | "checking" | "unknown"} */ (
26
+ "unknown"
27
+ );
28
+ let authError = "";
29
+ let currentAssistantText = "";
30
+ let eventSource = /** @type {EventSource | null} */ (null);
31
+ let mounted = false;
32
+
33
+ /** @type {any} */
34
+ let chatInstance = null;
35
+ /** @type {Element | null} */
36
+ let chatContainerEl = null;
37
+ let currentStreamMsgId = /** @type {number | null} */ (null);
38
+ let streamStarted = false;
39
+ /** @type {Set<string>} */
40
+ let pendingFileReloads = new Set();
41
+
42
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
43
+
44
+ export function mountAiPanel() {
45
+ if (mounted) return;
46
+ mounted = true;
47
+ checkAuth();
48
+ }
49
+
50
+ function rerenderPanel() {
51
+ const { render } = /** @type {any} */ (/** @type {any} */ (globalThis).__jxRightPanelRender) ||
52
+ {};
53
+ if (render) render();
54
+ requestAnimationFrame(() => mountQuikChat());
55
+ }
56
+
57
+ export function registerRightPanelRender(/** @type {Function} */ fn) {
58
+ /** @type {any} */ (globalThis).__jxRightPanelRender = { render: fn };
59
+ }
60
+
61
+ // ─── QuikChat Mount ────────────────────────────────────────────────────────
62
+
63
+ export function mountQuikChat() {
64
+ const container = rightPanel.querySelector("#ai-quikchat");
65
+ if (!container) return;
66
+ if (chatInstance && chatContainerEl === container) return;
67
+
68
+ chatInstance = new quikchat(
69
+ container,
70
+ (/** @type {any} */ _chat, /** @type {string} */ msg) => {
71
+ handleUserSend(msg);
72
+ },
73
+ {
74
+ theme: "quikchat-theme-dark",
75
+ titleArea: { show: false },
76
+ showTimestamps: false,
77
+ messagesArea: { alternating: false },
78
+ },
79
+ );
80
+ chatContainerEl = container;
81
+
82
+ replayMessages();
83
+
84
+ if (streaming) {
85
+ chatInstance.inputAreaSetEnabled(false);
86
+ }
87
+ }
88
+
89
+ function replayMessages() {
90
+ if (!chatInstance || !messages.length) return;
91
+ for (const msg of messages) {
92
+ if (msg.role === "user") {
93
+ chatInstance.messageAddNew(msg.content, "You", "right", "user");
94
+ } else if (msg.role === "tool" && msg.toolUse) {
95
+ chatInstance.messageAddNew(
96
+ formatToolLabel(msg.toolUse.tool, msg.toolUse.input),
97
+ "",
98
+ "left",
99
+ "tool",
100
+ );
101
+ } else {
102
+ chatInstance.messageAddNew(msg.content, "", "left", "assistant");
103
+ }
104
+ }
105
+ if (streaming && currentAssistantText) {
106
+ currentStreamMsgId = chatInstance.messageAddNew(currentAssistantText, "", "left", "assistant");
107
+ streamStarted = true;
108
+ }
109
+ }
110
+
111
+ // ─── Auth ───────────────────────────────────────────────────────────────────
112
+
113
+ async function checkAuth() {
114
+ authStatus = "checking";
115
+ rerenderPanel();
116
+ try {
117
+ const plat = getPlatform();
118
+ const result = await plat.aiAuthStatus();
119
+ authStatus = result.authenticated ? "authenticated" : "unauthenticated";
120
+ authError = result.error || "";
121
+ } catch (err) {
122
+ authStatus = "unauthenticated";
123
+ authError = String(err);
124
+ }
125
+ rerenderPanel();
126
+ }
127
+
128
+ // ─── Messaging ──────────────────────────────────────────────────────────────
129
+
130
+ /** @param {string} text */
131
+ async function handleUserSend(text) {
132
+ if (!text.trim() || streaming) return;
133
+
134
+ messages.push({ role: "user", content: text });
135
+ streaming = true;
136
+ currentAssistantText = "";
137
+ streamStarted = false;
138
+
139
+ if (chatInstance) {
140
+ currentStreamMsgId = chatInstance.messageAddTypingIndicator("");
141
+ chatInstance.inputAreaSetEnabled(false);
142
+ }
143
+
144
+ rerenderPanel();
145
+
146
+ const plat = getPlatform();
147
+
148
+ try {
149
+ if (!sessionId) {
150
+ const result = await plat.aiCreateSession({ message: text });
151
+ sessionId = result.id;
152
+ await connectStream(/** @type {string} */ (sessionId));
153
+ } else {
154
+ await plat.aiSendMessage(sessionId, text);
155
+ }
156
+ } catch (err) {
157
+ if (chatInstance && currentStreamMsgId != null) {
158
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${err}`);
159
+ }
160
+ messages.push({ role: "assistant", content: `Error: ${err}` });
161
+ streaming = false;
162
+ if (chatInstance) chatInstance.inputAreaSetEnabled(true);
163
+ rerenderPanel();
164
+ }
165
+ }
166
+
167
+ function stop() {
168
+ if (!sessionId) return;
169
+ const plat = getPlatform();
170
+ plat.aiStopSession(sessionId);
171
+ finishStream();
172
+ }
173
+
174
+ function newChat() {
175
+ if (sessionId) {
176
+ const plat = getPlatform();
177
+ plat.aiDeleteSession(sessionId);
178
+ }
179
+ disconnectStream();
180
+ messages = [];
181
+ sessionId = null;
182
+ streaming = false;
183
+ currentAssistantText = "";
184
+ currentStreamMsgId = null;
185
+ streamStarted = false;
186
+ pendingFileReloads.clear();
187
+ if (chatInstance) {
188
+ chatInstance.historyImport([]);
189
+ chatInstance.inputAreaSetEnabled(true);
190
+ }
191
+ rerenderPanel();
192
+ }
193
+
194
+ // ─── SSE Stream ─────────────────────────────────────────────────────────────
195
+
196
+ async function connectStream(/** @type {string} */ id) {
197
+ disconnectStream();
198
+ const plat = getPlatform();
199
+ const url = await Promise.resolve(plat.aiStreamUrl(id));
200
+ eventSource = new EventSource(url);
201
+
202
+ eventSource.addEventListener("stream_event", (e) => {
203
+ try {
204
+ const data = JSON.parse(e.data);
205
+ const evt = data.event;
206
+ if (evt?.type === "content_block_delta" && evt.delta?.type === "text_delta") {
207
+ const token = evt.delta.text;
208
+ currentAssistantText += token;
209
+ if (chatInstance && currentStreamMsgId != null) {
210
+ if (!streamStarted) {
211
+ chatInstance.messageReplaceContent(currentStreamMsgId, currentAssistantText);
212
+ streamStarted = true;
213
+ } else {
214
+ chatInstance.messageAppendContent(currentStreamMsgId, token);
215
+ }
216
+ }
217
+ }
218
+ } catch {}
219
+ });
220
+
221
+ eventSource.addEventListener("assistant", (e) => {
222
+ try {
223
+ handleAssistantMessage(JSON.parse(e.data));
224
+ } catch {}
225
+ });
226
+
227
+ eventSource.addEventListener("result", (e) => {
228
+ try {
229
+ const data = JSON.parse(e.data);
230
+ if (data.result && data.is_error) {
231
+ if (chatInstance && currentStreamMsgId != null) {
232
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.result}`);
233
+ }
234
+ currentAssistantText = `Error: ${data.result}`;
235
+ }
236
+ } catch {}
237
+ finishStream();
238
+ });
239
+
240
+ eventSource.addEventListener("done", () => {
241
+ finishStream();
242
+ });
243
+
244
+ eventSource.addEventListener("error", (e) => {
245
+ try {
246
+ const data = JSON.parse(/** @type {MessageEvent} */ (e).data);
247
+ if (data.error) {
248
+ if (chatInstance && currentStreamMsgId != null) {
249
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.error}`);
250
+ }
251
+ currentAssistantText = `Error: ${data.error}`;
252
+ }
253
+ } catch {}
254
+ finishStream();
255
+ });
256
+
257
+ eventSource.onerror = () => {
258
+ finishStream();
259
+ };
260
+ }
261
+
262
+ function finishStream() {
263
+ if (!streaming) return;
264
+ if (currentAssistantText) {
265
+ messages.push({ role: "assistant", content: currentAssistantText });
266
+ currentAssistantText = "";
267
+ }
268
+ streaming = false;
269
+ currentStreamMsgId = null;
270
+ streamStarted = false;
271
+ if (chatInstance) chatInstance.inputAreaSetEnabled(true);
272
+
273
+ if (pendingFileReloads.size) {
274
+ for (const fp of pendingFileReloads) reloadFileInTab(fp);
275
+ pendingFileReloads.clear();
276
+ }
277
+
278
+ rerenderPanel();
279
+ }
280
+
281
+ function disconnectStream() {
282
+ if (eventSource) {
283
+ eventSource.close();
284
+ eventSource = null;
285
+ }
286
+ }
287
+
288
+ /** @param {any} data */
289
+ function handleAssistantMessage(data) {
290
+ const content = data.message?.content || data.content;
291
+ if (!content) return;
292
+
293
+ let text = "";
294
+ const toolBlocks = [];
295
+
296
+ for (const block of content) {
297
+ if (block.type === "text") {
298
+ text += block.text;
299
+ } else if (block.type === "tool_use") {
300
+ toolBlocks.push({ tool: block.name, input: block.input });
301
+ if ((block.name === "Edit" || block.name === "Write") && block.input) {
302
+ const fp = block.input.file_path || block.input.path;
303
+ if (fp) pendingFileReloads.add(String(fp));
304
+ }
305
+ }
306
+ }
307
+
308
+ if (toolBlocks.length) {
309
+ if (currentAssistantText) {
310
+ messages.push({ role: "assistant", content: currentAssistantText });
311
+ }
312
+ for (const t of toolBlocks) {
313
+ messages.push({ role: "tool", content: "", toolUse: t });
314
+ if (chatInstance) {
315
+ chatInstance.messageAddNew(formatToolLabel(t.tool, t.input), "", "left", "tool");
316
+ }
317
+ }
318
+ }
319
+
320
+ currentAssistantText = text;
321
+ if (chatInstance) {
322
+ currentStreamMsgId = chatInstance.messageAddNew(text || "", "", "left", "assistant");
323
+ streamStarted = !!text;
324
+ }
325
+ }
326
+
327
+ // ─── Tool Label Formatting ─────────────────────────────────────────────────
328
+
329
+ /**
330
+ * @param {string} tool
331
+ * @param {Record<string, unknown>} [input]
332
+ */
333
+ function formatToolLabel(tool, input) {
334
+ switch (tool) {
335
+ case "Edit":
336
+ case "Write":
337
+ return `📝 ${tool}: ${input?.file_path || input?.path || "file"}`;
338
+ case "Read":
339
+ return `📖 Read: ${input?.file_path || input?.path || "file"}`;
340
+ case "Bash":
341
+ return `⚡ Run: ${truncate(String(input?.command || ""), 50)}`;
342
+ case "Glob":
343
+ return `🔍 Glob: ${input?.pattern || ""}`;
344
+ case "Grep":
345
+ return `🔍 Grep: ${truncate(String(input?.pattern || ""), 40)}`;
346
+ default:
347
+ return `🔧 ${tool}`;
348
+ }
349
+ }
350
+
351
+ /** @param {string} s @param {number} max */
352
+ function truncate(s, max) {
353
+ return s.length > max ? s.slice(0, max) + "…" : s;
354
+ }
355
+
356
+ // ─── Template ───────────────────────────────────────────────────────────────
357
+
358
+ /** @returns {import("lit-html").TemplateResult} */
359
+ export function renderAiPanelTemplate() {
360
+ if (authStatus === "checking" || authStatus === "unknown") {
361
+ return html`<div class="ai-tab-body">
362
+ <div class="ai-status-center">Checking authentication...</div>
363
+ </div>`;
364
+ }
365
+
366
+ if (authStatus === "unauthenticated") {
367
+ return html`
368
+ <div class="ai-tab-body">
369
+ <div class="ai-status-center">
370
+ <sp-icon-artboard style="font-size:32px"></sp-icon-artboard>
371
+ <div>Claude authentication required</div>
372
+ <div style="font-size:11px;color:var(--spectrum-global-color-gray-600)">
373
+ Run the following in your terminal:
374
+ </div>
375
+ <code class="ai-code-snippet">npx @anthropic-ai/claude-code login</code>
376
+ ${authError
377
+ ? html`<sp-help-text variant="negative">${authError}</sp-help-text>`
378
+ : nothing}
379
+ <sp-button size="s" variant="primary" @click=${checkAuth}>Retry</sp-button>
380
+ </div>
381
+ </div>
382
+ `;
383
+ }
384
+
385
+ return html`
386
+ <div class="ai-tab-body">
387
+ <div class="ai-toolbar">
388
+ ${streaming
389
+ ? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>`
390
+ : nothing}
391
+ <sp-action-button size="xs" quiet @click=${newChat}>
392
+ <sp-icon-add slot="icon"></sp-icon-add>
393
+ New Chat
394
+ </sp-action-button>
395
+ </div>
396
+ <div id="ai-quikchat"></div>
397
+ </div>
398
+ `;
399
+ }