@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.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Tool-executor.js — Agentic loop driver for the document AI assistant
3
+ *
4
+ * Streams a chat round, executes any tool calls the model makes (via a ToolRegistry backed by
5
+ * `transactDoc()`), feeds the results back as `tool` messages, and re-streams — up to a capped
6
+ * number of rounds (spec §10.2, ADR docs/ai-assistant-decision.md §6a).
7
+ *
8
+ * @license MIT
9
+ */
10
+
11
+ import type { createChatState } from "@jxsuite/ai/chat-state";
12
+ import type { StreamingClient } from "@jxsuite/ai/streaming-client";
13
+ import type { ToolRegistry } from "@jxsuite/ai/tools";
14
+
15
+ import type { Tab } from "../tabs/tab";
16
+ import { beginBatch, endBatch } from "../tabs/transact";
17
+
18
+ const MAX_ROUNDS = 5;
19
+
20
+ interface RunAgentLoopOptions {
21
+ chatState: ReturnType<typeof createChatState>;
22
+ streamingClient: StreamingClient;
23
+ toolRegistry: ToolRegistry;
24
+ systemPrompt: string;
25
+ signal?: AbortSignal;
26
+ getTab?: () => Tab | null;
27
+ }
28
+
29
+ /**
30
+ * Run one user turn through the agent loop: stream the model's response, execute any tool calls,
31
+ * and repeat until the model stops calling tools or the round cap is hit.
32
+ */
33
+ export async function runAgentLoop({
34
+ chatState,
35
+ streamingClient,
36
+ toolRegistry,
37
+ systemPrompt,
38
+ signal,
39
+ getTab,
40
+ }: RunAgentLoopOptions): Promise<void> {
41
+ const allErrors: string[] = [];
42
+ const appliedSummaries: string[] = [];
43
+
44
+ // Batch all tool-call mutations into a single undo step
45
+ if (getTab) {
46
+ beginBatch(getTab());
47
+ }
48
+
49
+ try {
50
+ for (let round = 1; round <= MAX_ROUNDS; round++) {
51
+ const messages = chatState.toMessagesArray();
52
+ const tools = toolRegistry.listForLLM();
53
+
54
+ const toolCalls = new Map<string, { name: string; arguments: string }>();
55
+ let stopReason = "stop";
56
+ let streamError = null;
57
+
58
+ for await (const event of streamingClient.streamChat(
59
+ messages,
60
+ tools,
61
+ systemPrompt,
62
+ signal as AbortSignal,
63
+ )) {
64
+ switch (event.type) {
65
+ case "delta": {
66
+ chatState.appendDelta(event.content);
67
+ break;
68
+ }
69
+ case "tool_call_start": {
70
+ chatState.appendToolCallStart(event.id, event.name);
71
+ toolCalls.set(event.id, { name: event.name, arguments: "" });
72
+ break;
73
+ }
74
+ case "tool_call_delta": {
75
+ chatState.appendToolCallDelta(event.id, event.args);
76
+ const tc = toolCalls.get(event.id);
77
+ if (tc) {
78
+ tc.arguments += event.args;
79
+ }
80
+ break;
81
+ }
82
+ case "tool_call_end": {
83
+ chatState.appendToolCallEnd(event.id);
84
+ break;
85
+ }
86
+ case "done": {
87
+ ({ stopReason } = event);
88
+ break;
89
+ }
90
+ case "error": {
91
+ streamError = event.message;
92
+ break;
93
+ }
94
+ default: {
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ chatState.finishStream(stopReason);
101
+
102
+ if (streamError) {
103
+ chatState.setError(streamError);
104
+ return;
105
+ }
106
+
107
+ if (stopReason !== "tool_calls" || toolCalls.size === 0) {
108
+ return;
109
+ }
110
+
111
+ for (const [id, call] of toolCalls) {
112
+ let result;
113
+ try {
114
+ const args = call.arguments ? (JSON.parse(call.arguments) as object) : {};
115
+ result = await toolRegistry.execute(call.name, args);
116
+ } catch (error) {
117
+ result = {
118
+ success: false,
119
+ error: `Failed to parse arguments: ${(error as Error).message}`,
120
+ };
121
+ }
122
+ if (!result.success && result.error) {
123
+ allErrors.push(result.error);
124
+ }
125
+ if (result.success && result.summary) {
126
+ appliedSummaries.push(result.summary);
127
+ }
128
+ chatState.appendToolResult(id, result);
129
+ chatState.pushToolResultMessage(id, JSON.stringify(result));
130
+ }
131
+
132
+ if (round < MAX_ROUNDS) {
133
+ chatState.beginAssistantTurn();
134
+ }
135
+ }
136
+
137
+ /*
138
+ * Surface the actual errors so the user knows what went wrong, not just a generic
139
+ * "I couldn't do it" message.
140
+ */
141
+ const uniqueErrors = [...new Set(allErrors)];
142
+ const applied =
143
+ appliedSummaries.length > 0
144
+ ? `\n\nChanges applied so far:\n${appliedSummaries.map((s) => `- ${s}`).join("\n")}`
145
+ : "";
146
+ const errors =
147
+ uniqueErrors.length > 0
148
+ ? `\n\nErrors encountered:\n${uniqueErrors.map((e) => `- ${e}`).join("\n")}`
149
+ : "";
150
+ chatState.setError(
151
+ `I ran out of tool-call rounds (${MAX_ROUNDS}) before finishing.${applied}${errors}\n\nYou can continue by sending another message, or try a more specific request.`,
152
+ );
153
+ } finally {
154
+ endBatch();
155
+ }
156
+ }
package/src/state.ts CHANGED
@@ -73,6 +73,35 @@ export function getNodeAtPath(doc: JxMutableNode, path: JxPath) {
73
73
  return node;
74
74
  }
75
75
 
76
+ /**
77
+ * Shallow-clone every node along `path` from root to target, leaving non-path subtrees as shared
78
+ * references. Returns the new root and the mutable target node at the end of the path.
79
+ *
80
+ * Used by mutation functions for structural-sharing history: the old root becomes an immutable
81
+ * snapshot (shared subtrees are never mutated), and the new root is the live document.
82
+ */
83
+ export function cloneAlongPath(
84
+ doc: JxMutableNode,
85
+ path: JxPath,
86
+ ): { root: JxMutableNode; target: JxMutableNode } {
87
+ const root = Array.isArray(doc) ? [...doc] : { ...doc };
88
+ let node: Record<string | number, unknown> = root as Record<string | number, unknown>;
89
+
90
+ for (const key of path) {
91
+ const child = node[key];
92
+ if (child == null) {
93
+ return { root: root as JxMutableNode, target: node as JxMutableNode };
94
+ }
95
+ const cloned = Array.isArray(child)
96
+ ? [...(child as unknown[])]
97
+ : { ...(child as Record<string, unknown>) };
98
+ node[key] = cloned;
99
+ node = cloned as Record<string | number, unknown>;
100
+ }
101
+
102
+ return { root: root as JxMutableNode, target: node as JxMutableNode };
103
+ }
104
+
76
105
  /**
77
106
  * The node's children when they are a static array (the edit-mode invariant); an empty array for
78
107
  * mapped-array or absent children.
package/src/studio.ts CHANGED
@@ -106,7 +106,7 @@ import { initCssData } from "./panels/style-utils";
106
106
  import { updateForcedPseudoPreview } from "./panels/pseudo-preview";
107
107
  import { initPanelEvents } from "./panels/panel-events";
108
108
  import { initQuickSearch } from "./panels/quick-search";
109
- import { addRecentProject } from "./recent-projects";
109
+ import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
110
110
  import { initWelcome } from "./panels/welcome-screen";
111
111
  import { openNewProjectModal } from "./new-project/new-project-modal";
112
112
  import type { DocumentStackEntry, GitDiffState } from "./types";
@@ -338,7 +338,7 @@ toolbarPanel.mount(toolbarEl, {
338
338
  });
339
339
 
340
340
  initLayers();
341
- initQuickSearch();
341
+ initQuickSearch({ openRecentProject: (root: string) => openRecentProject(root) });
342
342
 
343
343
  tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
344
344
 
@@ -662,6 +662,14 @@ if (_projectParam) {
662
662
  ensureFsSync();
663
663
  }
664
664
 
665
+ // Hydrate the recent-projects list from the backend store (desktop/chromium), then refresh the
666
+ // Toolbar dropdown + welcome screen, both of which read it synchronously.
667
+ // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
668
+ void hydrateRecentProjects().then(() => {
669
+ toolbarPanel.render();
670
+ render();
671
+ });
672
+
665
673
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
666
674
 
667
675
  function renderLeftPanel() {
@@ -748,6 +756,11 @@ async function openRecentProject(root: string) {
748
756
  ensureFsSync();
749
757
  void maybePromptJxsuiteUpdate(root);
750
758
  } catch (error) {
759
+ // The project likely moved or was deleted — drop the stale entry so it stops cluttering the
760
+ // List, and refresh the dropdown + welcome screen.
761
+ removeRecentProject(root);
762
+ toolbarPanel.render();
763
+ render();
751
764
  statusMessage(`Error: ${errorMessage(error)}`);
752
765
  }
753
766
  }
@@ -86,6 +86,12 @@ export type JxNodeValue =
86
86
  * "Array"`) cannot be index-mutated — fail loudly instead of corrupting.
87
87
  */
88
88
  function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
89
+ // Defense-in-depth: a path that resolves to a children array (rather than a node) would
90
+ // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
91
+ // Nothing renders. Callers must pass a node; fail loudly if they don't.
92
+ if (Array.isArray(node)) {
93
+ throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
94
+ }
89
95
  if (!node.children) {
90
96
  node.children = [];
91
97
  }
@@ -154,7 +160,7 @@ export function transactDoc(
154
160
  }
155
161
  }
156
162
 
157
- if (!skipHistory) {
163
+ if (!skipHistory && !_batchTab) {
158
164
  pushHistoryEntry(tab, raw, record, selectionBefore);
159
165
  }
160
166
 
@@ -351,6 +357,36 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
351
357
  }
352
358
  }
353
359
 
360
+ // ─── Batch (group multiple mutations into one undo step) ────────────────────
361
+
362
+ let _batchTab: Tab | null = null;
363
+
364
+ export function beginBatch(tab: Tab | null) {
365
+ _batchTab = tab;
366
+ }
367
+
368
+ export function endBatch() {
369
+ if (_batchTab) {
370
+ const raw = toRaw(_batchTab.doc.document);
371
+ const snapshot = {
372
+ document: jsonClone(raw),
373
+ selection: _batchTab.session.selection ? [..._batchTab.session.selection] : null,
374
+ };
375
+ const truncated = _batchTab.history.snapshots.slice(0, _batchTab.history.index + 1);
376
+ truncated.push(snapshot);
377
+ if (truncated.length > HISTORY_LIMIT) {
378
+ truncated.shift();
379
+ }
380
+ _batchTab.history.snapshots = truncated;
381
+ _batchTab.history.index = truncated.length - 1;
382
+ }
383
+ _batchTab = null;
384
+ }
385
+
386
+ export function isBatching(): boolean {
387
+ return _batchTab !== null;
388
+ }
389
+
354
390
  // ─── Undo / Redo ─────────────────────────────────────────────────────────────
355
391
 
356
392
  /** Restore a materialized snapshot state (full-render path). */
package/src/types.ts CHANGED
@@ -214,12 +214,8 @@ export interface StudioPlatform {
214
214
  adapter?: string;
215
215
  directory: string;
216
216
  }) => Promise<{ root: string; config: ProjectConfig }>;
217
- aiAuthStatus: () => Promise<{ authenticated: boolean; error?: string }>;
218
- aiCreateSession: (opts: { message: string; systemPrompt?: string }) => Promise<{ id: string }>;
219
- aiSendMessage: (id: string, message: string) => Promise<void>;
220
- aiStreamUrl: (id: string) => string | Promise<string>;
221
- aiStopSession: (id: string) => Promise<void>;
222
- aiDeleteSession: (id: string) => Promise<void>;
217
+ /** Stack B AI assistant: URL of the OpenAI-compatible SSE chat proxy (`/__studio/ai/chat`). */
218
+ aiChatUrl: () => string | Promise<string>;
223
219
  // ─── Multi-window (desktop only; undefined on dev-server) ───────────────────
224
220
  /** Open a project in a new window, focusing an existing window if it is already open. */
225
221
  openProjectInNewWindow?: (root: string) => Promise<void>;
@@ -232,6 +228,22 @@ export interface StudioPlatform {
232
228
  setWindowProject?: (root: string) => Promise<{ deduped: boolean; config: ProjectConfig | null }>;
233
229
  /** The project root this window's backend is currently bound to. */
234
230
  getProjectRoot?: () => Promise<{ root: string | null }>;
231
+ // ─── Recent projects (backend-persisted; undefined on dev-server) ───────────
232
+ /**
233
+ * Read the user-level recent-projects list from a backend store shared across all
234
+ * projects/windows. Platforms without a native backend (dev server) omit it and the studio falls
235
+ * back to localStorage.
236
+ */
237
+ getRecentProjects?: () => Promise<RecentProjectEntry[]>;
238
+ /** Persist the full recent-projects list to the backend store. */
239
+ saveRecentProjects?: (projects: RecentProjectEntry[]) => Promise<void>;
240
+ }
241
+
242
+ /** A recently-opened project, keyed by its re-openable `root` (platform-specific). */
243
+ export interface RecentProjectEntry {
244
+ name: string;
245
+ root: string;
246
+ timestamp: number;
235
247
  }
236
248
 
237
249
  // ─── Studio Types ───────────────────────────────────────────────────────────
@@ -10,7 +10,7 @@ import { html, render as litRender, nothing } from "lit-html";
10
10
  import { live } from "lit-html/directives/live.js";
11
11
  import { ref } from "lit-html/directives/ref.js";
12
12
  import { getPlatform } from "../platform";
13
- import { debouncedStyleCommit } from "../store";
13
+ import { debouncedStyleCommit, renderOnly } from "../store";
14
14
  import { getLayerSlot } from "./layers";
15
15
 
16
16
  // ─── Media file cache ────────────────────────────────────────────────────────
@@ -88,6 +88,10 @@ async function loadMediaCache() {
88
88
  m.path = m.path.replace(/^\/public\//, "/");
89
89
  }
90
90
  mediaCacheLoaded = true;
91
+ // Re-render the host panels so the Browse button (gated on mediaCache.length) appears once the
92
+ // Async listing resolves — including when an image value is already set, so the current image can
93
+ // Be replaced. Mirrors loadLayoutEntries()'s renderOnly() in head-panel.
94
+ renderOnly("leftPanel", "rightPanel");
91
95
  }
92
96
 
93
97
  /** Force media cache reload (e.g. after upload). */
@@ -168,6 +168,9 @@ export function findContentTypeSchema(
168
168
  if (!documentPath || !projectConfig?.contentTypes) {
169
169
  return null;
170
170
  }
171
+ // Content-type `source` prefixes are always forward-slash. The desktop platform can hand us
172
+ // OS-native backslash paths on Windows, so normalize before prefix matching.
173
+ const docPath = documentPath.replaceAll("\\", "/");
171
174
  for (const [name, def] of Object.entries(
172
175
  projectConfig.contentTypes as Record<string, ContentTypeDef>,
173
176
  )) {
@@ -177,7 +180,7 @@ export function findContentTypeSchema(
177
180
  const src = def.source.replace(/^\.\//, "").replace(/\/$/, "");
178
181
  const hasExt = src.includes(".") && !src.endsWith("/");
179
182
  if (hasExt) {
180
- if (documentPath === src || documentPath.endsWith(`/${src}`)) {
183
+ if (docPath === src || docPath.endsWith(`/${src}`)) {
181
184
  return { name, schema: def.schema };
182
185
  }
183
186
  } else {
@@ -187,7 +190,7 @@ export function findContentTypeSchema(
187
190
  : (formatByName(def.format)?.extensions[0] ??
188
191
  defaultContentFormat()?.extensions[0] ??
189
192
  ".json");
190
- if (documentPath.startsWith(`${src}/`) && documentPath.endsWith(ext)) {
193
+ if (docPath.startsWith(`${src}/`) && docPath.endsWith(ext)) {
191
194
  return { name, schema: def.schema };
192
195
  }
193
196
  }