@tylertech/forge-ai 0.15.3-dev.0 → 0.15.3-dev.1

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 (54) hide show
  1. package/custom-elements.json +7733 -6803
  2. package/dist/ai-assistant-response/ai-assistant-response.d.ts +2 -2
  3. package/dist/ai-assistant-response/ai-assistant-response.mjs +4 -3
  4. package/dist/ai-canvas/ai-canvas.d.ts +43 -0
  5. package/dist/ai-canvas/ai-canvas.mjs +95 -0
  6. package/dist/ai-canvas/ai-canvas.scss.mjs +4 -0
  7. package/dist/ai-canvas/index.d.ts +1 -0
  8. package/dist/ai-canvas/index.mjs +5 -0
  9. package/dist/ai-chatbot/ag-ui-adapter.d.ts +2 -2
  10. package/dist/ai-chatbot/agent-adapter.d.ts +5 -5
  11. package/dist/ai-chatbot/agent-runner.d.ts +2 -2
  12. package/dist/ai-chatbot/ai-chatbot-base.d.ts +3 -2
  13. package/dist/ai-chatbot/ai-chatbot-base.mjs +11 -0
  14. package/dist/ai-chatbot/ai-chatbot-tool-call.d.ts +2 -2
  15. package/dist/ai-chatbot/ai-chatbot-tool-call.mjs +88 -10
  16. package/dist/ai-chatbot/ai-chatbot.d.ts +24 -1
  17. package/dist/ai-chatbot/ai-chatbot.mjs +102 -2
  18. package/dist/ai-chatbot/ai-chatbot.scss.mjs +1 -1
  19. package/dist/ai-chatbot/create-tool-renderer.d.ts +5 -5
  20. package/dist/ai-chatbot/index.d.ts +2 -1
  21. package/dist/ai-chatbot/index.mjs +3 -0
  22. package/dist/ai-chatbot/message-state-controller.d.ts +2 -2
  23. package/dist/ai-chatbot/message-state-controller.mjs +11 -11
  24. package/dist/ai-chatbot/tool-canvas.d.ts +60 -0
  25. package/dist/ai-chatbot/tool-canvas.mjs +128 -0
  26. package/dist/ai-chatbot/types.d.ts +128 -16
  27. package/dist/ai-chatbot/utils.d.ts +8 -3
  28. package/dist/ai-chatbot/utils.mjs +4 -0
  29. package/dist/ai-chatbot-launcher/ai-chatbot-launcher.d.ts +40 -2
  30. package/dist/ai-chatbot-launcher/ai-chatbot-launcher.mjs +224 -5
  31. package/dist/ai-chatbot-launcher/ai-chatbot-launcher.scss.mjs +1 -1
  32. package/dist/ai-embedded-chat/ai-embedded-chat.mjs +1 -0
  33. package/dist/ai-message-thread/ai-message-thread.d.ts +2 -2
  34. package/dist/ai-message-thread/ai-message-thread.mjs +3 -2
  35. package/dist/ai-tool-call-indicator/ai-tool-call-indicator.d.ts +2 -2
  36. package/dist/core/canvas-controller.d.ts +81 -0
  37. package/dist/core/canvas-controller.mjs +221 -0
  38. package/dist/core/chatbot-core-controller.d.ts +2 -2
  39. package/dist/core/chatbot-core-controller.mjs +44 -22
  40. package/dist/core/index.d.ts +2 -1
  41. package/dist/core/index.mjs +2 -0
  42. package/dist/core/resize-controller.d.ts +5 -0
  43. package/dist/core/resize-controller.mjs +7 -5
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.mjs +6 -0
  46. package/dist/tools/ai-data-table/ai-data-table-definition.mjs +1 -0
  47. package/dist/tools/ai-mcp-app/ai-mcp-app.d.ts +2 -1
  48. package/dist/tools/ai-mcp-app/ai-mcp-app.mjs +16 -1
  49. package/dist/tools/ai-mcp-app/ai-mcp-app.scss.mjs +1 -1
  50. package/dist/tools/ai-mcp-app/mcp-app-bridge-controller.d.ts +3 -4
  51. package/dist/tools/ai-mcp-app/mcp-app-bridge-controller.mjs +12 -21
  52. package/dist/tools/ai-mcp-app/mcp-app-bridge-types.d.ts +2 -1
  53. package/dist/tools/ai-steps/ai-steps-definition.mjs +1 -0
  54. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
4
4
  import { when } from "lit/directives/when.js";
5
5
  import { keyed } from "lit/directives/keyed.js";
6
6
  import { MarkdownStreamController } from "../ai-chatbot/markdown-stream-controller.mjs";
7
+ import { rendersAsMcpAppWidget } from "../ai-chatbot/utils.mjs";
7
8
  import "../ai-assistant-response/ai-assistant-response.mjs";
8
9
  import "../ai-client-message/ai-client-message.mjs";
9
10
  import "../ai-empty-state/ai-empty-state.mjs";
@@ -196,7 +197,7 @@ handleUserEdit_fn = function(messageId, content) {
196
197
  });
197
198
  };
198
199
  renderToolCall_fn = function(toolCall) {
199
- if (toolCall.uiResource) {
200
+ if (rendersAsMcpAppWidget(toolCall)) {
200
201
  return keyed(toolCall.id, html`<forge-ai-mcp-app .toolCall=${toolCall}></forge-ai-mcp-app>`);
201
202
  }
202
203
  const toolDefinition = this.tools?.get(toolCall.name);
@@ -257,7 +258,7 @@ messages_get = function() {
257
258
  return true;
258
259
  }
259
260
  if (item.type === "toolCall") {
260
- if (item.data.uiResource) {
261
+ if (rendersAsMcpAppWidget(item.data)) {
261
262
  return true;
262
263
  }
263
264
  const toolDef = this.tools?.get(item.data.name);
@@ -1,5 +1,5 @@
1
1
  import { LitElement, nothing, PropertyValues, TemplateResult, CSSResult } from 'lit';
2
- import { ToolCall, ToolDefinition } from '../ai-chatbot/types.js';
2
+ import { ToolCall, AnyToolDefinition } from '../ai-chatbot/types.js';
3
3
  declare global {
4
4
  interface HTMLElementTagNameMap {
5
5
  'forge-ai-tool-call-indicator': AiToolCallIndicatorComponent;
@@ -20,7 +20,7 @@ export declare class AiToolCallIndicatorComponent extends LitElement {
20
20
  #private;
21
21
  static styles: CSSResult;
22
22
  toolCalls: ToolCall[];
23
- tools?: Map<string, ToolDefinition>;
23
+ tools?: Map<string, AnyToolDefinition>;
24
24
  debugMode: boolean;
25
25
  private _expanded;
26
26
  private _expandedRows;
@@ -0,0 +1,81 @@
1
+ import { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ import { AnyToolDefinition, ChatMessage, ToolCall } from '../ai-chatbot/types.js';
3
+ import { ForgeAiCanvasCloseRequestEventData, ForgeAiCanvasOpenRequestEventData } from '../ai-chatbot/tool-canvas.js';
4
+ export interface OpenCanvasOptions {
5
+ /** Identifies the artifact; tool-driven opens use the tool call id. */
6
+ id?: string;
7
+ /** Overrides `canvasTitle` while this content is shown. */
8
+ title?: string;
9
+ /** Content mounted in the canvas body. Omit to show the `canvas` slot instead. */
10
+ content?: Node;
11
+ }
12
+ export interface CanvasOpenEventData {
13
+ id?: string;
14
+ title?: string;
15
+ /** Present when the canvas was opened for a tool call. */
16
+ toolCall?: ToolCall;
17
+ }
18
+ /** `auto`: opened by `canvas.open: 'auto'` rather than a user or API request. */
19
+ export type CanvasOpenReason = 'auto' | 'request';
20
+ export interface CanvasContent {
21
+ id?: string;
22
+ title?: string;
23
+ node?: Node;
24
+ toolCall?: ToolCall;
25
+ /** Set for tool-driven content so it can follow the tool call as it updates. */
26
+ toolDefinition?: AnyToolDefinition;
27
+ /** Title supplied with the open request; otherwise the title is re-derived from the tool call. */
28
+ titleOverride?: string;
29
+ }
30
+ export interface CanvasControllerConfig {
31
+ /** Cancelable event dispatched on the host before the canvas opens or swaps content. */
32
+ openEvent: string;
33
+ /** Event dispatched on the host when the canvas closes; cancelable except when the host forces it. */
34
+ closeEvent: string;
35
+ dispatchHostEvent: (config: {
36
+ type: string;
37
+ detail?: unknown;
38
+ cancelable?: boolean;
39
+ }) => CustomEvent;
40
+ getToolCalls: () => ToolCall[];
41
+ getTools: () => Map<string, AnyToolDefinition>;
42
+ /** Whether the host can show the canvas right now. Defaults to always. */
43
+ canOpen?: () => boolean;
44
+ /** Called once the canvas has gone from closed to open, not when its content is swapped. */
45
+ onOpen?: (config: {
46
+ reason: CanvasOpenReason;
47
+ }) => void;
48
+ /** Called after the canvas is hidden for any reason. */
49
+ onClose?: () => void;
50
+ /** Called when the requested tool call is already showing, so the host can draw attention to it. */
51
+ onRefocus?: () => void;
52
+ /** Resolves once the host's collapse presentation has finished; hidden content is released after it. */
53
+ collapsed?: () => Promise<void>;
54
+ }
55
+ /**
56
+ * Owns the artifact canvas state shared by every chatbot form factor: what is shown, the cancelable
57
+ * open/close events, tool call tracking, auto-open, and the `canvas` controls injected into renderers.
58
+ * The host decides how that state is presented (a side panel, a modal, ...).
59
+ */
60
+ export declare class CanvasController implements ReactiveController {
61
+ #private;
62
+ constructor(host: ReactiveControllerHost & HTMLElement, config: CanvasControllerConfig);
63
+ hostConnected(): void;
64
+ get isOpen(): boolean;
65
+ get content(): CanvasContent | null;
66
+ open(options?: OpenCanvasOptions): boolean;
67
+ /** Closes the canvas. Returns false if it was not open or the close event was prevented. */
68
+ close(): boolean;
69
+ /** Closes without asking, for when the host can no longer show the canvas (e.g. it left its conversation view). */
70
+ forceClose(): void;
71
+ /** Forgets the canvas entirely, for when the thread it belonged to is gone. */
72
+ reset(): void;
73
+ /** Tool calls restored from history are marked as seen so loading a thread never auto-opens the canvas. */
74
+ markSeen(messages: ChatMessage[]): void;
75
+ readonly handleOpenRequest: (evt: CustomEvent<ForgeAiCanvasOpenRequestEventData>) => void;
76
+ readonly handleCloseRequest: (evt: CustomEvent<ForgeAiCanvasCloseRequestEventData>) => void;
77
+ /** The tool call object is replaced on every args or status update, so open content follows it by id. */
78
+ syncToolCall(): void;
79
+ /** Opens tools configured with `canvas.open: 'auto'` once their renderer shows. Call after render, not during it. */
80
+ autoOpenPending(): void;
81
+ }
@@ -0,0 +1,221 @@
1
+ import { ContextProvider } from "@lit/context";
2
+ import { toolCanvasHostContext, resolveToolCanvasTitle, createToolCanvas } from "../ai-chatbot/tool-canvas.mjs";
3
+ import { shouldShowToolRenderer } from "../ai-chatbot/utils.mjs";
4
+ class CanvasController {
5
+ constructor(host, config) {
6
+ this.#autoOpenedToolCallIds = /* @__PURE__ */ new Set();
7
+ this.#isOpen = false;
8
+ this.#content = null;
9
+ this.handleOpenRequest = (evt) => {
10
+ evt.stopPropagation();
11
+ const toolCall = this.#findToolCall(evt.detail.toolCallId);
12
+ const toolDefinition = this.#config.getTools().get(evt.detail.toolName);
13
+ if (!toolCall || !toolDefinition) {
14
+ return;
15
+ }
16
+ evt.detail.handled = this.#openToolCall({ toolCall, toolDefinition, title: evt.detail.title });
17
+ };
18
+ this.handleCloseRequest = (evt) => {
19
+ evt.stopPropagation();
20
+ evt.detail.handled = this.#closeToolCall(evt.detail.toolCallId);
21
+ };
22
+ this.#host = host;
23
+ this.#config = config;
24
+ host.addController(this);
25
+ this.#provider = new ContextProvider(host, {
26
+ context: toolCanvasHostContext,
27
+ initialValue: { presentation: "inline-expandable" }
28
+ });
29
+ }
30
+ #host;
31
+ #config;
32
+ #provider;
33
+ #autoOpenedToolCallIds;
34
+ #isOpen;
35
+ #content;
36
+ hostConnected() {
37
+ }
38
+ get isOpen() {
39
+ return this.#isOpen;
40
+ }
41
+ get content() {
42
+ return this.#content;
43
+ }
44
+ open(options = {}) {
45
+ return this.#commitOpen({ id: options.id, title: options.title, node: options.content });
46
+ }
47
+ /** Closes the canvas. Returns false if it was not open or the close event was prevented. */
48
+ close() {
49
+ if (!this.#isOpen) {
50
+ return false;
51
+ }
52
+ const event = this.#config.dispatchHostEvent({ type: this.#config.closeEvent, cancelable: true });
53
+ if (event.defaultPrevented) {
54
+ return false;
55
+ }
56
+ this.#hide();
57
+ return true;
58
+ }
59
+ /** Closes without asking, for when the host can no longer show the canvas (e.g. it left its conversation view). */
60
+ forceClose() {
61
+ if (!this.#isOpen) {
62
+ return;
63
+ }
64
+ this.#hide();
65
+ this.#config.dispatchHostEvent({ type: this.#config.closeEvent });
66
+ }
67
+ /** Forgets the canvas entirely, for when the thread it belonged to is gone. */
68
+ reset() {
69
+ this.forceClose();
70
+ this.#content = null;
71
+ this.#autoOpenedToolCallIds.clear();
72
+ this.#host.requestUpdate();
73
+ }
74
+ /** Tool calls restored from history are marked as seen so loading a thread never auto-opens the canvas. */
75
+ markSeen(messages) {
76
+ for (const message of messages) {
77
+ for (const toolCall of message.toolCalls ?? []) {
78
+ this.#autoOpenedToolCallIds.add(toolCall.id);
79
+ }
80
+ }
81
+ }
82
+ /** The tool call object is replaced on every args or status update, so open content follows it by id. */
83
+ syncToolCall() {
84
+ const content = this.#content;
85
+ if (!this.#isOpen || !content?.toolCall || !content.toolDefinition) {
86
+ return;
87
+ }
88
+ const latest = this.#findToolCall(content.toolCall.id);
89
+ if (!latest || latest === content.toolCall) {
90
+ return;
91
+ }
92
+ const { toolDefinition } = content;
93
+ const renderer = toolDefinition.canvas?.renderer ?? toolDefinition.renderer;
94
+ let node = content.node;
95
+ if (renderer?.elementTag && node) {
96
+ node.toolCall = latest;
97
+ } else {
98
+ node = this.#createRenderer(latest, toolDefinition);
99
+ }
100
+ this.#content = {
101
+ ...content,
102
+ node,
103
+ toolCall: latest,
104
+ title: content.titleOverride ?? resolveToolCanvasTitle(latest, toolDefinition)
105
+ };
106
+ }
107
+ /** Opens tools configured with `canvas.open: 'auto'` once their renderer shows. Call after render, not during it. */
108
+ autoOpenPending() {
109
+ const tools = this.#config.getTools();
110
+ if (![...tools.values()].some((tool) => tool.canvas?.open === "auto")) {
111
+ return;
112
+ }
113
+ for (const toolCall of this.#config.getToolCalls()) {
114
+ const toolDefinition = tools.get(toolCall.name);
115
+ if (toolDefinition?.canvas?.open !== "auto" || this.#autoOpenedToolCallIds.has(toolCall.id) || !shouldShowToolRenderer(toolCall, toolDefinition)) {
116
+ continue;
117
+ }
118
+ this.#autoOpenedToolCallIds.add(toolCall.id);
119
+ this.#openToolCall({ toolCall, toolDefinition, reason: "auto" });
120
+ }
121
+ }
122
+ #findToolCall(id) {
123
+ return this.#config.getToolCalls().find((toolCall) => toolCall.id === id);
124
+ }
125
+ #commitOpen(content, reason = "request") {
126
+ if (this.#config.canOpen && !this.#config.canOpen()) {
127
+ return false;
128
+ }
129
+ const event = this.#config.dispatchHostEvent({
130
+ type: this.#config.openEvent,
131
+ detail: { id: content.id, title: content.title, toolCall: content.toolCall },
132
+ cancelable: true
133
+ });
134
+ if (event.defaultPrevented) {
135
+ return false;
136
+ }
137
+ this.#content = content;
138
+ if (!this.#isOpen) {
139
+ this.#isOpen = true;
140
+ this.#config.onOpen?.({ reason });
141
+ }
142
+ this.#syncProvider();
143
+ this.#host.requestUpdate();
144
+ return true;
145
+ }
146
+ // Content stays mounted while the host collapses, then is released so hidden iframes and timers stop.
147
+ #hide() {
148
+ this.#isOpen = false;
149
+ this.#config.onClose?.();
150
+ this.#syncProvider();
151
+ this.#host.requestUpdate();
152
+ void this.#releaseAfterCollapse();
153
+ }
154
+ async #releaseAfterCollapse() {
155
+ const content = this.#content;
156
+ await this.#host.updateComplete;
157
+ await this.#config.collapsed?.();
158
+ if (!this.#isOpen && this.#content === content) {
159
+ this.#content = null;
160
+ this.#host.requestUpdate();
161
+ }
162
+ }
163
+ #syncProvider() {
164
+ this.#provider.setValue({
165
+ presentation: "inline-expandable",
166
+ openToolCallId: this.#isOpen ? this.#content?.toolCall?.id : void 0
167
+ });
168
+ }
169
+ #closeToolCall(toolCallId) {
170
+ if (this.#content?.toolCall?.id !== toolCallId) {
171
+ return false;
172
+ }
173
+ return this.close();
174
+ }
175
+ #openToolCall({
176
+ toolCall,
177
+ toolDefinition,
178
+ title,
179
+ reason
180
+ }) {
181
+ if (this.#isOpen && this.#content?.toolCall?.id === toolCall.id) {
182
+ this.#config.onRefocus?.();
183
+ return true;
184
+ }
185
+ return this.#commitOpen(
186
+ {
187
+ id: toolCall.id,
188
+ title: title ?? resolveToolCanvasTitle(toolCall, toolDefinition),
189
+ node: this.#createRenderer(toolCall, toolDefinition),
190
+ toolCall,
191
+ toolDefinition,
192
+ titleOverride: title
193
+ },
194
+ reason
195
+ );
196
+ }
197
+ #createControls(toolCall, toolDefinition) {
198
+ return createToolCanvas({
199
+ available: true,
200
+ // This instance only exists to be shown in the canvas, and is created before the canvas finishes opening.
201
+ isOpen: () => true,
202
+ open: (options) => this.#openToolCall({ toolCall, toolDefinition, title: options?.title }),
203
+ close: () => this.#closeToolCall(toolCall.id)
204
+ });
205
+ }
206
+ #createRenderer(toolCall, toolDefinition) {
207
+ const renderer = toolDefinition.canvas?.renderer ?? toolDefinition.renderer;
208
+ const canvas = this.#createControls(toolCall, toolDefinition);
209
+ if (renderer?.elementTag) {
210
+ const element = document.createElement(renderer.elementTag);
211
+ element.toolCall = toolCall;
212
+ element.presentation = "canvas";
213
+ element.canvas = canvas;
214
+ return element;
215
+ }
216
+ return renderer?.render?.(toolCall, { presentation: "canvas", canvas });
217
+ }
218
+ }
219
+ export {
220
+ CanvasController
221
+ };
@@ -1,6 +1,6 @@
1
1
  import { ReactiveController, ReactiveControllerHost } from 'lit';
2
2
  import { AgentAdapter } from '../ai-chatbot/agent-adapter.js';
3
- import { ChatMessage, ClientMessageInput, FileAttachment, FileUploadCallbacks, MessageItem, ThreadState, ToolCall, ToolDefinition } from '../ai-chatbot/types.js';
3
+ import { ChatMessage, ClientMessageInput, FileAttachment, FileUploadCallbacks, MessageItem, ThreadState, ToolCall, AnyToolDefinition } from '../ai-chatbot/types.js';
4
4
  export interface ChatbotCoreCallbacks {
5
5
  onRequestUpdate: () => void;
6
6
  onScrollToBottom: () => void;
@@ -20,7 +20,7 @@ export declare class ChatbotCoreController implements ReactiveController {
20
20
  get isStreaming(): boolean;
21
21
  get isUploading(): boolean;
22
22
  get hasMessages(): boolean;
23
- get tools(): Map<string, ToolDefinition>;
23
+ get tools(): Map<string, AnyToolDefinition>;
24
24
  get pendingAttachments(): FileAttachment[];
25
25
  get completedAttachments(): FileAttachment[];
26
26
  sendMessage(config: {
@@ -2,6 +2,7 @@ import { SubscriptionManager } from "../ai-chatbot/event-emitter.mjs";
2
2
  import { FileUploadManager } from "../ai-chatbot/file-upload-manager.mjs";
3
3
  import { MessageStateController } from "../ai-chatbot/message-state-controller.mjs";
4
4
  import { generateId } from "../ai-chatbot/utils.mjs";
5
+ const NO_UI_RESOURCE = { uiResource: void 0, uiResourceStatus: void 0 };
5
6
  const UNSETTLED_TOOL_CALL_STATUSES = ["pending", "parsing", "executing"];
6
7
  class ChatbotCoreController {
7
8
  #host;
@@ -15,10 +16,14 @@ class ChatbotCoreController {
15
16
  #mcpAppToolCallIds = /* @__PURE__ */ new Set();
16
17
  /** Agent tool calls marked complete on their `tool-call` chunk, before any result arrived. */
17
18
  #presumedCompleteToolCallIds = /* @__PURE__ */ new Set();
18
- /** Resolved UI resources whose tool call is not in state yet. */
19
+ /** UI resource fields (resolve status or resolved resource) whose tool call is not in state yet. */
19
20
  #pendingUiResources = /* @__PURE__ */ new Map();
20
- /** Bumped whenever the thread is cleared or replaced, so in-flight UI resource resolves from before are dropped. */
21
- #threadGeneration = 0;
21
+ /**
22
+ * Token of the latest in-flight UI resource resolve per tool call. Removed when that resolve
23
+ * settles, or when the thread is cleared/replaced; a resolve whose token is no longer current
24
+ * is dropped, so the latest announcement wins and stale threads never get stamped.
25
+ */
26
+ #uiResourceResolves = /* @__PURE__ */ new Map();
22
27
  constructor(host, config) {
23
28
  this.#host = host;
24
29
  this.#callbacks = config.callbacks;
@@ -310,10 +315,11 @@ class ChatbotCoreController {
310
315
  this.#messageStateController.completeToolCallInResponse(event.toolCallId, event.result, event);
311
316
  }
312
317
  /**
313
- * Resolves and stamps the UI resource. Works whether the event arrives before or after
314
- * the tool call's chunks, mid-run, after the run ended, or after `setMessages` for
315
- * tool calls restored from history. A resolve that outlives the thread it started in
316
- * (cleared/replaced, or the adapter swapped) is dropped.
318
+ * Marks the tool call as a loading MCP app, then resolves and stamps the UI resource.
319
+ * Works whether the event arrives before or after the tool call's chunks, mid-run, after
320
+ * the run ended, or after `setMessages` for tool calls restored from history. A resolve
321
+ * superseded by a newer announcement, or outliving its thread (cleared/replaced), is
322
+ * dropped; one that outlives its adapter falls back to a plain tool call.
317
323
  */
318
324
  async #handleMcpUiResource(event) {
319
325
  const adapter = this.#adapter;
@@ -321,7 +327,8 @@ class ChatbotCoreController {
321
327
  return;
322
328
  }
323
329
  const { toolCallId, resourceUri } = event;
324
- const generation = this.#threadGeneration;
330
+ const token = Symbol(toolCallId);
331
+ this.#uiResourceResolves.set(toolCallId, token);
325
332
  this.#mcpAppToolCallIds.add(toolCallId);
326
333
  if (this.#presumedCompleteToolCallIds.delete(toolCallId)) {
327
334
  this.#messageStateController.updateToolCallInResponse(toolCallId, {
@@ -329,29 +336,43 @@ class ChatbotCoreController {
329
336
  endTimestamp: void 0
330
337
  });
331
338
  }
332
- let uiResource;
333
- try {
334
- uiResource = await adapter.resolveMcpAppResource({ resourceUri, toolCallId });
335
- } catch (error) {
336
- console.warn(`[forge-ai-mcp-app] Failed to resolve UI resource for tool call ${toolCallId}:`, error);
339
+ this.#stampUiResource(toolCallId, { uiResourceStatus: "loading" });
340
+ const fields = await this.#resolveUiResource(adapter, { resourceUri, toolCallId });
341
+ if (this.#uiResourceResolves.get(toolCallId) !== token) {
337
342
  return;
338
343
  }
339
- if (!uiResource || adapter !== this.#adapter || generation !== this.#threadGeneration) {
340
- return;
344
+ this.#uiResourceResolves.delete(toolCallId);
345
+ this.#stampUiResource(toolCallId, adapter === this.#adapter ? fields : NO_UI_RESOURCE);
346
+ }
347
+ /**
348
+ * A resolved resource replaces the status; `undefined` (MCP apps unavailable) clears both so
349
+ * the call renders as a plain tool call; a throw marks the widget as failed.
350
+ */
351
+ async #resolveUiResource(adapter, params) {
352
+ try {
353
+ const uiResource = await adapter.resolveMcpAppResource?.(params);
354
+ return uiResource ? { uiResource, uiResourceStatus: void 0 } : NO_UI_RESOURCE;
355
+ } catch (error) {
356
+ console.warn(`[forge-ai-mcp-app] Failed to resolve UI resource for tool call ${params.toolCallId}:`, error);
357
+ return { uiResource: void 0, uiResourceStatus: "error" };
341
358
  }
359
+ }
360
+ #stampUiResource(toolCallId, fields) {
342
361
  if (this.#messageStateController.getToolCall(toolCallId)) {
343
- this.#messageStateController.updateToolCallInResponse(toolCallId, { uiResource });
362
+ this.#messageStateController.updateToolCallInResponse(toolCallId, fields);
363
+ } else if (fields.uiResource || fields.uiResourceStatus) {
364
+ this.#pendingUiResources.set(toolCallId, fields);
344
365
  } else {
345
- this.#pendingUiResources.set(toolCallId, uiResource);
366
+ this.#pendingUiResources.delete(toolCallId);
346
367
  }
347
368
  }
348
369
  #takePendingUiResource(toolCallId) {
349
- const uiResource = this.#pendingUiResources.get(toolCallId);
350
- if (!uiResource) {
370
+ const fields = this.#pendingUiResources.get(toolCallId);
371
+ if (!fields) {
351
372
  return {};
352
373
  }
353
374
  this.#pendingUiResources.delete(toolCallId);
354
- return { uiResource };
375
+ return fields;
355
376
  }
356
377
  async #sendToolResult(toolCallId, result) {
357
378
  const toolCall = this.#messageStateController.getToolCall(toolCallId);
@@ -445,10 +466,10 @@ class ChatbotCoreController {
445
466
  return this.#messageStateController.getToolCall(id);
446
467
  }
447
468
  clearMessages() {
448
- this.#threadGeneration++;
449
469
  this.#mcpAppToolCallIds.clear();
450
470
  this.#presumedCompleteToolCallIds.clear();
451
471
  this.#pendingUiResources.clear();
472
+ this.#uiResourceResolves.clear();
452
473
  this.#messageStateController.clearMessages();
453
474
  this.#fileUploadManager.clear();
454
475
  }
@@ -456,10 +477,10 @@ class ChatbotCoreController {
456
477
  return this.#messageStateController.getMessages();
457
478
  }
458
479
  setMessages(messages) {
459
- this.#threadGeneration++;
460
480
  this.#mcpAppToolCallIds.clear();
461
481
  this.#presumedCompleteToolCallIds.clear();
462
482
  this.#pendingUiResources.clear();
483
+ this.#uiResourceResolves.clear();
463
484
  this.#messageStateController.setMessages(messages);
464
485
  }
465
486
  updateMessageContent(id, content) {
@@ -474,6 +495,7 @@ class ChatbotCoreController {
474
495
  this.#mcpAppToolCallIds.delete(id);
475
496
  this.#presumedCompleteToolCallIds.delete(id);
476
497
  this.#pendingUiResources.delete(id);
498
+ this.#uiResourceResolves.delete(id);
477
499
  }
478
500
  }
479
501
  setResponseFeedback(responseId, feedback) {
@@ -1,3 +1,4 @@
1
+ export { CanvasController, type CanvasControllerConfig, type CanvasContent, type CanvasOpenEventData, type OpenCanvasOptions } from './canvas-controller.js';
1
2
  export { DragController, type DragControllerOptions } from './drag-controller.js';
2
- export { ResizeController, type ResizeControllerOptions } from './resize-controller.js';
3
+ export { ResizeController, type ResizeControllerOptions, type ResizeGrowDirection } from './resize-controller.js';
3
4
  export { ChatbotCoreController, type ChatbotCoreCallbacks, type ChatbotCoreControllerConfig } from './chatbot-core-controller.js';
@@ -1,7 +1,9 @@
1
+ import { CanvasController } from "./canvas-controller.mjs";
1
2
  import { DragController } from "./drag-controller.mjs";
2
3
  import { ResizeController } from "./resize-controller.mjs";
3
4
  import { ChatbotCoreController } from "./chatbot-core-controller.mjs";
4
5
  export {
6
+ CanvasController,
5
7
  ChatbotCoreController,
6
8
  DragController,
7
9
  ResizeController
@@ -1,5 +1,6 @@
1
1
  import { ReactiveController, ReactiveControllerHost } from 'lit';
2
2
  import { Ref } from 'lit/directives/ref.js';
3
+ export type ResizeGrowDirection = 'left' | 'right';
3
4
  export interface ResizeControllerOptions {
4
5
  targetElementRef: Ref<HTMLElement>;
5
6
  resizeHandleRef: Ref<HTMLElement>;
@@ -7,6 +8,10 @@ export interface ResizeControllerOptions {
7
8
  maxWidth?: number;
8
9
  defaultWidth?: number;
9
10
  keyboardStep?: number;
11
+ /** Screen direction the handle moves to make the width larger. Defaults to `'left'`, for a panel docked to the right. */
12
+ growDirection?: () => ResizeGrowDirection;
13
+ /** Widest the target may be given its container. Defaults to the window width minus a small buffer. */
14
+ getAvailableWidth?: () => number;
10
15
  onResize?: (width: number) => void;
11
16
  onResizeStart?: () => void;
12
17
  onResizeEnd?: () => void;
@@ -24,13 +24,14 @@ class ResizeController {
24
24
  };
25
25
  this.handleKeyDown = (event) => {
26
26
  const step = this.#options.keyboardStep;
27
+ const growSign = this.#options.growDirection() === "left" ? 1 : -1;
27
28
  let delta = 0;
28
29
  switch (event.key) {
29
30
  case "ArrowLeft":
30
- delta = step;
31
+ delta = step * growSign;
31
32
  break;
32
33
  case "ArrowRight":
33
- delta = -step;
34
+ delta = -step * growSign;
34
35
  break;
35
36
  default:
36
37
  return;
@@ -46,7 +47,7 @@ class ResizeController {
46
47
  return;
47
48
  }
48
49
  event.preventDefault();
49
- const deltaX = this.#dragStartX - event.clientX;
50
+ const deltaX = this.#options.growDirection() === "left" ? this.#dragStartX - event.clientX : event.clientX - this.#dragStartX;
50
51
  const newWidth = this.#dragStartWidth + deltaX;
51
52
  this.#currentWidth = this.#constrainWidth(newWidth);
52
53
  this.#applyWidth();
@@ -72,6 +73,8 @@ class ResizeController {
72
73
  maxWidth: 800,
73
74
  defaultWidth: 420,
74
75
  keyboardStep: 20,
76
+ growDirection: () => "left",
77
+ getAvailableWidth: () => window.innerWidth - RESIZE_SCREEN_BUFFER,
75
78
  onResize: () => {
76
79
  },
77
80
  onResizeStart: () => {
@@ -118,8 +121,7 @@ class ResizeController {
118
121
  }
119
122
  }
120
123
  #constrainWidth(width) {
121
- const availableWidth = window.innerWidth - RESIZE_SCREEN_BUFFER;
122
- const effectiveMaxWidth = Math.min(this.#options.maxWidth, availableWidth);
124
+ const effectiveMaxWidth = Math.min(this.#options.maxWidth, this.#options.getAvailableWidth());
123
125
  return Math.max(this.#options.minWidth, Math.min(width, effectiveMaxWidth));
124
126
  }
125
127
  #applyWidth() {
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './ai-artifact-card';
9
9
  export * from './ai-artifact-card-group';
10
10
  export * from './ai-attachment';
11
11
  export * from './ai-button';
12
+ export * from './ai-canvas';
12
13
  export * from './ai-chain-of-thought';
13
14
  export * from './ai-chat-header';
14
15
  export * from './ai-chat-interface';
package/dist/index.mjs CHANGED
@@ -9,6 +9,7 @@ import { AiArtifactCardComponent, AiArtifactCardComponentTagName } from "./ai-ar
9
9
  import { AiArtifactCardGroupComponent, AiArtifactCardGroupComponentTagName } from "./ai-artifact-card-group/ai-artifact-card-group.mjs";
10
10
  import { AiAttachmentComponent, AiAttachmentComponentTagName } from "./ai-attachment/ai-attachment.mjs";
11
11
  import { AiButtonComponent, AiButtonComponentTagName } from "./ai-button/ai-button.mjs";
12
+ import { AiCanvasComponent, AiCanvasComponentTagName } from "./ai-canvas/ai-canvas.mjs";
12
13
  import { AiChainOfThoughtComponent, AiChainOfThoughtComponentTagName } from "./ai-chain-of-thought/ai-chain-of-thought.mjs";
13
14
  import { ThoughtBaseComponent, ThoughtBaseComponentTagName } from "./ai-chain-of-thought/thought-base/thought-base.mjs";
14
15
  import { ThoughtSearchResultComponent, ThoughtSearchResultComponentTagName } from "./ai-chain-of-thought/thought-search-result/thought-search-result.mjs";
@@ -25,6 +26,7 @@ import { HttpAgentWithCredentials } from "./ai-chatbot/http-agent-with-credentia
25
26
  import { EventEmitter, SubscriptionManager } from "./ai-chatbot/event-emitter.mjs";
26
27
  import { AgentRunner } from "./ai-chatbot/agent-runner.mjs";
27
28
  import { createToolRenderer } from "./ai-chatbot/create-tool-renderer.mjs";
29
+ import { requestCanvasClose, requestCanvasOpen } from "./ai-chatbot/tool-canvas.mjs";
28
30
  import { MessageStateController } from "./ai-chatbot/message-state-controller.mjs";
29
31
  import { FileUploadManager } from "./ai-chatbot/file-upload-manager.mjs";
30
32
  import { MarkdownStreamController } from "./ai-chatbot/markdown-stream-controller.mjs";
@@ -92,6 +94,8 @@ export {
92
94
  AiAttachmentComponentTagName,
93
95
  AiButtonComponent,
94
96
  AiButtonComponentTagName,
97
+ AiCanvasComponent,
98
+ AiCanvasComponentTagName,
95
99
  AiChainOfThoughtComponent,
96
100
  AiChainOfThoughtComponentTagName,
97
101
  AiChatHeaderComponent,
@@ -203,6 +207,8 @@ export {
203
207
  generateId,
204
208
  renderInlineMarkdown,
205
209
  renderMarkdown,
210
+ requestCanvasClose,
211
+ requestCanvasOpen,
206
212
  toRequestMessages,
207
213
  toRequestToolCall
208
214
  };
@@ -5,6 +5,7 @@ import "@ag-ui/client";
5
5
  import "uuid";
6
6
  import "../../utils/markdown.mjs";
7
7
  import { createToolRenderer } from "../../ai-chatbot/create-tool-renderer.mjs";
8
+ import "../../ai-chatbot/tool-canvas.mjs";
8
9
  const displayDataTableTool = {
9
10
  name: "displayDataTable",
10
11
  displayName: "Display Data Table",
@@ -28,7 +28,8 @@ declare global {
28
28
  * Renders an MCP-app UI resource stamped onto a {@link ToolCall} as a sandboxed,
29
29
  * cross-origin widget. The iframe + bridge are mounted once and survive every `toolCall`
30
30
  * delta (Lit reuses this node at its keyed position), so args stream live without
31
- * remounting.
31
+ * remounting. While `uiResourceStatus` is `loading` it renders a skeleton placeholder in the
32
+ * same position, and connects once `uiResource` is stamped onto the same element.
32
33
  *
33
34
  * The cross-origin sandbox is mandatory: when no `sandboxUrl` is available the element
34
35
  * renders an error artifact rather than an insecure same-origin iframe.