@jxsuite/studio 0.31.1 → 0.33.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.
@@ -4,15 +4,37 @@ import { classMap } from "lit-html/directives/class-map.js";
4
4
  import { live } from "lit-html/directives/live.js";
5
5
  import { ref } from "lit-html/directives/ref.js";
6
6
  import { getPlatform } from "../platform";
7
+ import { projectState } from "../store";
7
8
  import { documentExtensions, formatByExtension, loadFormats } from "../format/format-host";
8
9
  import { openFileInTab } from "../files/files";
9
- import { getRecentFiles, trackRecentFile } from "../recent-projects";
10
+ import { getRecentFiles, getRecentProjects, trackRecentFile } from "../recent-projects";
10
11
  import { getLayerSlot } from "../ui/layers";
11
12
 
13
+ /**
14
+ * A row in the Quick Access modal. With a project open the modal lists/searches that project's
15
+ * files; with no project open it lists recent projects to re-open. The two never mix — the modal
16
+ * only ever shows files from the current project.
17
+ */
18
+ interface FileItem {
19
+ kind: "file";
20
+ path: string;
21
+ name: string;
22
+ }
23
+ interface ProjectItem {
24
+ kind: "project";
25
+ root: string;
26
+ name: string;
27
+ }
28
+ type QuickItem = FileItem | ProjectItem;
29
+
30
+ interface QuickCtx {
31
+ openRecentProject: (root: string) => void | Promise<void>;
32
+ }
33
+
34
+ let _ctx: QuickCtx | null = null;
12
35
  let _open = false;
13
36
  let _query = "";
14
- /** @type {{ path: string; name?: string }[]} */
15
- let _results: { path: string; name?: string }[] = [];
37
+ let _results: FileItem[] = [];
16
38
  let _selectedIndex = 0;
17
39
  let _debounceTimer = 0;
18
40
 
@@ -21,8 +43,9 @@ function getContainer() {
21
43
  return getLayerSlot("popover", "quick-search");
22
44
  }
23
45
 
24
- export function initQuickSearch() {
25
- // No-op container is now provided by the layer system
46
+ /** @param {QuickCtx} [ctx] */
47
+ export function initQuickSearch(ctx?: QuickCtx) {
48
+ _ctx = ctx ?? null;
26
49
  }
27
50
 
28
51
  export function openQuickSearch() {
@@ -38,6 +61,39 @@ export function closeQuickSearch() {
38
61
  renderOverlay();
39
62
  }
40
63
 
64
+ /** The project root scoping the modal, or null when no project is open. */
65
+ function scopeRoot(): string | null {
66
+ return projectState ? (projectState.projectRoot ?? null) : null;
67
+ }
68
+
69
+ /**
70
+ * Resolve the rows to display for the current query/mode. File search is async (populates
71
+ * `_results`); recent files and recent-project filtering are synchronous.
72
+ */
73
+ function currentItems(): { items: QuickItem[]; showingRecent: boolean } {
74
+ const q = _query.trim();
75
+ if (!projectState) {
76
+ // No project open → offer recent projects to re-open, filtered by the query.
77
+ const needle = q.toLowerCase();
78
+ const projects = getRecentProjects().filter(
79
+ (p) =>
80
+ !needle || p.name.toLowerCase().includes(needle) || p.root.toLowerCase().includes(needle),
81
+ );
82
+ return {
83
+ items: projects.map((p) => ({ kind: "project", name: p.name, root: p.root })),
84
+ showingRecent: !q,
85
+ };
86
+ }
87
+ if (!q) {
88
+ const recent = getRecentFiles(scopeRoot() ?? undefined);
89
+ return {
90
+ items: recent.map((f) => ({ kind: "file", name: f.name, path: f.path })),
91
+ showingRecent: true,
92
+ };
93
+ }
94
+ return { items: _results, showingRecent: false };
95
+ }
96
+
41
97
  async function doSearch(query: string) {
42
98
  if (!query.trim()) {
43
99
  _results = [];
@@ -48,7 +104,12 @@ async function doSearch(query: string) {
48
104
  try {
49
105
  const platform = getPlatform();
50
106
  await loadFormats();
51
- _results = await platform.searchFiles(query.trim().toLowerCase(), documentExtensions());
107
+ const hits = await platform.searchFiles(query.trim().toLowerCase(), documentExtensions());
108
+ _results = hits.map((h) => ({
109
+ kind: "file",
110
+ name: h.name ?? h.path.split("/").pop() ?? "",
111
+ path: h.path,
112
+ }));
52
113
  _selectedIndex = 0;
53
114
  renderOverlay();
54
115
  } catch {
@@ -60,12 +121,16 @@ async function doSearch(query: string) {
60
121
  function onInput(e: Event) {
61
122
  _query = (e.target as HTMLInputElement).value;
62
123
  clearTimeout(_debounceTimer);
63
- _debounceTimer = setTimeout(() => doSearch(_query), 150) as unknown as number;
124
+ // Only file search needs the backend (and debouncing); recent-project filtering is synchronous.
125
+ if (projectState) {
126
+ _debounceTimer = setTimeout(() => doSearch(_query), 150) as unknown as number;
127
+ }
128
+ _selectedIndex = 0;
64
129
  renderOverlay();
65
130
  }
66
131
 
67
132
  function onKeydown(e: KeyboardEvent) {
68
- const items = _query.trim() ? _results : getRecentFiles();
133
+ const { items } = currentItems();
69
134
  switch (e.key) {
70
135
  case "ArrowDown": {
71
136
  e.preventDefault();
@@ -97,11 +162,14 @@ function onKeydown(e: KeyboardEvent) {
97
162
  }
98
163
  }
99
164
 
100
- function selectItem(item: { path: string; name?: string }) {
165
+ function selectItem(item: QuickItem) {
101
166
  closeQuickSearch();
102
- const { path } = item;
103
- trackRecentFile({ name: path.split("/").pop() || "", path });
104
- void openFileInTab(path);
167
+ if (item.kind === "project") {
168
+ void _ctx?.openRecentProject(item.root);
169
+ return;
170
+ }
171
+ trackRecentFile({ name: item.name, path: item.path, root: scopeRoot() ?? "" });
172
+ void openFileInTab(item.path);
105
173
  }
106
174
 
107
175
  function fileIcon(name: string) {
@@ -121,6 +189,14 @@ function dirPart(path: string) {
121
189
  return parts.length > 0 ? parts.join("/") : "";
122
190
  }
123
191
 
192
+ /** Collapse a home-prefixed absolute path for compact display. */
193
+ function shortenPath(path: string) {
194
+ if (path.startsWith("/home/")) {
195
+ return `~/${path.split("/").slice(3).join("/")}`;
196
+ }
197
+ return path;
198
+ }
199
+
124
200
  function renderOverlay() {
125
201
  const container = getContainer();
126
202
  if (!_open) {
@@ -128,9 +204,15 @@ function renderOverlay() {
128
204
  return;
129
205
  }
130
206
 
131
- const recentFiles = getRecentFiles();
132
- const showRecent = !_query.trim();
133
- const items = showRecent ? recentFiles : _results;
207
+ const hasProject = projectState != null;
208
+ const { items, showingRecent } = currentItems();
209
+ const hasQuery = _query.trim().length > 0;
210
+
211
+ const placeholder = hasProject ? "Search project files…" : "Open a recent project…";
212
+ const sectionLabel = hasProject ? "Recently opened" : "Recent projects";
213
+ const emptyHint = hasProject
214
+ ? "Type to search project files"
215
+ : "No recent projects — open one to get started";
134
216
 
135
217
  const tpl = html`
136
218
  <div class="quick-search-overlay" @click=${closeQuickSearch}>
@@ -138,7 +220,7 @@ function renderOverlay() {
138
220
  <input
139
221
  class="quick-search-input"
140
222
  type="text"
141
- placeholder="Search project files…"
223
+ placeholder=${placeholder}
142
224
  .value=${live(_query)}
143
225
  @input=${onInput}
144
226
  @keydown=${onKeydown}
@@ -149,17 +231,22 @@ function renderOverlay() {
149
231
  })}
150
232
  />
151
233
  <div class="quick-search-results">
152
- ${items.length === 0 && _query.trim()
234
+ ${items.length === 0 && hasQuery
153
235
  ? html`<div class="quick-search-empty">No results</div>`
154
236
  : nothing}
155
- ${items.length === 0 && !_query.trim() && recentFiles.length === 0
156
- ? html`<div class="quick-search-empty">Type to search project files</div>`
237
+ ${items.length === 0 && !hasQuery
238
+ ? html`<div class="quick-search-empty">${emptyHint}</div>`
157
239
  : nothing}
158
- ${showRecent && recentFiles.length > 0
159
- ? html`<div class="quick-search-section-label">Recently opened</div>`
240
+ ${showingRecent && items.length > 0
241
+ ? html`<div class="quick-search-section-label">${sectionLabel}</div>`
160
242
  : nothing}
161
- ${items.map(
162
- (item, i) => html`
243
+ ${items.map((item, i) => {
244
+ const icon =
245
+ item.kind === "project"
246
+ ? html`<sp-icon-folder-open size="s"></sp-icon-folder-open>`
247
+ : fileIcon(item.name);
248
+ const pathText = item.kind === "project" ? shortenPath(item.root) : dirPart(item.path);
249
+ return html`
163
250
  <div
164
251
  class=${classMap({
165
252
  "quick-search-item": true,
@@ -171,15 +258,13 @@ function renderOverlay() {
171
258
  renderOverlay();
172
259
  }}
173
260
  >
174
- <span class="quick-search-icon"
175
- >${fileIcon(item.name || item.path.split("/").pop() || "")}</span
176
- >
177
- <span class="quick-search-name">${item.name || item.path.split("/").pop()}</span>
178
- <span class="quick-search-path">${dirPart(item.path)}</span>
179
- ${showRecent ? html`<span class="quick-search-badge">recent</span>` : nothing}
261
+ <span class="quick-search-icon">${icon}</span>
262
+ <span class="quick-search-name">${item.name}</span>
263
+ <span class="quick-search-path">${pathText}</span>
264
+ ${showingRecent ? html`<span class="quick-search-badge">recent</span>` : nothing}
180
265
  </div>
181
- `,
182
- )}
266
+ `;
267
+ })}
183
268
  </div>
184
269
  </div>
185
270
  </div>
@@ -10,7 +10,7 @@ import { redo as tabRedo, undo as tabUndo } from "../tabs/transact";
10
10
  import { effect, effectScope } from "../reactivity";
11
11
  import { activeTab } from "../workspace/workspace";
12
12
  import { applyPanelCollapse, view } from "../view";
13
- import { getRecentProjects } from "../recent-projects";
13
+ import { clearRecentProjects, getRecentProjects, removeRecentProject } from "../recent-projects";
14
14
  import { openQuickSearch } from "./quick-search";
15
15
  import { getPlatform } from "../platform";
16
16
  import { refreshGitStatus } from "./git-panel";
@@ -61,8 +61,8 @@ const toolbarIconMap = {
61
61
  */
62
62
  function tbBtnTpl(label: string, onClick: () => void, iconTag?: string) {
63
63
  return html`
64
- <sp-action-button size="s" @click=${onClick}>
65
- ${iconTag ? toolbarIconMap[iconTag] : nothing} ${label}
64
+ <sp-action-button size="s" title=${label} @click=${onClick}>
65
+ ${iconTag ? toolbarIconMap[iconTag] : nothing}<span class="tb-label">${label}</span>
66
66
  </sp-action-button>
67
67
  `;
68
68
  }
@@ -131,10 +131,15 @@ async function handleNewProject() {
131
131
  }
132
132
  }
133
133
 
134
- /** @param {ToolbarCtx} ctx */
135
- function minimalToolbarTemplate(ctx: ToolbarCtx) {
134
+ /**
135
+ * The chevron dropdown beside "Open Project": New Project, the recent-projects list (each with a
136
+ * remove affordance), and a clear-all action. Shared by both the minimal and full toolbars.
137
+ *
138
+ * @param {ToolbarCtx} ctx
139
+ */
140
+ function recentMenuTpl(ctx: ToolbarCtx) {
136
141
  const recentProjects = getRecentProjects();
137
- const recentProjectsTpl = html`
142
+ return html`
138
143
  <overlay-trigger placement="bottom-start" triggered-by="click">
139
144
  <sp-action-button size="s" slot="trigger" title="Recent projects" class="tb-split-trigger">
140
145
  <sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>
@@ -145,6 +150,9 @@ function minimalToolbarTemplate(ctx: ToolbarCtx) {
145
150
  const val = (e.target as unknown as HTMLInputElement).value;
146
151
  if (val === "__new__") {
147
152
  void handleNewProject();
153
+ } else if (val === "__clear__") {
154
+ clearRecentProjects();
155
+ render();
148
156
  } else {
149
157
  void ctx.openRecentProject(val);
150
158
  }
@@ -152,14 +160,41 @@ function minimalToolbarTemplate(ctx: ToolbarCtx) {
152
160
  >
153
161
  <sp-menu-item value="__new__">New Project…</sp-menu-item>
154
162
  ${recentProjects.length > 0
155
- ? html`<sp-menu-divider></sp-menu-divider> ${recentProjects.map(
156
- (p) => html`<sp-menu-item value=${p.root}>${p.name}</sp-menu-item>`,
157
- )}`
163
+ ? html`
164
+ <sp-menu-divider></sp-menu-divider>
165
+ ${recentProjects.map(
166
+ (p) => html`
167
+ <sp-menu-item value=${p.root} title=${p.root}>
168
+ ${p.name}
169
+ <sp-action-button
170
+ slot="end"
171
+ quiet
172
+ size="s"
173
+ title="Remove from recent"
174
+ @click=${(e: Event) => {
175
+ e.stopPropagation();
176
+ removeRecentProject(p.root);
177
+ render();
178
+ }}
179
+ >
180
+ <sp-icon-close slot="icon"></sp-icon-close>
181
+ </sp-action-button>
182
+ </sp-menu-item>
183
+ `,
184
+ )}
185
+ <sp-menu-divider></sp-menu-divider>
186
+ <sp-menu-item value="__clear__">Clear recent projects</sp-menu-item>
187
+ `
158
188
  : nothing}
159
189
  </sp-menu>
160
190
  </sp-popover>
161
191
  </overlay-trigger>
162
192
  `;
193
+ }
194
+
195
+ /** @param {ToolbarCtx} ctx */
196
+ function minimalToolbarTemplate(ctx: ToolbarCtx) {
197
+ const recentProjectsTpl = recentMenuTpl(ctx);
163
198
 
164
199
  const windowControls = (
165
200
  globalThis as unknown as {
@@ -206,25 +241,36 @@ function minimalToolbarTemplate(ctx: ToolbarCtx) {
206
241
 
207
242
  return html`
208
243
  <div class="tb-split-btn">
209
- <sp-action-button size="s" class="tb-split-main" @click=${ctx.openProject}>
210
- ${toolbarIconMap["sp-icon-folder-open"]} Open Project
244
+ <sp-action-button
245
+ size="s"
246
+ class="tb-split-main"
247
+ title="Open Project"
248
+ @click=${ctx.openProject}
249
+ >
250
+ ${toolbarIconMap["sp-icon-folder-open"]}<span class="tb-label">Open Project</span>
211
251
  </sp-action-button>
212
252
  ${recentProjectsTpl}
213
253
  </div>
214
254
  ${tbBtnTpl("Manage", openBrowseModal, "sp-icon-view-list")}
215
- <sp-action-button size="s" disabled>
216
- ${toolbarIconMap["sp-icon-save-floppy"]} Save
255
+ <sp-action-button size="s" title="Save" disabled>
256
+ ${toolbarIconMap["sp-icon-save-floppy"]}<span class="tb-label">Save</span>
217
257
  </sp-action-button>
218
258
  <sp-action-group compact size="s">
219
- <sp-action-button size="s" disabled>
220
- ${toolbarIconMap["sp-icon-undo"]} Undo
259
+ <sp-action-button size="s" title="Undo" disabled>
260
+ ${toolbarIconMap["sp-icon-undo"]}<span class="tb-label">Undo</span>
221
261
  </sp-action-button>
222
- <sp-action-button size="s" disabled>
223
- ${toolbarIconMap["sp-icon-redo"]} Redo
262
+ <sp-action-button size="s" title="Redo" disabled>
263
+ ${toolbarIconMap["sp-icon-redo"]}<span class="tb-label">Redo</span>
224
264
  </sp-action-button>
225
265
  </sp-action-group>
226
266
  <div class="tb-spacer"></div>
227
- <sp-action-button class="tb-search-trigger" size="s" quiet @click=${openQuickSearch}>
267
+ <sp-action-button
268
+ class="tb-search-trigger"
269
+ size="s"
270
+ quiet
271
+ title="Search files (⌘P)"
272
+ @click=${openQuickSearch}
273
+ >
228
274
  <sp-icon-search slot="icon"></sp-icon-search>
229
275
  <span class="tb-search-label">Search files… <kbd>⌘P</kbd></span>
230
276
  </sp-action-button>
@@ -232,8 +278,8 @@ function minimalToolbarTemplate(ctx: ToolbarCtx) {
232
278
  <sp-action-group selects="single" size="s" compact>
233
279
  ${modes.map(
234
280
  (m) => html`
235
- <sp-action-button size="s" disabled ?selected=${m.key === "design"}>
236
- ${toolbarIconMap[m.iconTag]}${m.label}
281
+ <sp-action-button size="s" title=${m.label} disabled ?selected=${m.key === "design"}>
282
+ ${toolbarIconMap[m.iconTag]}<span class="tb-label">${m.label}</span>
237
283
  </sp-action-button>
238
284
  `,
239
285
  )}
@@ -295,6 +341,7 @@ function toolbarTemplate() {
295
341
  (m) => html`
296
342
  <sp-action-button
297
343
  size="s"
344
+ title=${m.label}
298
345
  ?selected=${canvasMode === m.key}
299
346
  ?disabled=${!allowedModes.has(m.key)}
300
347
  @click=${() => {
@@ -323,7 +370,7 @@ function toolbarTemplate() {
323
370
  ctx.safeRenderRightPanel();
324
371
  }}
325
372
  >
326
- ${toolbarIconMap[m.iconTag]}${m.label}
373
+ ${toolbarIconMap[m.iconTag]}<span class="tb-label">${m.label}</span>
327
374
  </sp-action-button>
328
375
  `,
329
376
  )}
@@ -408,69 +455,65 @@ function toolbarTemplate() {
408
455
  `
409
456
  : nothing;
410
457
 
411
- const recentProjects = getRecentProjects();
412
- const recentProjectsTpl = html`
413
- <overlay-trigger placement="bottom-start" triggered-by="click">
414
- <sp-action-button size="s" slot="trigger" title="Recent projects" class="tb-split-trigger">
415
- <sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>
416
- </sp-action-button>
417
- <sp-popover slot="click-content" tip>
418
- <sp-menu
419
- @change=${(e: Event) => {
420
- const val = (e.target as unknown as HTMLInputElement).value;
421
- if (val === "__new__") {
422
- void handleNewProject();
423
- } else {
424
- void ctx.openRecentProject(val);
425
- }
426
- }}
427
- >
428
- <sp-menu-item value="__new__">New Project…</sp-menu-item>
429
- ${recentProjects.length > 0
430
- ? html`<sp-menu-divider></sp-menu-divider> ${recentProjects.map(
431
- (p) => html`<sp-menu-item value=${p.root}>${p.name}</sp-menu-item>`,
432
- )}`
433
- : nothing}
434
- </sp-menu>
435
- </sp-popover>
436
- </overlay-trigger>
437
- `;
458
+ const recentProjectsTpl = recentMenuTpl(ctx);
438
459
 
439
460
  return html`
440
461
  ${isMac ? csdTpl : nothing}
441
462
  <div class="tb-split-btn">
442
- <sp-action-button size="s" class="tb-split-main" @click=${ctx.openProject}>
443
- ${toolbarIconMap["sp-icon-folder-open"]} Open Project
463
+ <sp-action-button
464
+ size="s"
465
+ class="tb-split-main"
466
+ title="Open Project"
467
+ @click=${ctx.openProject}
468
+ >
469
+ ${toolbarIconMap["sp-icon-folder-open"]}<span class="tb-label">Open Project</span>
444
470
  </sp-action-button>
445
471
  ${recentProjectsTpl}
446
472
  </div>
447
473
  ${tbBtnTpl("Manage", openBrowseModal, "sp-icon-view-list")}
448
- <sp-action-button size="s" ?disabled=${!canSave} @click=${ctx.saveFile}>
449
- ${toolbarIconMap["sp-icon-save-floppy"]} Save
474
+ <sp-action-button size="s" title="Save" ?disabled=${!canSave} @click=${ctx.saveFile}>
475
+ ${toolbarIconMap["sp-icon-save-floppy"]}<span class="tb-label">Save</span>
450
476
  </sp-action-button>
451
477
  <sp-action-group compact size="s">
452
- <sp-action-button size="s" ?disabled=${!canUndo} @click=${() => tabUndo(activeTab.value!)}>
453
- ${toolbarIconMap["sp-icon-undo"]} Undo
478
+ <sp-action-button
479
+ size="s"
480
+ title="Undo"
481
+ ?disabled=${!canUndo}
482
+ @click=${() => tabUndo(activeTab.value!)}
483
+ >
484
+ ${toolbarIconMap["sp-icon-undo"]}<span class="tb-label">Undo</span>
454
485
  </sp-action-button>
455
- <sp-action-button size="s" ?disabled=${!canRedo} @click=${() => tabRedo(activeTab.value!)}>
456
- ${toolbarIconMap["sp-icon-redo"]} Redo
486
+ <sp-action-button
487
+ size="s"
488
+ title="Redo"
489
+ ?disabled=${!canRedo}
490
+ @click=${() => tabRedo(activeTab.value!)}
491
+ >
492
+ ${toolbarIconMap["sp-icon-redo"]}<span class="tb-label">Redo</span>
457
493
  </sp-action-button>
458
494
  </sp-action-group>
459
495
  <div class="tb-spacer"></div>
460
- <sp-action-button class="tb-search-trigger" size="s" quiet @click=${openQuickSearch}>
496
+ <sp-action-button
497
+ class="tb-search-trigger"
498
+ size="s"
499
+ quiet
500
+ title="Search files (⌘P)"
501
+ @click=${openQuickSearch}
502
+ >
461
503
  <sp-icon-search slot="icon"></sp-icon-search>
462
504
  <span class="tb-search-label">Search files… <kbd>⌘P</kbd></span>
463
505
  </sp-action-button>
464
506
  ${(activeTab.value?.session.ui.gitStatus?.behind ?? 0) > 0
465
507
  ? html`<sp-action-button
466
508
  size="s"
509
+ title="Sync Project"
467
510
  @click=${async () => {
468
511
  await getPlatform().gitPull();
469
512
  await refreshGitStatus();
470
513
  }}
471
514
  >
472
515
  <sp-icon-download slot="icon"></sp-icon-download>
473
- Sync Project
516
+ <span class="tb-label">Sync Project</span>
474
517
  </sp-action-button>`
475
518
  : nothing}
476
519
  <div class="tb-spacer"></div>
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { html, render as litRender, nothing } from "lit-html";
8
- import { getRecentProjects } from "../recent-projects";
8
+ import { clearRecentProjects, getRecentProjects, removeRecentProject } from "../recent-projects";
9
+ import { renderOnly } from "../store";
9
10
  import { platformSupportsClone } from "./git-panel";
10
11
 
11
12
  interface WelcomeCtx {
@@ -86,17 +87,40 @@ export function renderWelcome(host: HTMLElement) {
86
87
  ${recent.length > 0
87
88
  ? html`
88
89
  <div class="welcome-section">
89
- <h2 class="welcome-section-title">Recent</h2>
90
+ <div class="welcome-section-header">
91
+ <h2 class="welcome-section-title">Recent</h2>
92
+ <button
93
+ class="welcome-clear"
94
+ @click=${() => {
95
+ clearRecentProjects();
96
+ renderOnly("canvas");
97
+ }}
98
+ >
99
+ Clear
100
+ </button>
101
+ </div>
90
102
  ${recent.map(
91
103
  (p) => html`
92
- <button
93
- class="welcome-recent"
94
- @click=${() => ctx.openRecentProject(p.root)}
95
- title=${p.root}
96
- >
97
- <span class="welcome-recent-name">${p.name}</span>
98
- <span class="welcome-recent-path">${shortenPath(p.root)}</span>
99
- </button>
104
+ <div class="welcome-recent-row">
105
+ <button
106
+ class="welcome-recent"
107
+ @click=${() => ctx.openRecentProject(p.root)}
108
+ title=${p.root}
109
+ >
110
+ <span class="welcome-recent-name">${p.name}</span>
111
+ <span class="welcome-recent-path">${shortenPath(p.root)}</span>
112
+ </button>
113
+ <button
114
+ class="welcome-recent-remove"
115
+ title="Remove from recent"
116
+ @click=${() => {
117
+ removeRecentProject(p.root);
118
+ renderOnly("canvas");
119
+ }}
120
+ >
121
+
122
+ </button>
123
+ </div>
100
124
  `,
101
125
  )}
102
126
  </div>
@@ -793,54 +793,10 @@ export function createDevServerPlatform() {
793
793
  }
794
794
  },
795
795
 
796
- // ─── AI Assistant ───────────────────────────────────
796
+ // ─── AI Assistant (Stack B: OpenAI-compatible SSE proxy) ───────────────────
797
797
 
798
- async aiAuthStatus() {
799
- const res = await fetch("/__studio/ai/auth-status");
800
- return await res.json();
801
- },
802
-
803
- /** @param {{ message: string; systemPrompt?: string }} opts */
804
- async aiCreateSession(opts: { message: string; systemPrompt?: string }) {
805
- const res = await fetch("/__studio/ai/session", {
806
- body: JSON.stringify(opts),
807
- headers: { "Content-Type": "application/json" },
808
- method: "POST",
809
- });
810
- if (!res.ok) {
811
- const body = await readJson<ErrorBody>(res);
812
- throw new Error(body.error);
813
- }
814
- return await res.json();
815
- },
816
-
817
- /** @param {string} id @param {string} message */
818
- async aiSendMessage(id: string, message: string) {
819
- const res = await fetch(`/__studio/ai/session/${id}/message`, {
820
- body: JSON.stringify({ message }),
821
- headers: { "Content-Type": "application/json" },
822
- method: "POST",
823
- });
824
- if (!res.ok) {
825
- const body = await readJson<ErrorBody>(res);
826
- throw new Error(body.error);
827
- }
828
- return await res.json();
829
- },
830
-
831
- /** @param {string} id */
832
- aiStreamUrl(id: string) {
833
- return `/__studio/ai/session/${id}/stream`;
834
- },
835
-
836
- /** @param {string} id */
837
- async aiStopSession(id: string) {
838
- await fetch(`/__studio/ai/session/${id}/stop`, { method: "POST" });
839
- },
840
-
841
- /** @param {string} id */
842
- async aiDeleteSession(id: string) {
843
- await fetch(`/__studio/ai/session/${id}`, { method: "DELETE" });
798
+ aiChatUrl() {
799
+ return "/__studio/ai/chat";
844
800
  },
845
801
  };
846
802
  }