@parallelworks/ui 0.64.1 → 0.65.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.
package/README.md CHANGED
@@ -845,6 +845,20 @@ optional capability groups (`providers`, `attachments`, `sharing`,
845
845
  `ChatAdapterError` with a user-facing `message`. Model entries and wire
846
846
  messages keep OpenAI-compatible snake_case field names.
847
847
 
848
+ An adapter with `tools` lists the tools its backend runs itself while it
849
+ answers, each a `ChatToolOption` (`id`, and a `label` and optional
850
+ `description` in the reader's language). The composer's + button then opens a
851
+ menu of Add files (when the adapter stores attachments) and Tools, whose
852
+ submenu has a switch for each tool and the `extraLinks.connectTools` link,
853
+ which leaves the row under the composer. Past six tools (`TOOLS_IN_MENU`),
854
+ Tools opens a modal that searches them and turns a match on or off together. Every tool starts on, a dot on the + shows that some are off, and the
855
+ choice is remembered per user in the browser. A turn the provider doesn't run,
856
+ such as an agent's, offers no tools. Each request carries the enabled ids as
857
+ `StreamRequest.tools` once the list has loaded, possibly empty, and the
858
+ OpenAI-compatible transport sends them as the `X-Platform-Tools` header
859
+ (`TOOLS_HEADER`), comma-separated and empty when every tool is off. A request
860
+ without a tool choice sends no header.
861
+
848
862
  Tailwind v4 consumers add the chat rules (markdown rendering, streaming
849
863
  animations) and streamdown's classes next to the theme contract:
850
864
 
@@ -6,6 +6,24 @@ var CANNED_REPLIES = [
6
6
  "Branching works too: edit one of your earlier messages or hit regenerate on a reply, then use the arrows to flip between branches."
7
7
  ];
8
8
  var TOOL_REPLY = "Two clusters are running: **gpu-a** with 4 nodes, and **cpu-batch**, idle and scaled to zero.";
9
+ var TOOLS_OFF_REPLY = "Listing clusters is turned off in the tools menu, so I can't check which clusters are running. Turn it back on and ask again.";
10
+ var LIST_CLUSTERS = "list_clusters";
11
+ var MOCK_TOOLS = [
12
+ {
13
+ id: LIST_CLUSTERS,
14
+ label: "List clusters",
15
+ description: "Names your clusters and their state"
16
+ },
17
+ {
18
+ id: "list_storage",
19
+ label: "List storage",
20
+ description: "Names your buckets and disks"
21
+ },
22
+ {
23
+ id: "read_docs",
24
+ label: "Read the docs"
25
+ }
26
+ ];
9
27
  var THINKING_PREAMBLE = "Thinking about the question, weighing a few canned options, and picking the next reply in the rotation...";
10
28
  function sleep(ms, signal) {
11
29
  return new Promise((resolve, reject) => {
@@ -29,6 +47,9 @@ function createMockChatAdapter(options) {
29
47
  let replyIndex = 0;
30
48
  return {
31
49
  conversations: store.conversations,
50
+ tools: { async list() {
51
+ return MOCK_TOOLS;
52
+ } },
32
53
  models: { async list() {
33
54
  return {
34
55
  models: [
@@ -69,7 +90,8 @@ function createMockChatAdapter(options) {
69
90
  reasoning += word;
70
91
  handlers.onReasoning?.(word);
71
92
  }
72
- const usesTools = req.model.includes("tools");
93
+ const toolsModel = req.model.includes("tools");
94
+ const usesTools = toolsModel && (req.tools === void 0 || req.tools.includes(LIST_CLUSTERS));
73
95
  if (usesTools) {
74
96
  const id = `call-${crypto.randomUUID()}`;
75
97
  handlers.onPart?.({
@@ -93,7 +115,7 @@ function createMockChatAdapter(options) {
93
115
  }])
94
116
  });
95
117
  }
96
- const reply = usesTools ? TOOL_REPLY : CANNED_REPLIES[replyIndex++ % CANNED_REPLIES.length] ?? "";
118
+ const reply = usesTools ? TOOL_REPLY : toolsModel ? TOOLS_OFF_REPLY : CANNED_REPLIES[replyIndex++ % CANNED_REPLIES.length] ?? "";
97
119
  await sleep(250, signal);
98
120
  let content = "";
99
121
  for (const word of reply.split(/(?<= )/)) {
@@ -1,4 +1,5 @@
1
1
  import { type StreamCompletion, type StreamRequest } from '../types.js';
2
+ export declare const TOOLS_HEADER = "X-Platform-Tools";
2
3
  export interface OpenAIStreamConfig {
3
4
  completionsUrl: string;
4
5
  responsesUrl?: string;
@@ -3,6 +3,17 @@ import { errorMessageFromResponse } from "./errors.js";
3
3
  import { buildResponsesRequestBody, parseResponsesSSELine } from "./responses.js";
4
4
  import { processSSEStream } from "./sse.js";
5
5
  //#region src/ai/adapter/openai/createOpenAIStream.ts
6
+ var TOOLS_HEADER = "X-Platform-Tools";
7
+ function requestHeaders(hostHeaders, tools) {
8
+ const headers = {
9
+ "Content-Type": "application/json",
10
+ Accept: "text/event-stream"
11
+ };
12
+ const toolsKey = TOOLS_HEADER.toLowerCase();
13
+ for (const [name, value] of Object.entries(hostHeaders ?? {})) if (tools === void 0 || name.toLowerCase() !== toolsKey) headers[name] = value;
14
+ if (tools !== void 0) headers[TOOLS_HEADER] = tools.join(",");
15
+ return headers;
16
+ }
6
17
  function createOpenAICompatibleStream(config) {
7
18
  return async (req, handlers, signal) => {
8
19
  const responsesUrl = req.useResponses === true ? config.responsesUrl : void 0;
@@ -15,11 +26,7 @@ function createOpenAICompatibleStream(config) {
15
26
  };
16
27
  const res = await (config.fetch ?? globalThis.fetch.bind(globalThis))(url, {
17
28
  method: "POST",
18
- headers: {
19
- "Content-Type": "application/json",
20
- Accept: "text/event-stream",
21
- ...config.buildHeaders?.(req)
22
- },
29
+ headers: requestHeaders(config.buildHeaders?.(req), req.tools),
23
30
  ...config.credentials ? { credentials: config.credentials } : {},
24
31
  body: JSON.stringify(body),
25
32
  ...signal ? { signal } : {}
@@ -31,4 +38,4 @@ function createOpenAICompatibleStream(config) {
31
38
  };
32
39
  }
33
40
  //#endregion
34
- export { createOpenAICompatibleStream };
41
+ export { TOOLS_HEADER, createOpenAICompatibleStream };
@@ -1,4 +1,4 @@
1
- export { createOpenAICompatibleStream, type OpenAIStreamConfig, } from './createOpenAIStream.js';
1
+ export { createOpenAICompatibleStream, type OpenAIStreamConfig, TOOLS_HEADER, } from './createOpenAIStream.js';
2
2
  export { apiErrorMessage, errorMessageFromResponse, statusFallbackMessage, } from './errors.js';
3
3
  export { createOpenAIChatAdapter, type OpenAIChatAdapterOptions, } from './openaiAdapter.js';
4
4
  export { buildResponsesRequestBody, parseResponsesSSELine, } from './responses.js';
@@ -1,6 +1,6 @@
1
1
  import { apiErrorMessage, errorMessageFromResponse, statusFallbackMessage } from "./errors.js";
2
2
  import { buildResponsesRequestBody, parseResponsesSSELine } from "./responses.js";
3
3
  import { parseSSELine, processSSEStream } from "./sse.js";
4
- import { createOpenAICompatibleStream } from "./createOpenAIStream.js";
4
+ import { TOOLS_HEADER, createOpenAICompatibleStream } from "./createOpenAIStream.js";
5
5
  import { createOpenAIChatAdapter } from "./openaiAdapter.js";
6
- export { apiErrorMessage, buildResponsesRequestBody, createOpenAIChatAdapter, createOpenAICompatibleStream, errorMessageFromResponse, parseResponsesSSELine, parseSSELine, processSSEStream, statusFallbackMessage };
6
+ export { TOOLS_HEADER, apiErrorMessage, buildResponsesRequestBody, createOpenAIChatAdapter, createOpenAICompatibleStream, errorMessageFromResponse, parseResponsesSSELine, parseSSELine, processSSEStream, statusFallbackMessage };
@@ -20,6 +20,7 @@ export interface StreamRequest {
20
20
  userMessageId: string;
21
21
  allocation?: string | null;
22
22
  useResponses?: boolean;
23
+ tools?: string[];
23
24
  }
24
25
  export interface StreamHandlers {
25
26
  onContent: (content: string) => void;
@@ -124,6 +125,14 @@ export interface SharingAdapter {
124
125
  export interface AllocationsAdapter {
125
126
  list(): Promise<Allocation[]>;
126
127
  }
128
+ export interface ChatToolOption {
129
+ id: string;
130
+ label: string;
131
+ description?: string;
132
+ }
133
+ export interface ChatToolsAdapter {
134
+ list(): Promise<ChatToolOption[]>;
135
+ }
127
136
  export interface ChatAdapter {
128
137
  conversations: ConversationsAdapter;
129
138
  models: {
@@ -134,4 +143,5 @@ export interface ChatAdapter {
134
143
  attachments?: AttachmentsAdapter;
135
144
  sharing?: SharingAdapter;
136
145
  allocations?: AllocationsAdapter;
146
+ tools?: ChatToolsAdapter;
137
147
  }
@@ -1,9 +1,10 @@
1
1
  import { cx } from "../../cx.js";
2
- import { AddIcon, ArrowUpIcon, StopIcon } from "../../icons.js";
2
+ import { ArrowUpIcon, StopIcon } from "../../icons.js";
3
3
  import { useChatConfig } from "../core/config.js";
4
4
  import { useChat } from "../core/ChatProvider.js";
5
5
  import AttachmentUpload from "./AttachmentUpload.js";
6
6
  import useDragDrop from "../core/useDragDrop.js";
7
+ import ComposerAddMenu from "./ComposerAddMenu.js";
7
8
  import { ComposerContext, ComposerControls, ComposerSettings } from "./ComposerChrome.js";
8
9
  import { clearDraft, readDraft, storeDraft, sweepDrafts } from "./chatDraft.js";
9
10
  import DragOverlay from "./DragOverlay.js";
@@ -27,18 +28,6 @@ function keyAction(e, empty, canEditLast) {
27
28
  if (e.key === "ArrowUp" && empty && canEditLast) return "editLast";
28
29
  return null;
29
30
  }
30
- function AttachButton({ open, disabled, label, onClick }) {
31
- return /* @__PURE__ */ jsx("button", {
32
- type: "button",
33
- onClick,
34
- disabled,
35
- "aria-expanded": open,
36
- className: cx("flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors", open ? "theme-text chat-tint-strong" : "theme-muted-text hover:theme-text hover:chat-tint", "disabled:opacity-50 disabled:cursor-not-allowed"),
37
- "aria-label": label,
38
- title: label,
39
- children: /* @__PURE__ */ jsx(AddIcon, { className: cx("h-5 w-5 transition-transform duration-200", open && "rotate-45") })
40
- });
41
- }
42
31
  function SendButton({ onClick, enabled, emphasized, label, testId }) {
43
32
  return /* @__PURE__ */ jsx("button", {
44
33
  type: "button",
@@ -86,7 +75,7 @@ function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEd
86
75
  const textareaRef = useRef(null);
87
76
  const attachmentUploadRef = useRef(null);
88
77
  const chat = useChat();
89
- const { adapter, flushQueuedMessages, isSelectedModelAvailable } = chat;
78
+ const { adapter, flushQueuedMessages, isSelectedModelAvailable, tools } = chat;
90
79
  const { isStreaming, stopStreaming, queuedCount } = streamControls(chat, turn);
91
80
  const t = useChatConfig().strings.input;
92
81
  const effectivePlaceholder = placeholder ?? t.placeholder;
@@ -197,7 +186,8 @@ function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEd
197
186
  if (pastes && handleTextPaste(e.clipboardData.getData("text/plain"))) e.preventDefault();
198
187
  };
199
188
  const { isDragging, dragHandlers } = useDragDrop({ onFilesDropped: handleFilesDropped });
200
- const toolbar = attachmentsAvailable || !!settingsRight;
189
+ const toolsAvailable = !turn && tools.length > 0;
190
+ const toolbar = attachmentsAvailable || toolsAvailable || !!settingsRight;
201
191
  const action = isStreaming && !input.trim() ? /* @__PURE__ */ jsx("button", {
202
192
  type: "button",
203
193
  onClick: stopStreaming,
@@ -281,11 +271,11 @@ function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEd
281
271
  }),
282
272
  toolbar && /* @__PURE__ */ jsxs("div", {
283
273
  className: "flex items-center gap-1 px-2 pb-2",
284
- children: [attachmentsAvailable && /* @__PURE__ */ jsx(AttachButton, {
285
- open: showAttachments,
274
+ children: [/* @__PURE__ */ jsx(ComposerAddMenu, {
275
+ attachmentsOpen: showAttachments && attachmentsAvailable,
276
+ onToggleAttachments: attachmentsAvailable ? () => setShowAttachments(!showAttachments) : void 0,
286
277
  disabled,
287
- label: t.attachFiles,
288
- onClick: () => setShowAttachments(!showAttachments)
278
+ withTools: !turn
289
279
  }), /* @__PURE__ */ jsxs("div", {
290
280
  className: "ml-auto flex min-w-0 items-center gap-1",
291
281
  children: [/* @__PURE__ */ jsx("div", {
@@ -0,0 +1,14 @@
1
+ /** More tools than this leave the menu for a modal, where they can be searched. */
2
+ export declare const TOOLS_IN_MENU = 6;
3
+ /** The composer's + button. With only attachments it opens their panel, as it
4
+ * always has; once the adapter lists tools it opens a menu of Add files and
5
+ * Tools, whose submenu toggles each one. Past TOOLS_IN_MENU, Tools opens a
6
+ * modal instead. A dot on the + says some tools are off. */
7
+ export default function ComposerAddMenu({ attachmentsOpen, onToggleAttachments, disabled, withTools, }: {
8
+ attachmentsOpen: boolean;
9
+ /** Absent when the composer takes no files. */
10
+ onToggleAttachments?: (() => void) | undefined;
11
+ disabled: boolean;
12
+ /** Offer the provider's tools, for a turn the provider runs. */
13
+ withTools: boolean;
14
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,274 @@
1
+ import { cx } from "../../cx.js";
2
+ import { AddIcon, AttachmentIcon, ConnectIcon, ToolsIcon } from "../../icons.js";
3
+ import { panelItemClassName } from "../../components/floating.js";
4
+ import { MenuItem, MenuLinkItem, MenuPopup, MenuSeparator, MenuSubmenuTrigger } from "../../components/Menu.js";
5
+ import { useQueryMatch } from "../../components/search.js";
6
+ import { Button } from "../../components/Button.js";
7
+ import { Modal } from "../../components/Modal.js";
8
+ import { Switch } from "../../components/Switch.js";
9
+ import { SearchInput } from "../../components/SearchInput.js";
10
+ import { useChatConfig } from "../core/config.js";
11
+ import { useChat } from "../core/ChatProvider.js";
12
+ import { Menu } from "@base-ui/react/menu";
13
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
+ import { useId, useState, useSyncExternalStore } from "react";
15
+ var NARROW = "(max-width: 639px)";
16
+ function subscribeNarrow(onChange) {
17
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
18
+ const media = window.matchMedia(NARROW);
19
+ media.addEventListener("change", onChange);
20
+ return () => media.removeEventListener("change", onChange);
21
+ }
22
+ var isNarrow = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(NARROW).matches;
23
+ /** A phone has no room beside the menu for a submenu, and no hover to open one. */
24
+ function useNarrow() {
25
+ return useSyncExternalStore(subscribeNarrow, isNarrow, () => false);
26
+ }
27
+ function PlusButton({ open, label, hint, className, ...props }) {
28
+ const name = hint ? `${label} · ${hint}` : label;
29
+ return /* @__PURE__ */ jsxs("button", {
30
+ type: "button",
31
+ ...props,
32
+ "aria-label": name,
33
+ title: name,
34
+ className: cx("relative flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors", open ? "theme-text chat-tint-strong" : "theme-muted-text hover:theme-text hover:chat-tint data-popup-open:theme-text data-popup-open:chat-tint", "disabled:opacity-50 disabled:cursor-not-allowed", className),
35
+ children: [/* @__PURE__ */ jsx(AddIcon, { className: cx("h-5 w-5 transition-transform duration-200", open && "rotate-45") }), hint && /* @__PURE__ */ jsx("span", {
36
+ "aria-hidden": "true",
37
+ className: "absolute top-1 right-1 size-1.5 rounded-full bg-(--theme-accent)"
38
+ })]
39
+ });
40
+ }
41
+ /** A switch drawn for a menu row, which is itself the checkbox the reader toggles. */
42
+ function RowSwitch() {
43
+ return /* @__PURE__ */ jsx("span", {
44
+ "aria-hidden": "true",
45
+ className: "ml-auto inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-(--theme-border-strong) p-0.5 transition-colors duration-150 group-data-checked:bg-(--theme-focus) motion-reduce:transition-none",
46
+ children: /* @__PURE__ */ jsx("span", { className: "size-3 rounded-full bg-white shadow-sm transition-transform duration-150 group-data-checked:translate-x-3 rtl:group-data-checked:-translate-x-3 motion-reduce:transition-none" })
47
+ });
48
+ }
49
+ function ToolText({ tool, descriptionId }) {
50
+ return /* @__PURE__ */ jsxs("span", {
51
+ className: "min-w-0 flex-1",
52
+ children: [/* @__PURE__ */ jsx("span", {
53
+ className: "block truncate",
54
+ children: tool.label
55
+ }), tool.description && /* @__PURE__ */ jsx("span", {
56
+ id: descriptionId,
57
+ className: "block truncate text-xs theme-muted-text",
58
+ children: tool.description
59
+ })]
60
+ });
61
+ }
62
+ function ToolMenuRow({ tool, enabled, onChange }) {
63
+ const descriptionId = useId();
64
+ return /* @__PURE__ */ jsxs(Menu.CheckboxItem, {
65
+ checked: enabled,
66
+ onCheckedChange: onChange,
67
+ closeOnClick: false,
68
+ className: cx(panelItemClassName, "group gap-3"),
69
+ ...tool.description ? { "aria-describedby": descriptionId } : {},
70
+ children: [/* @__PURE__ */ jsx(ToolText, {
71
+ tool,
72
+ descriptionId
73
+ }), /* @__PURE__ */ jsx(RowSwitch, {})]
74
+ });
75
+ }
76
+ function ToolsList({ listed }) {
77
+ const { enabledToolIds, setToolEnabled } = useChat();
78
+ return listed.map((tool) => /* @__PURE__ */ jsx(ToolMenuRow, {
79
+ tool,
80
+ enabled: enabledToolIds.includes(tool.id),
81
+ onChange: (enabled) => setToolEnabled(tool.id, enabled)
82
+ }, tool.id));
83
+ }
84
+ function ConnectToolsRow({ href }) {
85
+ const { strings } = useChatConfig();
86
+ return /* @__PURE__ */ jsxs(MenuLinkItem, {
87
+ render: /* @__PURE__ */ jsx("a", {
88
+ href,
89
+ target: "_blank",
90
+ rel: "noreferrer"
91
+ }),
92
+ className: "text-xs theme-muted-text data-highlighted:theme-text",
93
+ children: [/* @__PURE__ */ jsx(ConnectIcon, { className: "h-3.5 w-3.5 shrink-0" }), strings.sidebar.connectTools]
94
+ });
95
+ }
96
+ function OnCount({ enabled, total }) {
97
+ const { strings } = useChatConfig();
98
+ return /* @__PURE__ */ jsx("span", {
99
+ className: "ml-auto pl-3 text-xs tabular-nums theme-muted-text",
100
+ children: strings.composerMenu.toolsOn(enabled, total)
101
+ });
102
+ }
103
+ function ToolsModalRow({ tool, enabled, onChange }) {
104
+ const descriptionId = useId();
105
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("label", {
106
+ className: "flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-(--theme-hover)",
107
+ children: [/* @__PURE__ */ jsxs("span", {
108
+ className: "min-w-0 flex-1",
109
+ children: [/* @__PURE__ */ jsx("span", {
110
+ className: "block text-sm",
111
+ children: tool.label
112
+ }), tool.description && /* @__PURE__ */ jsx("span", {
113
+ id: descriptionId,
114
+ className: "block text-xs theme-muted-text",
115
+ children: tool.description
116
+ })]
117
+ }), /* @__PURE__ */ jsx(Switch, {
118
+ size: "sm",
119
+ checked: enabled,
120
+ onCheckedChange: onChange,
121
+ ...tool.description ? { "aria-describedby": descriptionId } : {}
122
+ })]
123
+ }) });
124
+ }
125
+ /** Every tool, searchable, for a host with more than the menu holds. */
126
+ function ToolsModal({ open, onClose, listed }) {
127
+ const { enabledToolIds, setToolEnabled } = useChat();
128
+ const { LinkComponent, extraLinks, strings } = useChatConfig();
129
+ const t = strings.composerMenu;
130
+ const matches = useQueryMatch();
131
+ const [query, setQuery] = useState("");
132
+ const needle = query.trim();
133
+ const shown = needle ? listed.filter((tool) => matches(needle, tool.label, tool.description, tool.id)) : listed;
134
+ const allOn = shown.every((tool) => enabledToolIds.includes(tool.id));
135
+ const close = () => {
136
+ onClose();
137
+ setQuery("");
138
+ };
139
+ let footerStart = /* @__PURE__ */ jsx("span", {});
140
+ if (extraLinks.connectTools) footerStart = /* @__PURE__ */ jsxs(LinkComponent, {
141
+ target: {
142
+ kind: "external",
143
+ href: extraLinks.connectTools
144
+ },
145
+ className: "mr-auto inline-flex items-center gap-1.5 text-xs theme-muted-text hover:theme-text",
146
+ children: [/* @__PURE__ */ jsx(ConnectIcon, { className: "h-3.5 w-3.5 shrink-0" }), strings.sidebar.connectTools]
147
+ });
148
+ return /* @__PURE__ */ jsxs(Modal, {
149
+ open,
150
+ onClose: close,
151
+ size: "md",
152
+ title: t.tools,
153
+ description: t.toolsDescription,
154
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [footerStart, /* @__PURE__ */ jsx(Button, {
155
+ variant: "primary",
156
+ onClick: close,
157
+ children: t.done
158
+ })] }),
159
+ children: [/* @__PURE__ */ jsxs("div", {
160
+ className: "flex items-center gap-2",
161
+ children: [/* @__PURE__ */ jsx(SearchInput, {
162
+ value: query,
163
+ onValueChange: setQuery,
164
+ placeholder: t.searchTools,
165
+ label: t.searchTools,
166
+ className: "flex-1"
167
+ }), shown.length > 0 && /* @__PURE__ */ jsx(Button, {
168
+ variant: "ghost",
169
+ size: "sm",
170
+ onClick: () => {
171
+ for (const tool of shown) setToolEnabled(tool.id, !allOn);
172
+ },
173
+ children: allOn ? t.turnAllOff : t.turnAllOn
174
+ })]
175
+ }), shown.length > 0 ? /* @__PURE__ */ jsx("ul", {
176
+ className: "-mx-2 mt-3 flex flex-col",
177
+ children: shown.map((tool) => /* @__PURE__ */ jsx(ToolsModalRow, {
178
+ tool,
179
+ enabled: enabledToolIds.includes(tool.id),
180
+ onChange: (enabled) => setToolEnabled(tool.id, enabled)
181
+ }, tool.id))
182
+ }) : /* @__PURE__ */ jsx("p", {
183
+ className: "m-0 py-8 text-center text-sm theme-muted-text",
184
+ children: t.noToolsMatch
185
+ })]
186
+ });
187
+ }
188
+ /** The composer's + button. With only attachments it opens their panel, as it
189
+ * always has; once the adapter lists tools it opens a menu of Add files and
190
+ * Tools, whose submenu toggles each one. Past TOOLS_IN_MENU, Tools opens a
191
+ * modal instead. A dot on the + says some tools are off. */
192
+ function ComposerAddMenu({ attachmentsOpen, onToggleAttachments, disabled, withTools }) {
193
+ const { tools, enabledToolIds } = useChat();
194
+ const { extraLinks, strings } = useChatConfig();
195
+ const [modalOpen, setModalOpen] = useState(false);
196
+ const narrow = useNarrow();
197
+ const t = strings.composerMenu;
198
+ const listed = withTools ? tools : [];
199
+ if (listed.length === 0) {
200
+ if (!onToggleAttachments) return null;
201
+ return /* @__PURE__ */ jsx(PlusButton, {
202
+ open: attachmentsOpen,
203
+ "aria-expanded": attachmentsOpen,
204
+ disabled,
205
+ label: strings.input.attachFiles,
206
+ onClick: onToggleAttachments
207
+ });
208
+ }
209
+ const enabled = listed.filter((tool) => enabledToolIds.includes(tool.id)).length;
210
+ const someOff = enabled < listed.length;
211
+ const many = listed.length > 6;
212
+ const connect = extraLinks.connectTools;
213
+ const label = onToggleAttachments ? t.label : t.tools;
214
+ if (many && !onToggleAttachments) return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PlusButton, {
215
+ open: false,
216
+ "aria-haspopup": "dialog",
217
+ disabled,
218
+ label,
219
+ hint: someOff ? t.someToolsOff : void 0,
220
+ onClick: () => setModalOpen(true)
221
+ }), /* @__PURE__ */ jsx(ToolsModal, {
222
+ open: modalOpen,
223
+ onClose: () => setModalOpen(false),
224
+ listed
225
+ })] });
226
+ const toolsEntry = many ? /* @__PURE__ */ jsxs(MenuItem, {
227
+ onClick: () => setModalOpen(true),
228
+ children: [
229
+ /* @__PURE__ */ jsx(ToolsIcon, { className: "h-4 w-4 shrink-0 theme-muted-text" }),
230
+ t.tools,
231
+ /* @__PURE__ */ jsx(OnCount, {
232
+ enabled,
233
+ total: listed.length
234
+ })
235
+ ]
236
+ }) : onToggleAttachments && !narrow ? /* @__PURE__ */ jsxs(Menu.SubmenuRoot, { children: [/* @__PURE__ */ jsxs(MenuSubmenuTrigger, { children: [
237
+ /* @__PURE__ */ jsx(ToolsIcon, { className: "h-4 w-4 shrink-0 theme-muted-text" }),
238
+ t.tools,
239
+ someOff && /* @__PURE__ */ jsx(OnCount, {
240
+ enabled,
241
+ total: listed.length
242
+ })
243
+ ] }), /* @__PURE__ */ jsxs(MenuPopup, {
244
+ side: "inline-end",
245
+ className: "w-72",
246
+ children: [/* @__PURE__ */ jsx(ToolsList, { listed }), connect && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MenuSeparator, {}), /* @__PURE__ */ jsx(ConnectToolsRow, { href: connect })] })]
247
+ })] }) : null;
248
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Menu.Root, { children: [/* @__PURE__ */ jsx(Menu.Trigger, {
249
+ disabled,
250
+ render: /* @__PURE__ */ jsx(PlusButton, {
251
+ open: attachmentsOpen,
252
+ label,
253
+ hint: someOff ? t.someToolsOff : void 0
254
+ })
255
+ }), /* @__PURE__ */ jsxs(MenuPopup, {
256
+ side: "top",
257
+ align: "start",
258
+ className: toolsEntry ? "w-56" : "w-72",
259
+ children: [onToggleAttachments && /* @__PURE__ */ jsxs(MenuItem, {
260
+ onClick: onToggleAttachments,
261
+ children: [/* @__PURE__ */ jsx(AttachmentIcon, { className: "h-4 w-4 shrink-0 theme-muted-text" }), t.addFiles]
262
+ }), toolsEntry ?? /* @__PURE__ */ jsxs(Fragment, { children: [
263
+ onToggleAttachments && /* @__PURE__ */ jsx(MenuSeparator, {}),
264
+ /* @__PURE__ */ jsx(ToolsList, { listed }),
265
+ connect && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MenuSeparator, {}), /* @__PURE__ */ jsx(ConnectToolsRow, { href: connect })] })
266
+ ] })]
267
+ })] }), many && /* @__PURE__ */ jsx(ToolsModal, {
268
+ open: modalOpen,
269
+ onClose: () => setModalOpen(false),
270
+ listed
271
+ })] });
272
+ }
273
+ //#endregion
274
+ export { ComposerAddMenu as default };
@@ -25,4 +25,6 @@ export declare function ComposerControls({ targetSession }: {
25
25
  /** Where the reader manages the providers behind the model picker, at the
26
26
  * foot of a conversation list. */
27
27
  export declare function ManageProvidersLink(): import("react").JSX.Element | null;
28
+ /** Where the reader connects more tools, under the composer. While the
29
+ * adapter lists tools it sits at the foot of the composer's + menu instead. */
28
30
  export declare function ConnectToolsLink(): import("react").JSX.Element | null;
@@ -1,5 +1,6 @@
1
1
  import { ConnectIcon, SettingsIcon } from "../../icons.js";
2
2
  import { useChatConfig } from "../core/config.js";
3
+ import { useHasChatTools } from "../core/ChatProvider.js";
3
4
  import AllocationSelector from "./AllocationSelector.js";
4
5
  import ModelSelector from "./ModelSelector.js";
5
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -60,9 +61,12 @@ function ManageProvidersLink() {
60
61
  })]
61
62
  });
62
63
  }
64
+ /** Where the reader connects more tools, under the composer. While the
65
+ * adapter lists tools it sits at the foot of the composer's + menu instead. */
63
66
  function ConnectToolsLink() {
64
67
  const { extraLinks, LinkComponent, strings } = useChatConfig();
65
- if (!extraLinks.connectTools) return null;
68
+ const inMenu = useHasChatTools();
69
+ if (!extraLinks.connectTools || inMenu) return null;
66
70
  return /* @__PURE__ */ jsxs(LinkComponent, {
67
71
  target: {
68
72
  kind: "external",
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { ChatAdapter } from '../adapter/types.js';
2
+ import type { ChatAdapter, ChatToolOption } from '../adapter/types.js';
3
3
  import type { AttachmentMeta, ChatModel, ChatNavigation, ChatUser, Conversation, ConversationSummary, ChatMessage as Message, MessagePart, ProviderInfo, ProviderIssue, UnreachableSession } from '../types.js';
4
4
  import { type ChatUIConfig } from './config.js';
5
5
  import { type SidebarPresentation, type SidebarState } from './sidebarState.js';
@@ -17,6 +17,9 @@ interface ChatContextValue {
17
17
  selectedProvider: string | null;
18
18
  isSelectedModelAvailable: boolean;
19
19
  selectedAllocation: string | null;
20
+ /** The host's tools the reader may turn on or off; empty when it lists none. */
21
+ tools: ChatToolOption[];
22
+ enabledToolIds: string[];
20
23
  isLoading: boolean;
21
24
  isLoadingConversation: boolean;
22
25
  isLoadingModels: boolean;
@@ -40,6 +43,7 @@ interface ChatContextValue {
40
43
  retryMessage: (messageId: string) => Promise<boolean>;
41
44
  setSelectedProvider: (providerId: string | null) => void;
42
45
  setSelectedAllocation: (allocation: string | null) => void;
46
+ setToolEnabled: (id: string, enabled: boolean) => void;
43
47
  stopStreaming: () => void;
44
48
  removeQueuedMessage: (id: string) => void;
45
49
  flushQueuedMessages: () => void;
@@ -64,6 +68,9 @@ export interface ChatStream {
64
68
  thinkingStartTime: number | null;
65
69
  }
66
70
  export declare function useChat(): ChatContextValue;
71
+ /** True while the adapter lists tools of its own, which the composer's +
72
+ * menu offers; false outside a ChatProvider. */
73
+ export declare function useHasChatTools(): boolean;
67
74
  /** The reply being streamed. A component that reads it re-renders on every
68
75
  * token, so read it only where the reply is drawn. */
69
76
  export declare function useChatStream(): ChatStream;