@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.
- package/dist/studio.css +98 -98
- package/dist/studio.js +12577 -12183
- package/dist/studio.js.map +146 -144
- package/dist/workers/editor.worker.js +79 -79
- package/dist/workers/json.worker.js +109 -109
- package/dist/workers/ts.worker.js +82 -82
- package/package.json +17 -6
- package/src/browse/browse.js +25 -19
- package/src/canvas/canvas-diff.js +24 -16
- package/src/canvas/canvas-helpers.js +24 -19
- package/src/canvas/canvas-live-render.js +94 -39
- package/src/canvas/canvas-render.js +119 -73
- package/src/canvas/canvas-utils.js +38 -15
- package/src/canvas/nested-site-style.js +50 -0
- package/src/editor/component-inline-edit.js +14 -13
- package/src/editor/content-inline-edit.js +22 -10
- package/src/editor/context-menu.js +1 -1
- package/src/editor/convert-to-component.js +12 -10
- package/src/editor/inline-edit.js +66 -28
- package/src/editor/insertion-helper.js +16 -12
- package/src/editor/shortcuts.js +4 -2
- package/src/editor/slash-menu.js +6 -4
- package/src/files/components.js +1 -1
- package/src/files/file-ops.js +37 -16
- package/src/files/files.js +78 -52
- package/src/github/github-auth.js +122 -0
- package/src/github/github-publish.js +115 -0
- package/src/markdown/md-convert.js +172 -107
- package/src/new-project/new-project-modal.js +204 -0
- package/src/panels/activity-bar.js +22 -20
- package/src/panels/ai-panel.js +399 -0
- package/src/panels/block-action-bar.js +5 -5
- package/src/panels/canvas-dnd.js +8 -2
- package/src/panels/data-explorer.js +19 -13
- package/src/panels/dnd.js +144 -92
- package/src/panels/editors.js +55 -35
- package/src/panels/elements-panel.js +33 -19
- package/src/panels/events-panel.js +15 -10
- package/src/panels/git-panel.js +469 -98
- package/src/panels/head-panel.js +121 -81
- package/src/panels/imports-panel.js +88 -64
- package/src/panels/layers-panel.js +64 -43
- package/src/panels/left-panel.js +85 -47
- package/src/panels/overlays.js +1 -0
- package/src/panels/panel-events.js +45 -30
- package/src/panels/preview-render.js +23 -18
- package/src/panels/properties-panel.js +365 -249
- package/src/panels/pseudo-preview.js +2 -2
- package/src/panels/quick-search.js +7 -5
- package/src/panels/right-panel.js +26 -2
- package/src/panels/shared.js +4 -4
- package/src/panels/signals-panel.js +295 -166
- package/src/panels/statusbar.js +4 -4
- package/src/panels/style-inputs.js +67 -35
- package/src/panels/style-panel.js +233 -132
- package/src/panels/style-utils.js +34 -16
- package/src/panels/stylebook-layers-panel.js +12 -10
- package/src/panels/stylebook-panel.js +134 -66
- package/src/panels/toolbar.js +333 -109
- package/src/panels/welcome-screen.js +121 -0
- package/src/platform.js +2 -4
- package/src/platforms/devserver.js +113 -7
- package/src/resize-edges.js +98 -0
- package/src/services/cem-export.js +12 -7
- package/src/services/code-services.js +30 -12
- package/src/settings/content-types-editor.js +7 -7
- package/src/settings/css-vars-editor.js +30 -24
- package/src/settings/defs-editor.js +12 -7
- package/src/settings/general-settings.js +85 -3
- package/src/settings/head-editor.js +30 -24
- package/src/settings/schema-field-ui.js +58 -39
- package/src/site-context.js +41 -31
- package/src/state.js +70 -29
- package/src/store.js +21 -26
- package/src/studio.js +103 -81
- package/src/tabs/tab.js +67 -43
- package/src/tabs/transact.js +23 -13
- package/src/ui/button-group.js +24 -24
- package/src/ui/color-selector.js +28 -23
- package/src/ui/field-row.js +3 -3
- package/src/ui/icons.js +2 -2
- package/src/ui/media-picker.js +7 -7
- package/src/ui/spectrum.js +5 -1
- package/src/ui/unit-selector.js +18 -16
- package/src/ui/value-selector.js +21 -15
- package/src/ui/widgets.js +22 -19
- package/src/utils/canvas-media.js +5 -4
- package/src/utils/edit-display.js +31 -20
- package/src/utils/google-fonts.js +11 -11
- package/src/utils/inherited-style.js +9 -8
- package/src/utils/studio-utils.js +7 -7
- package/src/view.js +58 -1
- 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 {
|
|
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 {
|
|
56
|
-
* @param {
|
|
55
|
+
* @param {string} tag
|
|
56
|
+
* @param {string} [size]
|
|
57
57
|
*/
|
|
58
58
|
export function tabIcon(tag, size) {
|
|
59
|
-
/** @type {Record<string,
|
|
59
|
+
/** @type {Record<string, (s: string) => import("lit-html").TemplateResult>} */
|
|
60
60
|
const m = {
|
|
61
|
-
"sp-icon-folder": (/** @type {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
81
|
+
"sp-icon-artboard": (/** @type {string} */ s) =>
|
|
82
82
|
html`<sp-icon-artboard slot="icon" size=${s}></sp-icon-artboard>`,
|
|
83
|
-
"sp-icon-
|
|
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 =
|
|
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 {
|
|
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,
|
|
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
|
+
}
|