@parallelworks/ui 0.57.0 → 0.59.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.
Files changed (60) hide show
  1. package/README.md +60 -4
  2. package/dist/access/PrincipalPicker.js +2 -1
  3. package/dist/ai/agent/diff.d.ts +1 -0
  4. package/dist/ai/agent/diff.js +50 -3
  5. package/dist/ai/ai.css +38 -0
  6. package/dist/ai/components/AttachmentUpload.d.ts +3 -0
  7. package/dist/ai/components/AttachmentUpload.js +51 -43
  8. package/dist/ai/components/agent/AgentMessageParts.js +33 -6
  9. package/dist/ai/strings.d.ts +2 -0
  10. package/dist/ai/strings.js +2 -1
  11. package/dist/components/AppLink.js +8 -3
  12. package/dist/components/Avatar.d.ts +5 -1
  13. package/dist/components/Avatar.js +13 -5
  14. package/dist/components/ExternalLink.d.ts +3 -2
  15. package/dist/components/ExternalLink.js +5 -2
  16. package/dist/components/Field.d.ts +7 -1
  17. package/dist/components/Field.js +83 -56
  18. package/dist/components/HoverCard.js +12 -0
  19. package/dist/components/Markdown.d.ts +3 -0
  20. package/dist/components/Markdown.js +2 -1
  21. package/dist/components/MarkdownImpl.d.ts +1 -1
  22. package/dist/components/MarkdownImpl.js +5 -3
  23. package/dist/components/Provider.d.ts +16 -1
  24. package/dist/components/Provider.js +17 -3
  25. package/dist/components/StickyBar.d.ts +14 -0
  26. package/dist/components/StickyBar.js +22 -0
  27. package/dist/components/Table.d.ts +7 -5
  28. package/dist/components/Table.js +6 -2
  29. package/dist/components/Tabs.d.ts +2 -2
  30. package/dist/components/Tabs.js +5 -2
  31. package/dist/components/category.d.ts +9 -0
  32. package/dist/components/category.js +30 -0
  33. package/dist/components/httpsImages.d.ts +12 -0
  34. package/dist/components/httpsImages.js +27 -0
  35. package/dist/components/stickyTop.d.ts +14 -0
  36. package/dist/components/stickyTop.js +93 -0
  37. package/dist/file-explorer/FileExplorer.js +21 -15
  38. package/dist/graph/DependencyGraph.d.ts +14 -1
  39. package/dist/graph/DependencyGraph.js +169 -77
  40. package/dist/graph/JobSummary.js +13 -3
  41. package/dist/graph/StepSummary.d.ts +4 -0
  42. package/dist/graph/StepSummary.js +31 -0
  43. package/dist/graph/index.d.ts +1 -0
  44. package/dist/graph/index.js +2 -1
  45. package/dist/graph/types.d.ts +3 -0
  46. package/dist/graph/util.d.ts +1 -1
  47. package/dist/icons.d.ts +1 -0
  48. package/dist/icons.js +3 -2
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +3 -1
  51. package/dist/list/ActionToolbar.d.ts +2 -3
  52. package/dist/list/ActionToolbar.js +3 -3
  53. package/dist/list/index.d.ts +1 -1
  54. package/dist/list/index.js +2 -2
  55. package/dist/logviewer/LogViewer.js +3 -3
  56. package/dist/styles/theme.css +27 -0
  57. package/dist/styles.css +1 -1
  58. package/dist/theme/derive.d.ts +2 -1
  59. package/dist/theme/derive.js +45 -1
  60. package/package.json +1 -1
package/README.md CHANGED
@@ -41,6 +41,12 @@ package navigates and never re-renders anything. Define slot components and
41
41
  string functions outside a component: one made each render is a new value, and
42
42
  a new slot component remounts what it renders.
43
43
 
44
+ Under `UIProvider` the components render no inline `<style>`, so they run
45
+ under a Content Security Policy whose `style-src` is `'self'` alone; the one
46
+ rule a select or scroll area would inject ships in `theme.css`. A host that
47
+ renders on the server passes its policy's `nonce`, which the few components
48
+ that emit a script to place themselves before hydration put on it.
49
+
44
50
  ## Which component?
45
51
 
46
52
  Each need has one component. Reach for it before styling markup by hand.
@@ -60,7 +66,7 @@ Each need has one component. Reach for it before styling markup by hand.
60
66
  | A record's details, such as in a drawer or a card: names and values on their own | `DescriptionList` of `DescriptionListItem`s; `copy` on an ID, `mono` on a path, `variant="card"` to frame them; titled lists in a `DescriptionListGroup` to share one label column |
61
67
  | An error beside a control outside a `Field` | `ErrorText` |
62
68
  | A dense field in a toolbar or a table cell | any field control with `size="sm"`; a width in `className` wins, and `cx` merges classes the same way |
63
- | On or off | `Switch`; `Checkbox` in a list of things to tick, several of which may be on |
69
+ | On or off | `Switch`; `Checkbox` in a list of things to tick, several of which may be on; either in a `Field` with `controlFirst` to read control, label, then its help and description |
64
70
  | One of a few choices, all shown | `RadioGroup` in a form; `SegmentedControl` in a toolbar, or as a field's control in a `Field group` |
65
71
  | Narrowing a list by values, such as statuses: several at once, or one or none | `ToggleGroup` |
66
72
  | Narrowing what is shown by typing | `SearchInput`; `ListSearchControl` on a list page's toolbar |
@@ -72,6 +78,7 @@ Each need has one component. Reach for it before styling markup by hand.
72
78
  | A titled part of a page or form | `Section` |
73
79
  | More detail under a line | `Disclosure` |
74
80
  | Views of one thing | `Tabs` |
81
+ | Chrome that stays in view as the page scrolls | `sticky` on `ActionToolbar` or `Tabs`; `StickyBar` around controls of your own. They stack under the page header by themselves (see [Host chrome](#host-chrome)) |
75
82
  | A list of actions | `ActionMenu`; `useContextMenu` for a right-click or a row's menu |
76
83
  | A panel of controls anchored to a trigger | `Popover` |
77
84
  | A hint for a control | `Tooltip`; a `Field`'s `help` for more about a field, which its control reads and which opens as Tab reaches it; `InfoTip` for help about anything else |
@@ -84,7 +91,7 @@ Each need has one component. Reach for it before styling markup by hand.
84
91
  | How full something is, how far a task has come | `Meter`, `ProgressBar` |
85
92
  | Pages of a long list | `Pagination` |
86
93
  | A grouped page of settings | `SettingsCard` with `SettingRow`s |
87
- | A person | `Avatar` |
94
+ | A person | `Avatar`, colored by name without a picture |
88
95
  | A status in words | `StatusBadge`, or `StatusDot` beside text that says it |
89
96
  | A run's, job's or step's status beside its name | `RunStatusIcon`; `StatusBadge` with `runStatusTone` to say it in words |
90
97
  | A copy action | `CopyButton`; `CopyCodeBlock` for a command or snippet; `useCopy` for a menu row or a control of your own |
@@ -312,6 +319,16 @@ icon beside an error message may keep the message's text shade through
312
319
  from the accent's hue when they come within 30° of it, so a keyword never looks
313
320
  like a link.
314
321
 
322
+ Eight category colors tell things apart without ranking them:
323
+ `--theme-category-1` to `--theme-category-8` are soft fills at hues 45° apart,
324
+ all at one lightness and chroma so none reads louder, and each
325
+ `--theme-category-N-text` is solved to secondary text's targets on its fill. An
326
+ `Avatar` without a picture takes one, picked by an FNV-1a hash of its name (or
327
+ its `colorKey`), so the same name has the same color on every page; a nameless
328
+ one keeps the accent. `categoryOf(key)` gives the same pick, 1–8, and
329
+ `categoryClasses(key)` its fill and text classes, for anything else drawn
330
+ beside a name, such as a group's icon.
331
+
315
332
  Every color is mapped into sRGB with the CSS Color 4 method, which lowers
316
333
  chroma at fixed lightness and hue. It is mapped into display-p3 too: where the
317
334
  wider color differs from sRGB by more than one just-noticeable difference
@@ -343,9 +360,20 @@ leaves unset follows the `color-scheme` it sets:
343
360
  ```css
344
361
  /* In a Tailwind v4 build: */
345
362
  @import '@parallelworks/ui/theme.css';
346
- @source '../node_modules/@parallelworks/ui/src/**/*.{ts,tsx}';
363
+ @source '../node_modules/@parallelworks/ui/dist/components';
364
+ @source '../node_modules/@parallelworks/ui/dist/list';
347
365
  ```
348
366
 
367
+ The package publishes only `dist`, built one JavaScript module per source
368
+ module under the same folders as its subpaths (`dist/components`, `dist/list`,
369
+ `dist/form`, `dist/ai` and so on), so `@source` points at those modules. List
370
+ the folders of the subpaths the host imports: each one adds the utilities of
371
+ every component in it. Scanning all of `dist` works too, but also reads the
372
+ editor's bundled workers in `dist/assets`. A host that wants only a few
373
+ components can name their modules one by one, such as
374
+ `dist/components/Avatar.js`, as long as it also names the modules they style
375
+ through, such as `dist/components/floating.js` for a popup's panel.
376
+
349
377
  Consumers without a Tailwind build can import the compiled standalone
350
378
  stylesheet instead:
351
379
 
@@ -566,13 +594,41 @@ root:
566
594
  | ------------------- | ----------------------------------------------------------------------- |
567
595
  | `--app-inset-top` | The height of a fixed top bar. Drawers and toasts start below it. |
568
596
  | `--app-inset-start` | The width of a fixed sidebar on the start side. Toasts center over the page beside it, and a drawer at its widest leaves that strip, and never less than 2.5rem. Update it when the sidebar collapses. |
569
- | `--table-header-top` | How far below the top of its scrolling box a table's sticky header row stops (`Table stickyHeader`, which list tables use). Set it on the scrolling box, or an ancestor, when something else sticks at that box's top, such as a page header in a page that scrolls as a whole. Inside `ListPage` the rows scroll in their own box below the header and toolbar, so it stays 0. |
570
597
 
571
598
  ```ts
572
599
  document.documentElement.style.setProperty('--app-inset-top', '56px')
573
600
  document.documentElement.style.setProperty('--app-inset-start', '220px')
574
601
  ```
575
602
 
603
+ Sticky chrome stacks by itself, with no offsets to set. A page's pinned pieces,
604
+ `ActionToolbar sticky`, `Tabs sticky`, a `StickyBar` around controls of the
605
+ page's own, and a table's header row (`Table stickyHeader`, which list tables
606
+ use), each stop just below whatever their scrolling box already pins at the top.
607
+ Each finds the elements there with `position: sticky` and a `top` whose parent
608
+ also holds it, so they stay pinned while it passes, and follows them as they
609
+ wrap to a new height or come and go, before the page paints. In a box that
610
+ scrolls on its own, such as a fixed-height panel or `ListPage`'s rows, they pin
611
+ at the box's top.
612
+
613
+ The stack starts from the host's page header. Put it inside the page's
614
+ scroller, ahead of the page, with `position: sticky` and a `top`, such as
615
+ `sticky top-0`: as a child of the scroller or of a wrapper the page also sits
616
+ in. A header above the scroller, outside it, needs nothing: the page's chrome
617
+ pins at the scroller's top, just under it.
618
+
619
+ ```tsx
620
+ <div className="overflow-auto">
621
+ <header className="sticky top-0 z-(--layer-header)">…</header>
622
+ <main>
623
+ <StickyBar className="px-4 py-1.5">
624
+ <ActionToolbar groups={actions} />
625
+ </StickyBar>
626
+ <Tabs label="Cluster" sticky items={tabs} />
627
+ <Table stickyHeader>…</Table>
628
+ </main>
629
+ </div>
630
+ ```
631
+
576
632
  Everything the package lays over the page stacks on one scale, set on the
577
633
  document root by `base.css`. Give the host's own fixed elements a place on it,
578
634
  such as `z-index: 100` for a top bar, rather than a z-index chosen to win:
@@ -1,6 +1,7 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { OrganizationIcon, RefreshIcon, UsersIcon } from "../icons.js";
3
3
  import { useQueryMatch } from "../components/search.js";
4
+ import { categoryClasses } from "../components/category.js";
4
5
  import { Avatar } from "../components/Avatar.js";
5
6
  import { Callout } from "../components/Callout.js";
6
7
  import { Combobox } from "../components/Combobox.js";
@@ -35,7 +36,7 @@ function PrincipalTile({ principal, compact = false, className }) {
35
36
  const Icon = everyone ? OrganizationIcon : UsersIcon;
36
37
  return /* @__PURE__ */ jsx("span", {
37
38
  "aria-hidden": "true",
38
- className: cx("flex size-8 shrink-0 items-center justify-center rounded-md [&_svg]:size-4", compact && "size-6 [&_svg]:size-3.5", everyone ? "bg-(--theme-info-muted) text-(--theme-info-text)" : "bg-(--theme-muted-panel-bg) text-(--theme-muted-text-color)", className),
39
+ className: cx("flex size-8 shrink-0 items-center justify-center rounded-md [&_svg]:size-4", compact && "size-6 [&_svg]:size-3.5", everyone ? "bg-(--theme-info-muted) text-(--theme-info-text)" : categoryClasses(principal.name), className),
39
40
  children: /* @__PURE__ */ jsx(Icon, {})
40
41
  });
41
42
  }
@@ -10,6 +10,7 @@ export interface EditToolArgs {
10
10
  }
11
11
  export declare function isEditDiffTool(name: string): boolean;
12
12
  export declare function parseEditArgs(argsJSON: string): EditToolArgs | null;
13
+ export declare const MAX_DIFF_CELLS = 1000000;
13
14
  export declare function wordEmphasisRanges(oldLine: string, newLine: string): Array<[number, number]> | null;
14
15
  export declare function lineDelta(oldLines: string[], newLines: string[]): [number, number];
15
16
  export interface DiffRow {
@@ -19,7 +19,51 @@ function splitLines(s) {
19
19
  const trimmed = s.endsWith("\n") ? s.slice(0, -1) : s;
20
20
  return trimmed === "" ? [] : trimmed.split("\n");
21
21
  }
22
+ var MAX_DIFF_CELLS = 1e6;
22
23
  function getOpCodes(a, b) {
24
+ if (a.length * b.length <= 1e6) return lcsOpCodes(a, b);
25
+ let start = 0;
26
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
27
+ let endA = a.length;
28
+ let endB = b.length;
29
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
30
+ endA--;
31
+ endB--;
32
+ }
33
+ const midA = a.slice(start, endA);
34
+ const midB = b.slice(start, endB);
35
+ const middle = midA.length * midB.length <= 1e6 ? lcsOpCodes(midA, midB) : [{
36
+ tag: "r",
37
+ i1: 0,
38
+ i2: midA.length,
39
+ j1: 0,
40
+ j2: midB.length
41
+ }];
42
+ const ops = [];
43
+ if (start > 0) ops.push({
44
+ tag: "e",
45
+ i1: 0,
46
+ i2: start,
47
+ j1: 0,
48
+ j2: start
49
+ });
50
+ for (const op of middle) ops.push({
51
+ tag: op.tag,
52
+ i1: op.i1 + start,
53
+ i2: op.i2 + start,
54
+ j1: op.j1 + start,
55
+ j2: op.j2 + start
56
+ });
57
+ if (endA < a.length) ops.push({
58
+ tag: "e",
59
+ i1: endA,
60
+ i2: a.length,
61
+ j1: endB,
62
+ j2: b.length
63
+ });
64
+ return ops;
65
+ }
66
+ function lcsOpCodes(a, b) {
23
67
  const n = a.length;
24
68
  const m = b.length;
25
69
  const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
@@ -106,9 +150,12 @@ function wordEmphasisRanges(oldLine, newLine) {
106
150
  return ranges.length > 0 ? ranges : null;
107
151
  }
108
152
  function lineDelta(oldLines, newLines) {
153
+ return countLines(getOpCodes(oldLines, newLines));
154
+ }
155
+ function countLines(ops) {
109
156
  let added = 0;
110
157
  let removed = 0;
111
- for (const op of getOpCodes(oldLines, newLines)) {
158
+ for (const op of ops) {
112
159
  if (op.tag === "d" || op.tag === "r") removed += op.i2 - op.i1;
113
160
  if (op.tag === "i" || op.tag === "r") added += op.j2 - op.j1;
114
161
  }
@@ -117,8 +164,8 @@ function lineDelta(oldLines, newLines) {
117
164
  function computeEditDiff(oldString, newString, startLine = 0) {
118
165
  const oldLines = splitLines(oldString);
119
166
  const newLines = splitLines(newString);
120
- const [added, removed] = lineDelta(oldLines, newLines);
121
167
  const ops = getOpCodes(oldLines, newLines);
168
+ const [added, removed] = countLines(ops);
122
169
  const numbered = startLine > 0;
123
170
  const rows = [];
124
171
  let truncated = false;
@@ -213,4 +260,4 @@ function editSummary(toolName, argsJSON, t = defaultToolStrings) {
213
260
  return "";
214
261
  }
215
262
  //#endregion
216
- export { MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, computeEditDiff, computeWriteRows, editSummary, isEditDiffTool, lineDelta, parseEditArgs, wordEmphasisRanges };
263
+ export { MAX_DIFF_CELLS, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, computeEditDiff, computeWriteRows, editSummary, isEditDiffTool, lineDelta, parseEditArgs, wordEmphasisRanges };
package/dist/ai/ai.css CHANGED
@@ -182,6 +182,44 @@
182
182
  .markdown [data-streamdown="table"] tbody tr:last-child {
183
183
  border-bottom: 0;
184
184
  }
185
+ /* Markdown set inside other UI, such as a step's summary, reads at the page's
186
+ * text size, its headings a step above it rather than at a document's sizes. */
187
+ .markdown-compact {
188
+ font-size: 0.875rem;
189
+ line-height: 1.5;
190
+ }
191
+ .markdown-compact :is(h1, h2, h3, h4, h5, h6) {
192
+ font-size: 0.875rem;
193
+ line-height: 1.25rem;
194
+ font-weight: 600;
195
+ }
196
+ .markdown-compact :is(h1, h2) {
197
+ font-size: 1rem;
198
+ line-height: 1.5rem;
199
+ }
200
+ .markdown-compact [data-streamdown="table-wrapper"] table,
201
+ .markdown-compact [data-streamdown="table"] :is(th, td) {
202
+ font-size: 0.875rem;
203
+ line-height: 1.25rem;
204
+ }
205
+ /* A summary's tables are mostly a few short columns of counts: they hug their
206
+ * content so each number sits by its label, and a phone fits them unscrolled. */
207
+ .markdown-compact table[data-streamdown="table"] {
208
+ width: auto;
209
+ min-width: min(100%, 20rem);
210
+ }
211
+ .markdown-compact [data-streamdown="table"] :is(th, td) {
212
+ min-width: 0;
213
+ padding-inline-end: 2rem;
214
+ font-variant-numeric: tabular-nums;
215
+ }
216
+ .markdown-compact [data-streamdown="table"] th {
217
+ font-weight: 500;
218
+ color: var(--theme-muted-text-color);
219
+ }
220
+ .markdown-compact :not(pre) > code {
221
+ font-size: 0.8125rem;
222
+ }
185
223
  @media (prefers-reduced-motion: reduce) {
186
224
  .markdown [data-streamdown="table-wrapper"] > div:first-child {
187
225
  transition: none;
@@ -16,6 +16,9 @@ export interface Attachment {
16
16
  interface AttachmentUploadProps {
17
17
  conversationId?: string | undefined;
18
18
  attachments: Attachment[];
19
+ /** The list with a change. Keep its entries as given: an upload's result
20
+ * replaces its pending entry by identity, on the list as it is when the
21
+ * upload finishes. */
19
22
  onAttachmentsChange: (attachments: Attachment[]) => void;
20
23
  disabled?: boolean;
21
24
  maxFiles?: number;
@@ -7,7 +7,7 @@ import { useChatConfig } from "../core/config.js";
7
7
  import { useChat } from "../core/ChatProvider.js";
8
8
  import { ATTACHMENTS_INFINITE_KEY } from "./attachmentKeys.js";
9
9
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
- import { useCallback, useImperativeHandle, useRef, useState } from "react";
10
+ import { useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
11
11
  import { mutate } from "swr";
12
12
  //#region src/ai/components/AttachmentUpload.tsx
13
13
  var DEFAULT_MAX_FILE_SIZE = 26214400;
@@ -24,18 +24,23 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
24
24
  const { common } = useStrings();
25
25
  const [isDragging, setIsDragging] = useState(false);
26
26
  const fileInputRef = useRef(null);
27
- const validateFile = useCallback((file) => {
28
- if (file.size > maxFileSize) return t.fileTooLarge(formatBytes(maxFileSize));
29
- if (attachments.length >= maxFiles) return t.maxFilesAllowed(maxFiles);
30
- return null;
31
- }, [
32
- attachments.length,
33
- maxFileSize,
34
- maxFiles,
35
- t
36
- ]);
27
+ const latest = useRef(attachments);
28
+ useLayoutEffect(() => {
29
+ latest.current = attachments;
30
+ }, [attachments]);
31
+ const commit = useCallback((next) => {
32
+ latest.current = next;
33
+ onAttachmentsChange(next);
34
+ }, [onAttachmentsChange]);
37
35
  const uploadFile = useCallback(async (file) => {
38
- if (!adapter.attachments) return null;
36
+ const failed = (error) => ({
37
+ file,
38
+ filename: file.name,
39
+ mimeType: file.type,
40
+ size: file.size,
41
+ error
42
+ });
43
+ if (!adapter.attachments) return failed(t.uploadUnavailable);
39
44
  try {
40
45
  const uploaded = await adapter.attachments.upload(file, conversationId);
41
46
  await mutate(ATTACHMENTS_INFINITE_KEY);
@@ -47,44 +52,47 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
47
52
  uploaded: true
48
53
  };
49
54
  } catch (error) {
50
- return {
51
- file,
52
- filename: file.name,
53
- mimeType: file.type,
54
- size: file.size,
55
- error: error instanceof Error ? error.message : String(error)
56
- };
55
+ return failed(error instanceof Error ? error.message : String(error));
57
56
  }
58
- }, [adapter, conversationId]);
57
+ }, [
58
+ adapter,
59
+ conversationId,
60
+ t
61
+ ]);
59
62
  const handleFiles = useCallback(async (files) => {
60
- const fileArray = Array.from(files);
61
- const newAttachments = [];
62
- for (const file of fileArray) {
63
- const error = validateFile(file);
64
- if (error) {
65
- toast.error(error);
63
+ const pending = [];
64
+ let room = maxFiles - latest.current.length;
65
+ let overLimit = false;
66
+ for (const file of Array.from(files)) {
67
+ if (file.size > maxFileSize) {
68
+ toast.error(t.fileTooLarge(formatBytes(maxFileSize)));
66
69
  continue;
67
70
  }
68
- const pendingAttachment = {
71
+ if (room <= 0) {
72
+ overLimit = true;
73
+ continue;
74
+ }
75
+ room--;
76
+ pending.push({
69
77
  file,
70
78
  filename: file.name,
71
79
  mimeType: file.type,
72
80
  size: file.size,
73
81
  uploading: true
74
- };
75
- newAttachments.push(pendingAttachment);
82
+ });
76
83
  }
77
- onAttachmentsChange([...attachments, ...newAttachments]);
78
- const uploaded = await Promise.all(newAttachments.map(async (a) => {
79
- if (a.file) return uploadFile(a.file);
80
- return a;
81
- }));
82
- onAttachmentsChange(attachments.concat(uploaded.filter((a) => a !== null)));
84
+ if (overLimit) toast.error(t.maxFilesAllowed(maxFiles));
85
+ if (pending.length === 0) return;
86
+ commit([...latest.current, ...pending]);
87
+ const results = await Promise.all(pending.map((a) => uploadFile(a.file)));
88
+ const resultOf = new Map(pending.map((a, i) => [a, results[i]]));
89
+ commit(latest.current.map((a) => resultOf.get(a) ?? a));
83
90
  }, [
84
- attachments,
85
- onAttachmentsChange,
86
- uploadFile,
87
- validateFile
91
+ commit,
92
+ maxFileSize,
93
+ maxFiles,
94
+ t,
95
+ uploadFile
88
96
  ]);
89
97
  const handleDragOver = useCallback((e) => {
90
98
  e.preventDefault();
@@ -103,9 +111,9 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
103
111
  if (e.target.files && e.target.files.length > 0) handleFiles(e.target.files);
104
112
  e.target.value = "";
105
113
  }, [handleFiles]);
106
- const handleRemove = useCallback((index) => {
107
- onAttachmentsChange(attachments.filter((_, i) => i !== index));
108
- }, [attachments, onAttachmentsChange]);
114
+ const handleRemove = useCallback((attachment) => {
115
+ commit(latest.current.filter((a) => a !== attachment));
116
+ }, [commit]);
109
117
  useImperativeHandle(ref, () => ({ addFiles: handleFiles }), [handleFiles]);
110
118
  const handleClick = () => {
111
119
  fileInputRef.current?.click();
@@ -148,7 +156,7 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
148
156
  }),
149
157
  /* @__PURE__ */ jsx("button", {
150
158
  type: "button",
151
- onClick: () => handleRemove(index),
159
+ onClick: () => handleRemove(attachment),
152
160
  className: "p-0.5 rounded hover:bg-(--theme-error-muted)",
153
161
  title: common.remove,
154
162
  disabled,
@@ -92,10 +92,30 @@ function DiffRows({ rows, lang }) {
92
92
  function EditDiffBlock({ part }) {
93
93
  const { strings } = useChatConfig();
94
94
  const t = strings.agentTranscript;
95
- const a = parseEditArgs(part.args);
96
- if (!a) return null;
97
- const outcome = editSummary(part.name, part.args, strings.tools);
98
- const model = part.name === "EditFile" ? computeEditDiff(a.old_string ?? "", a.new_string ?? "", part.startLine) : computeWriteRows(a.content ?? "");
95
+ const diff = useMemo(() => {
96
+ const a = parseEditArgs(part.args);
97
+ if (!a) return null;
98
+ if (part.name === "EditFile") {
99
+ const model = computeEditDiff(a.old_string ?? "", a.new_string ?? "", part.startLine);
100
+ return {
101
+ a,
102
+ model,
103
+ outcome: strings.tools.edited(model.added, model.removed)
104
+ };
105
+ }
106
+ return {
107
+ a,
108
+ model: computeWriteRows(a.content ?? ""),
109
+ outcome: editSummary(part.name, part.args, strings.tools)
110
+ };
111
+ }, [
112
+ part.name,
113
+ part.args,
114
+ part.startLine,
115
+ strings.tools
116
+ ]);
117
+ if (!diff) return null;
118
+ const { a, model, outcome } = diff;
99
119
  return /* @__PURE__ */ jsxs("div", {
100
120
  className: "mt-1.5 space-y-1",
101
121
  children: [
@@ -210,10 +230,17 @@ function ToolCallBlock({ part }) {
210
230
  const header = toolHeaderFor(part.name);
211
231
  const argSummary = summarizeToolArgs(header.arg, part.args);
212
232
  const isDiff = isEditDiffTool(part.name);
213
- const summary = part.status === "running" ? t.running : toolSummary(part.name, part.args, part.result ?? "", part.status === "error", {
233
+ const summary = useMemo(() => part.status === "running" ? t.running : toolSummary(part.name, part.args, part.result ?? "", part.status === "error", {
214
234
  editSummary,
215
235
  strings: strings.tools
216
- });
236
+ }), [
237
+ part.status,
238
+ part.name,
239
+ part.args,
240
+ part.result,
241
+ t.running,
242
+ strings.tools
243
+ ]);
217
244
  return /* @__PURE__ */ jsxs("div", {
218
245
  className: "py-1.5 text-sm",
219
246
  "data-testid": "tool-call-part",
@@ -139,6 +139,8 @@ export interface ChatStrings {
139
139
  addFiles: string;
140
140
  maxSizePerFile: (size: string) => string;
141
141
  uploading: string;
142
+ /** On a file added where the chat has nowhere to upload it. */
143
+ uploadUnavailable: string;
142
144
  };
143
145
  attachmentManager: {
144
146
  title: string;
@@ -174,7 +174,8 @@ var defaultChatStrings = {
174
174
  releaseToUpload: "Release to upload",
175
175
  addFiles: "Add files (drag or click)",
176
176
  maxSizePerFile: (size) => `Max ${size} per file`,
177
- uploading: "Uploading…"
177
+ uploading: "Uploading…",
178
+ uploadUnavailable: "Attachments can’t be uploaded here"
178
179
  },
179
180
  attachmentManager: {
180
181
  title: "Attachments",
@@ -3,9 +3,14 @@ import { useLink, useStrings } from "./Provider.js";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/components/AppLink.tsx
5
5
  function leavesApp(url) {
6
- if (!/^([a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return false;
7
- if (typeof window === "undefined") return true;
8
- return new URL(url, window.location.href).origin !== window.location.origin;
6
+ if (typeof window === "undefined") return /^([a-z][a-z\d+.-]*:|[/\\]{2})/i.test(url.replace(/[\t\n\r]/g, "").trimStart());
7
+ const { origin, protocol } = window.location;
8
+ try {
9
+ const resolved = new URL(url, window.location.href);
10
+ return resolved.origin !== origin || resolved.protocol !== protocol;
11
+ } catch {
12
+ return true;
13
+ }
9
14
  }
10
15
  /** True for a click the browser would open in place: the primary button with
11
16
  * no modifier. A link's own onClick work, such as closing a drawer or clearing
@@ -4,6 +4,10 @@ export interface AvatarProps {
4
4
  /** Image URL. Until it loads, or when it fails, the initials of `name` show. */
5
5
  src?: string | null | undefined;
6
6
  name?: string | undefined;
7
+ /** Picks the fallback's color from the theme's categorical palette, so the same key gets the
8
+ * same color everywhere. Defaults to `name`; pass a stable id when names can repeat or
9
+ * change, or `null` for the accent fill whatever the name. Without either, the accent fills it. */
10
+ colorKey?: string | null | undefined;
7
11
  size?: AvatarSize | undefined;
8
12
  status?: AvatarStatus | undefined;
9
13
  className?: string | undefined;
@@ -13,4 +17,4 @@ export declare function avatarInitials(name: string | undefined): string;
13
17
  /** A person's picture, or their initials while it loads or when it fails. It is
14
18
  * one image to assistive technology, named for the person, whichever shows,
15
19
  * and their presence when it has a status dot. */
16
- export declare function Avatar({ src, name, size, status, className }: AvatarProps): import("react").JSX.Element;
20
+ export declare function Avatar({ src, name, colorKey, size, status, className }: AvatarProps): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { useStrings } from "./Provider.js";
3
+ import { categoryClasses } from "./category.js";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  import { Avatar } from "@base-ui/react/avatar";
5
6
  //#region src/components/Avatar.tsx
@@ -17,20 +18,27 @@ var STATUS_CLASSES = {
17
18
  busy: "bg-(--theme-error)",
18
19
  away: "bg-(--theme-warning)"
19
20
  };
21
+ var graphemes = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : void 0;
22
+ function firstCharacter(text) {
23
+ if (!text) return;
24
+ if (graphemes) return graphemes.segment(text)[Symbol.iterator]().next().value?.segment;
25
+ return Array.from(text)[0];
26
+ }
20
27
  /** Up to two initials: the first and last word of a name split on spaces and dots. */
21
28
  function avatarInitials(name) {
22
29
  const parts = (name ?? "").split(/[\s.]+/).filter(Boolean);
23
- const first = parts[0]?.charAt(0);
24
- const last = parts.at(-1)?.charAt(0);
30
+ const first = firstCharacter(parts[0]);
31
+ const last = firstCharacter(parts.at(-1));
25
32
  if (parts.length >= 2 && first && last) return (first + last).toUpperCase();
26
33
  return first?.toUpperCase() || "?";
27
34
  }
28
35
  /** A person's picture, or their initials while it loads or when it fails. It is
29
36
  * one image to assistive technology, named for the person, whichever shows,
30
37
  * and their presence when it has a status dot. */
31
- function Avatar$1({ src, name, size = "md", status, className }) {
38
+ function Avatar$1({ src, name, colorKey, size = "md", status, className }) {
32
39
  const { common } = useStrings();
33
40
  const who = name || common.userAvatar;
41
+ const colorBy = colorKey === void 0 ? name?.trim() : colorKey;
34
42
  return /* @__PURE__ */ jsxs("span", {
35
43
  role: "img",
36
44
  "aria-label": status ? common.withPresence(who, common.presence[status]) : who,
@@ -45,8 +53,8 @@ function Avatar$1({ src, name, size = "md", status, className }) {
45
53
  className: "absolute inset-0 size-full object-cover data-error:hidden data-loading:invisible"
46
54
  }, src), /* @__PURE__ */ jsx(Avatar.Fallback, {
47
55
  "aria-hidden": "true",
48
- className: "flex size-full items-center justify-center bg-gradient-to-br from-[var(--gradient-start,var(--theme-accent))] to-[var(--gradient-mid,var(--theme-link))] text-(--theme-accent-text)",
49
- children: size === "xs" ? avatarInitials(name).charAt(0) : avatarInitials(name)
56
+ className: cx("flex size-full items-center justify-center", colorBy ? categoryClasses(colorBy) : "bg-gradient-to-br from-[var(--gradient-start,var(--theme-accent))] to-[var(--gradient-mid,var(--theme-link))] text-(--theme-accent-text)"),
57
+ children: size === "xs" ? firstCharacter(avatarInitials(name)) : avatarInitials(name)
50
58
  })]
51
59
  }), status && /* @__PURE__ */ jsx("span", {
52
60
  "data-testid": "online-indicator",
@@ -4,5 +4,6 @@ export type ExternalLinkProps = Omit<ComponentPropsWithRef<'a'>, 'target' | 'rel
4
4
  icon?: boolean;
5
5
  };
6
6
  /** A link that opens in a new tab, says so to screen readers, and gives the
7
- * page it opens no hold on this one. */
8
- export declare function ExternalLink({ children, icon, className, ...props }: ExternalLinkProps): import("react").JSX.Element;
7
+ * page it opens no hold on this one. Its href passes `safeUrl`, as every
8
+ * component's does, and one that doesn't is dropped. */
9
+ export declare function ExternalLink({ href, children, icon, className, ...props }: ExternalLinkProps): import("react").JSX.Element;
@@ -1,14 +1,17 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { ExternalLinkIcon } from "../icons.js";
3
+ import { safeUrl } from "../safeUrl.js";
3
4
  import { useStrings } from "./Provider.js";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
6
  //#region src/components/ExternalLink.tsx
6
7
  /** A link that opens in a new tab, says so to screen readers, and gives the
7
- * page it opens no hold on this one. */
8
- function ExternalLink({ children, icon = false, className, ...props }) {
8
+ * page it opens no hold on this one. Its href passes `safeUrl`, as every
9
+ * component's does, and one that doesn't is dropped. */
10
+ function ExternalLink({ href, children, icon = false, className, ...props }) {
9
11
  const { common } = useStrings();
10
12
  return /* @__PURE__ */ jsxs("a", {
11
13
  ...props,
14
+ href: safeUrl(href),
12
15
  target: "_blank",
13
16
  rel: "noopener noreferrer",
14
17
  className: cx(icon && "inline-flex items-center gap-1", className),
@@ -59,6 +59,12 @@ export interface FieldProps {
59
59
  size?: FieldBoxSize | undefined;
60
60
  /** A set of controls, such as radios or checkboxes, that a legend names instead of a label. */
61
61
  group?: boolean | undefined;
62
+ /** Puts a checkbox, a switch or a lone radio before its label, as such a control reads: the
63
+ * control, then its label and ?, with the description and error under the label. A click on
64
+ * the label sets the control. Inline, or in a row of fields, it sits in the control column,
65
+ * its first line on the line of the labels beside it. Not for a `group`, whose options are
66
+ * each a `FieldOption`. */
67
+ controlFirst?: boolean | undefined;
62
68
  /** The control's id; one is made up when left out. */
63
69
  id?: string | undefined;
64
70
  className?: string | undefined;
@@ -77,7 +83,7 @@ export declare function ErrorText({ id, children }: {
77
83
  }): import("react").JSX.Element | null;
78
84
  /** A control with its label, help and error, tied to it for assistive technology. One frame
79
85
  * for every field, in a form or standing alone. */
80
- export declare function Field({ label, hint, help, actions, description, error, invalid, required, disabled, layout, compact, size, group, id: ownId, className, children, }: FieldProps): import("react").JSX.Element;
86
+ export declare function Field({ label, hint, help, actions, description, error, invalid, required, disabled, layout, compact, size, group, controlFirst, id: ownId, className, children, }: FieldProps): import("react").JSX.Element;
81
87
  /** One checkbox or radio and its label inside a Field, such as an option of a group. A bare
82
88
  * `<label>` there would take the Field's label as its own, so every option read as the field. */
83
89
  export declare function FieldOption({ disabled, className, children, }: {