@slipstream-ui/ai 0.3.0 → 0.5.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 (34) hide show
  1. package/dist/index.d.mts +33 -1
  2. package/dist/index.d.ts +33 -1
  3. package/dist/index.js +236 -110
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +238 -112
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/manifests/attachment.manifest.json +2 -0
  8. package/dist/manifests/branch.manifest.json +2 -0
  9. package/dist/manifests/code-block.manifest.json +2 -0
  10. package/dist/manifests/command-palette.manifest.json +37 -3
  11. package/dist/manifests/confirmation.manifest.json +2 -0
  12. package/dist/manifests/inline-citation.manifest.json +2 -0
  13. package/dist/manifests/message-actions.manifest.json +3 -0
  14. package/dist/manifests/reasoning.manifest.json +6 -0
  15. package/dist/manifests/response.manifest.json +2 -0
  16. package/dist/manifests/tool-call.manifest.json +6 -0
  17. package/package.json +3 -3
  18. package/src/components/code-block/CodeBlock.tsx +91 -5
  19. package/src/components/command-palette/CommandPalette.test.tsx +21 -0
  20. package/src/components/command-palette/CommandPalette.tsx +71 -6
  21. package/src/components/confirmation/Confirmation.tsx +1 -1
  22. package/src/components/conversation/Conversation.tsx +6 -1
  23. package/src/components/reasoning/Reasoning.tsx +13 -6
  24. package/src/components/tool-call/ToolCall.tsx +24 -8
  25. package/src/manifests/attachment.manifest.json +2 -0
  26. package/src/manifests/branch.manifest.json +2 -0
  27. package/src/manifests/code-block.manifest.json +2 -0
  28. package/src/manifests/command-palette.manifest.json +37 -3
  29. package/src/manifests/confirmation.manifest.json +2 -0
  30. package/src/manifests/inline-citation.manifest.json +2 -0
  31. package/src/manifests/message-actions.manifest.json +3 -0
  32. package/src/manifests/reasoning.manifest.json +6 -0
  33. package/src/manifests/response.manifest.json +2 -0
  34. package/src/manifests/tool-call.manifest.json +6 -0
@@ -27,6 +27,8 @@
27
27
  { "feature": "Upload status", "implementation": "Uploading state is announced via role=status." },
28
28
  { "feature": "Decorative thumbnails", "implementation": "Preview images are aria-hidden; the file name carries the meaning." }
29
29
  ],
30
+ "sourceDir": "attachment",
31
+ "exportName": "Attachment",
30
32
  "package": "@slipstream-ui/ai",
31
33
  "importPath": "@slipstream-ui/ai",
32
34
  "version": "0.0.1"
@@ -59,6 +59,8 @@
59
59
  "implementation": "N/M indicator uses aria-live=polite."
60
60
  }
61
61
  ],
62
+ "sourceDir": "branch",
63
+ "exportName": "Branch",
62
64
  "package": "@slipstream-ui/ai",
63
65
  "importPath": "@slipstream-ui/ai",
64
66
  "version": "0.0.1"
@@ -44,6 +44,8 @@
44
44
  "implementation": "Icon-labeled button with aria-label that flips to Copied."
45
45
  }
46
46
  ],
47
+ "sourceDir": "code-block",
48
+ "exportName": "CodeBlock",
47
49
  "package": "@slipstream-ui/ai",
48
50
  "importPath": "@slipstream-ui/ai",
49
51
  "version": "0.0.1"
@@ -22,7 +22,13 @@
22
22
  "type": "Command[]",
23
23
  "required": true,
24
24
  "description": "Available commands: { id, label, section?, keywords?, shortcut? }",
25
- "examples": [{ "id": "go-docs", "label": "Open documentation", "section": "Navigation" }]
25
+ "examples": [
26
+ {
27
+ "id": "go-docs",
28
+ "label": "Open documentation",
29
+ "section": "Navigation"
30
+ }
31
+ ]
26
32
  },
27
33
  {
28
34
  "name": "onSelect",
@@ -33,9 +39,34 @@
33
39
  {
34
40
  "name": "placeholder",
35
41
  "type": "string",
36
- "defaultValue": "Type a command…",
42
+ "defaultValue": "Type a command.",
37
43
  "required": false,
38
44
  "description": "Input placeholder text"
45
+ },
46
+ {
47
+ "name": "loadCommands",
48
+ "type": "(query: string) => Promise<Command[]>",
49
+ "required": false,
50
+ "description": "Async command source; debounced and stale-response guarded."
51
+ },
52
+ {
53
+ "name": "debounceMs",
54
+ "type": "number",
55
+ "required": false,
56
+ "defaultValue": "200",
57
+ "description": "Debounce for loadCommands in ms."
58
+ },
59
+ {
60
+ "name": "renderCommand",
61
+ "type": "(command, state: { active: boolean }) => ReactNode",
62
+ "required": false,
63
+ "description": "Custom row renderer (icons, type badges)."
64
+ },
65
+ {
66
+ "name": "loadingText",
67
+ "type": "string",
68
+ "required": false,
69
+ "description": "Shown while async commands load."
39
70
  }
40
71
  ],
41
72
  "examples": [
@@ -71,7 +102,10 @@
71
102
  "relationship": "CommandPalette switches modes/actions; PromptInput captures free-form chat text."
72
103
  }
73
104
  ],
74
- "relatedComponents": ["PromptInput", "Dialog"],
105
+ "relatedComponents": [
106
+ "PromptInput",
107
+ "Dialog"
108
+ ],
75
109
  "package": "@slipstream-ui/ai",
76
110
  "importPath": "@slipstream-ui/ai",
77
111
  "version": "0.0.1",
@@ -137,6 +137,8 @@
137
137
  "implementation": "Status chip text announces Approved/Rejected."
138
138
  }
139
139
  ],
140
+ "sourceDir": "confirmation",
141
+ "exportName": "Confirmation",
140
142
  "package": "@slipstream-ui/ai",
141
143
  "importPath": "@slipstream-ui/ai",
142
144
  "version": "0.0.1"
@@ -40,6 +40,8 @@
40
40
  "implementation": "Card has role=tooltip linked via aria-describedby."
41
41
  }
42
42
  ],
43
+ "sourceDir": "inline-citation",
44
+ "exportName": "InlineCitation",
43
45
  "package": "@slipstream-ui/ai",
44
46
  "importPath": "@slipstream-ui/ai",
45
47
  "version": "0.0.1"
@@ -9,6 +9,7 @@
9
9
  { "name": "onCopy", "type": "(content: string) => void", "required": false, "description": "Called after copy with the copied text." },
10
10
  { "name": "onFeedback", "type": "(rating: 'up' | 'down' | undefined) => void", "required": false, "description": "Rating callback; selecting the active rating again passes undefined." },
11
11
  { "name": "onRegenerate", "type": "() => void", "required": false, "description": "Regeneration request; omit to hide the action." },
12
+ { "name": "autohide", "type": "'always' | 'on-hover' | 'not-last'", "required": false, "defaultValue": "always", "enum": ["always", "on-hover", "not-last"], "description": "Controls visibility: always visible, visible on hover/focus, or hidden until hover for non-latest messages." },
12
13
  { "name": "labels", "type": "{ copy?, copied?, thumbUp?, thumbDown?, regenerate? }", "required": false, "description": "Overrides for all visible labels; enables localization." }
13
14
  ],
14
15
  "examples": [
@@ -29,6 +30,8 @@
29
30
  { "feature": "Toggle state", "implementation": "Rating buttons expose aria-pressed so screen readers announce selection." },
30
31
  { "feature": "Icon buttons", "implementation": "All icon-only controls have accessible labels; labels are localizable." }
31
32
  ],
33
+ "sourceDir": "message-actions",
34
+ "exportName": "MessageActions",
32
35
  "package": "@slipstream-ui/ai",
33
36
  "importPath": "@slipstream-ui/ai",
34
37
  "version": "0.0.1"
@@ -53,6 +53,12 @@
53
53
  "type": "boolean",
54
54
  "required": false,
55
55
  "description": "Shows a streaming indicator while reasoning is in progress"
56
+ },
57
+ {
58
+ "name": "labels",
59
+ "type": "{ title?: string; count?: (count: number) => string; thinking?: string; step?: (index: number) => string }",
60
+ "required": false,
61
+ "description": "Localized header, count and step labels."
56
62
  }
57
63
  ],
58
64
  "examples": [
@@ -45,6 +45,8 @@
45
45
  "implementation": "Routed through CodeBlock with accessible copy button."
46
46
  }
47
47
  ],
48
+ "sourceDir": "response",
49
+ "exportName": "Response",
48
50
  "package": "@slipstream-ui/ai",
49
51
  "importPath": "@slipstream-ui/ai",
50
52
  "version": "0.0.1"
@@ -30,6 +30,12 @@
30
30
  "type": "string",
31
31
  "required": false,
32
32
  "description": "Additional CSS classes to apply"
33
+ },
34
+ {
35
+ "name": "labels",
36
+ "type": "{ pending?: string; running?: string; completed?: string; failed?: string; arguments?: string; result?: string; error?: string }",
37
+ "required": false,
38
+ "description": "Localized status and section labels."
33
39
  }
34
40
  ],
35
41
  "examples": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slipstream-ui/ai",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/michaelmcshinsky/slipstream-ui.git",
@@ -32,7 +32,7 @@
32
32
  "react-markdown": "^10.1.0",
33
33
  "remark-gfm": "^4.0.1",
34
34
  "tailwind-merge": "^2.2.0",
35
- "@slipstream-ui/core": "0.3.0"
35
+ "@slipstream-ui/core": "0.5.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@testing-library/jest-dom": "^7.0.1",
@@ -41,7 +41,7 @@
41
41
  "@types/react": "^18.2.0",
42
42
  "eslint": "^8.56.0",
43
43
  "jest-axe": "^9.0.0",
44
- "jsdom": "^24.0.0",
44
+ "jsdom": "^30.1.3",
45
45
  "tsup": "^8.0.0",
46
46
  "typescript": "^5.3.3",
47
47
  "vitest": "^5.0.5",
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import * as React from 'react';
4
- import { Highlight, themes } from 'prism-react-renderer';
4
+ import { Highlight, type PrismTheme } from 'prism-react-renderer';
5
5
  import { cn } from '@slipstream-ui/core';
6
6
 
7
7
  export type CodeBlockProps = {
@@ -29,12 +29,95 @@ function useIsDark(): boolean {
29
29
  return dark;
30
30
  }
31
31
 
32
+ /**
33
+ * AA-safe syntax palettes.
34
+ *
35
+ * prism-react-renderer's stock themes put comments (#999988) and constants
36
+ * (#36acaa) at ~2.7-2.9:1 on the code surface. Every colour below clears
37
+ * 4.5:1 against its background (white light / near-black dark).
38
+ */
39
+ const LIGHT_CODE_THEME: PrismTheme = {
40
+ plain: { color: '#1f2328', backgroundColor: 'transparent' },
41
+ styles: [
42
+ { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#57606a' } },
43
+ { types: ['punctuation', 'operator'], style: { color: '#24292f' } },
44
+ { types: ['namespace'], style: { color: '#6f42c1' } },
45
+ { types: ['tag', 'deleted', 'keyword', 'atrule', 'important'], style: { color: '#b31d28' } },
46
+ { types: ['attr-name', 'variable'], style: { color: '#953800' } },
47
+ { types: ['string', 'char', 'attr-value', 'inserted', 'regex'], style: { color: '#0a5c2f' } },
48
+ {
49
+ types: [
50
+ 'function',
51
+ 'class-name',
52
+ 'number',
53
+ 'boolean',
54
+ 'constant',
55
+ 'symbol',
56
+ 'property',
57
+ 'selector',
58
+ 'builtin',
59
+ 'entity',
60
+ 'url',
61
+ ],
62
+ style: { color: '#0550ae' },
63
+ },
64
+ ],
65
+ };
66
+
67
+ const DARK_CODE_THEME: PrismTheme = {
68
+ plain: { color: '#e6edf3', backgroundColor: 'transparent' },
69
+ styles: [
70
+ { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#8b949e' } },
71
+ { types: ['punctuation', 'operator'], style: { color: '#c9d1d9' } },
72
+ { types: ['namespace'], style: { color: '#d2a8ff' } },
73
+ { types: ['tag', 'deleted', 'keyword', 'atrule', 'important'], style: { color: '#ff7b72' } },
74
+ { types: ['attr-name', 'variable'], style: { color: '#ffa657' } },
75
+ { types: ['string', 'char', 'attr-value', 'inserted', 'regex'], style: { color: '#7ee787' } },
76
+ {
77
+ types: [
78
+ 'function',
79
+ 'class-name',
80
+ 'number',
81
+ 'boolean',
82
+ 'constant',
83
+ 'symbol',
84
+ 'property',
85
+ 'selector',
86
+ 'builtin',
87
+ 'entity',
88
+ 'url',
89
+ ],
90
+ style: { color: '#79c0ff' },
91
+ },
92
+ ],
93
+ };
94
+
32
95
  const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
33
96
  ({ code, language = 'tsx', showCopyButton = true, className, ...props }, ref) => {
34
97
  const [copied, setCopied] = React.useState(false);
35
98
  const isDark = useIsDark();
36
99
  const trimmedCode = code.replace(/\n$/, '');
37
100
 
101
+ // Horizontally scrollable code must be reachable by keyboard (WCAG 2.1.1).
102
+ // Only make it a tab stop when it actually overflows, so short snippets
103
+ // do not add noise to the tab order.
104
+ const preRef = React.useRef<HTMLPreElement>(null);
105
+ const [scrollable, setScrollable] = React.useState(false);
106
+ React.useEffect(() => {
107
+ const element = preRef.current;
108
+ if (!element) return;
109
+ const update = () => setScrollable(element.scrollWidth > element.clientWidth + 1);
110
+ update();
111
+ // jsdom (tests) has no ResizeObserver; window resize is the fallback.
112
+ if (typeof ResizeObserver === 'undefined') {
113
+ window.addEventListener('resize', update);
114
+ return () => window.removeEventListener('resize', update);
115
+ }
116
+ const observer = new ResizeObserver(update);
117
+ observer.observe(element);
118
+ return () => observer.disconnect();
119
+ }, [trimmedCode, language]);
120
+
38
121
  const handleCopy = async () => {
39
122
  try {
40
123
  await navigator.clipboard.writeText(trimmedCode);
@@ -50,8 +133,8 @@ const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
50
133
  <div
51
134
  ref={ref}
52
135
  className={cn(
53
- // bg-background (not muted): Prism light themes assume a white
54
- // surface — on gray the comment/keyword tokens fall below AA.
136
+ // bg-background (not muted): the light syntax palette is tuned for
137
+ // a white surface; on gray the token contrast drops below AA.
55
138
  'relative group rounded-md border border-border bg-background overflow-hidden',
56
139
  className
57
140
  )}
@@ -70,10 +153,13 @@ const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
70
153
  </button>
71
154
  )}
72
155
  </div>
73
- <Highlight theme={isDark ? themes.vsDark : themes.github} code={trimmedCode} language={language}>
156
+ <Highlight theme={isDark ? DARK_CODE_THEME : LIGHT_CODE_THEME} code={trimmedCode} language={language}>
74
157
  {({ style, tokens, getLineProps, getTokenProps }) => (
75
158
  <pre
76
- className="overflow-x-auto p-4 text-xs leading-relaxed m-0"
159
+ ref={preRef}
160
+ tabIndex={scrollable ? 0 : undefined}
161
+ aria-label={scrollable ? `${language} code, scrollable` : undefined}
162
+ className="overflow-x-auto p-4 text-xs leading-relaxed m-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
77
163
  style={{ ...style, background: 'transparent' }}
78
164
  >
79
165
  {tokens.map((line, i) => (
@@ -72,3 +72,24 @@ describe('CommandPalette', () => {
72
72
  expect(await axe(container)).toHaveNoViolations();
73
73
  });
74
74
  });
75
+
76
+
77
+ describe('CommandPalette async commands', () => {
78
+ it('loads commands from loadCommands', async () => {
79
+ const user = userEvent.setup();
80
+ const loadCommands = vi.fn(async () => [{ id: 'remote', label: 'Remote result' }]);
81
+ render(
82
+ <CommandPalette
83
+ open
84
+ onOpenChange={() => undefined}
85
+ commands={[]}
86
+ onSelect={() => undefined}
87
+ loadCommands={loadCommands}
88
+ debounceMs={1}
89
+ />
90
+ );
91
+ expect(await screen.findByText('Remote result')).toBeInTheDocument();
92
+ expect(loadCommands).toHaveBeenCalled();
93
+ expect(user).toBeTruthy();
94
+ });
95
+ });
@@ -11,13 +11,26 @@ export type Command = {
11
11
  /** Extra lexical search terms. */
12
12
  keywords?: string[];
13
13
  shortcut?: string;
14
+ /** Optional leading icon (rendered by the default row or renderCommand). */
15
+ icon?: React.ReactNode;
16
+ /** Optional secondary line in the default row. */
17
+ description?: string;
14
18
  };
15
19
 
16
20
  export type CommandPaletteProps = {
17
21
  open: boolean;
18
22
  onOpenChange: (open: boolean) => void;
23
+ /** Static command set (ignored for filtering when loadCommands is set). */
19
24
  commands: Command[];
20
25
  onSelect: (command: Command) => void;
26
+ /** Async source; called with the current query (debounced). */
27
+ loadCommands?: (query: string) => Promise<Command[]>;
28
+ /** Debounce for loadCommands, in ms. Default 200. */
29
+ debounceMs?: number;
30
+ /** Custom row renderer (icons, type badges, metadata). */
31
+ renderCommand?: (command: Command, state: { active: boolean }) => React.ReactNode;
32
+ /** Shown while async commands load. */
33
+ loadingText?: string;
21
34
  placeholder?: string;
22
35
  };
23
36
 
@@ -31,22 +44,49 @@ export function CommandPalette({
31
44
  onOpenChange,
32
45
  commands,
33
46
  onSelect,
34
- placeholder = 'Type a command…',
47
+ loadCommands,
48
+ debounceMs = 200,
49
+ renderCommand,
50
+ loadingText = 'Searching…',
51
+ placeholder = 'Type a command.',
35
52
  }: CommandPaletteProps) {
36
53
  const [query, setQuery] = React.useState('');
37
54
  const [activeIndex, setActiveIndex] = React.useState(0);
55
+ const [asyncCommands, setAsyncCommands] = React.useState<Command[]>([]);
56
+ const [loading, setLoading] = React.useState(false);
57
+ const requestId = React.useRef(0);
58
+ const asyncSignature = loadCommands ? String(loadCommands.length) : '';
38
59
  const inputRef = React.useRef<HTMLInputElement | null>(null);
39
60
  const listboxId = React.useId();
40
61
  const optionId = (index: number) => `${listboxId}-option-${index}`;
41
62
 
63
+ React.useEffect(() => {
64
+ if (!open || !loadCommands) return;
65
+ const id = ++requestId.current;
66
+ setLoading(true);
67
+ const timer = setTimeout(async () => {
68
+ try {
69
+ const result = await loadCommands(query);
70
+ if (requestId.current === id) setAsyncCommands(result);
71
+ } catch {
72
+ if (requestId.current === id) setAsyncCommands([]);
73
+ } finally {
74
+ if (requestId.current === id) setLoading(false);
75
+ }
76
+ }, debounceMs);
77
+ return () => clearTimeout(timer);
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [open, query, debounceMs, asyncSignature]);
80
+
42
81
  const filtered = React.useMemo(() => {
82
+ if (loadCommands) return asyncCommands;
43
83
  const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
44
84
  if (terms.length === 0) return commands;
45
85
  return commands.filter((command) => {
46
86
  const haystack = `${command.label} ${command.section ?? ''} ${(command.keywords ?? []).join(' ')}`.toLowerCase();
47
87
  return terms.every((term) => haystack.includes(term));
48
88
  });
49
- }, [commands, query]);
89
+ }, [commands, query, asyncCommands, loadCommands]);
50
90
 
51
91
  React.useEffect(() => {
52
92
  if (open) {
@@ -113,8 +153,19 @@ export function CommandPalette({
113
153
  onKeyDown={onKeyDown}
114
154
  className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring placeholder:text-muted-foreground"
115
155
  />
116
- <ul id={listboxId} role="listbox" aria-label="Commands" className="max-h-72 overflow-y-auto py-1">
117
- {filtered.length === 0 && (
156
+ <ul
157
+ id={listboxId}
158
+ role="listbox"
159
+ aria-label="Commands"
160
+ aria-busy={loading || undefined}
161
+ className="max-h-72 overflow-y-auto py-1"
162
+ >
163
+ {loading && filtered.length === 0 && (
164
+ <li role="presentation" className="px-4 py-3 text-sm text-muted-foreground">
165
+ {loadingText}
166
+ </li>
167
+ )}
168
+ {!loading && filtered.length === 0 && (
118
169
  <li role="presentation" className="px-4 py-3 text-sm text-muted-foreground">
119
170
  No matching commands.
120
171
  </li>
@@ -143,8 +194,22 @@ export function CommandPalette({
143
194
  index === activeIndex ? 'bg-primary/10 text-foreground' : 'text-muted-foreground'
144
195
  )}
145
196
  >
146
- <span>{command.label}</span>
147
- {command.shortcut && <span className="font-mono text-xs">{command.shortcut}</span>}
197
+ {renderCommand ? (
198
+ renderCommand(command, { active: index === activeIndex })
199
+ ) : (
200
+ <>
201
+ <span className="flex min-w-0 items-center gap-2">
202
+ {command.icon && <span className="shrink-0 [&_svg]:size-4">{command.icon}</span>}
203
+ <span className="min-w-0">
204
+ <span className="block truncate">{command.label}</span>
205
+ {command.description && (
206
+ <span className="block text-xs text-muted-foreground">{command.description}</span>
207
+ )}
208
+ </span>
209
+ </span>
210
+ {command.shortcut && <span className="font-mono text-xs">{command.shortcut}</span>}
211
+ </>
212
+ )}
148
213
  </li>
149
214
  </React.Fragment>
150
215
  );
@@ -45,7 +45,7 @@ export type ConfirmationProps = {
45
45
  };
46
46
 
47
47
  const statusChipClasses: Record<ConfirmationStatus, string> = {
48
- pending: 'bg-warning/10 text-warning border-warning/20',
48
+ pending: 'bg-warning/10 text-warning-ink border-warning/20',
49
49
  approved: 'bg-success/10 text-success border-success/20',
50
50
  rejected: 'bg-destructive/10 text-destructive border-destructive/20',
51
51
  };
@@ -192,7 +192,12 @@ const Conversation = React.forwardRef<HTMLDivElement, ConversationProps>(
192
192
 
193
193
  return (
194
194
  <div ref={ref} className={cn('relative h-full w-full', className)} {...props}>
195
- <div ref={scrollRef} className="flex flex-col gap-4 p-4 overflow-y-auto h-full">
195
+ <div
196
+ ref={scrollRef}
197
+ tabIndex={0}
198
+ aria-label="Conversation transcript"
199
+ className="flex flex-col gap-4 p-4 overflow-y-auto h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
200
+ >
196
201
  {(hasMoreHistory || isLoadingMore) && (
197
202
  <button
198
203
  type="button"
@@ -13,10 +13,17 @@ export type ReasoningProps = {
13
13
  duration?: number | string;
14
14
  /** Marks reasoning as still arriving: animated header state. */
15
15
  isStreaming?: boolean;
16
+ /** Localized header, count and step labels. */
17
+ labels?: {
18
+ title?: string;
19
+ count?: (count: number) => string;
20
+ thinking?: string;
21
+ step?: (index: number) => string;
22
+ };
16
23
  };
17
24
 
18
25
  const Reasoning = React.forwardRef<HTMLDivElement, ReasoningProps>(
19
- ({ steps, className, collapsible = true, defaultExpanded = false, duration, isStreaming = false, ...props }, ref) => {
26
+ ({ steps, className, collapsible = true, defaultExpanded = false, duration, isStreaming = false, labels, ...props }, ref) => {
20
27
  const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
21
28
  const contentId = React.useId();
22
29
 
@@ -25,10 +32,10 @@ const Reasoning = React.forwardRef<HTMLDivElement, ReasoningProps>(
25
32
  }
26
33
 
27
34
  const headerText = [
28
- 'Reasoning',
29
- `(${steps.length} ${steps.length === 1 ? 'step' : 'steps'})`,
35
+ labels?.title ?? 'Reasoning',
36
+ labels?.count ? labels.count(steps.length) : `(${steps.length} ${steps.length === 1 ? 'step' : 'steps'})`,
30
37
  typeof duration === 'number' ? `· ${duration}s` : duration ? `· ${duration}` : '',
31
- isStreaming ? '· thinking…' : '',
38
+ isStreaming ? `· ${labels?.thinking ?? 'thinking…'}` : '',
32
39
  ]
33
40
  .filter(Boolean)
34
41
  .join(' ');
@@ -42,7 +49,7 @@ const Reasoning = React.forwardRef<HTMLDivElement, ReasoningProps>(
42
49
  aria-expanded={isExpanded}
43
50
  aria-controls={contentId}
44
51
  >
45
- <span className={cn('transition-transform', isExpanded ? 'rotate-90' : '')}>▶</span>
52
+ <span className={cn('transition-transform', isExpanded ? 'rotate-90' : '')}>?</span>
46
53
  <span>{headerText}</span>
47
54
  {isStreaming && (
48
55
  <span className="flex gap-0.5" aria-hidden="true">
@@ -64,7 +71,7 @@ const Reasoning = React.forwardRef<HTMLDivElement, ReasoningProps>(
64
71
  <div key={step.id} className="flex gap-3">
65
72
  <div className="w-3 h-3 rounded-full bg-primary/20 border-2 border-primary flex-shrink-0 mt-1" />
66
73
  <div className="text-sm flex-1">
67
- <div className="font-medium text-foreground mb-1">Step {index + 1}</div>
74
+ <div className="font-medium text-foreground mb-1">{labels?.step?.(index + 1) ?? `Step ${index + 1}`}</div>
68
75
  <div className="text-muted-foreground">{step.content}</div>
69
76
  {step.timestamp && (
70
77
  <div className="text-xs text-muted-foreground mt-1">
@@ -8,16 +8,26 @@ export type ToolCallProps = {
8
8
  toolCall: ToolCallType;
9
9
  className?: string;
10
10
  showResult?: boolean;
11
+ /** Localized status and section labels. */
12
+ labels?: {
13
+ pending?: string;
14
+ running?: string;
15
+ completed?: string;
16
+ failed?: string;
17
+ arguments?: string;
18
+ result?: string;
19
+ error?: string;
20
+ };
11
21
  };
12
22
 
13
23
  const statusChipClasses: Record<ToolCallType['status'], string> = {
14
- pending: 'bg-warning/10 text-warning border-warning/20',
15
- running: 'bg-info/10 text-info border-info/20',
24
+ pending: 'bg-warning/10 text-warning-ink border-warning/20',
25
+ running: 'bg-info/10 text-info-ink border-info/20',
16
26
  completed: 'bg-success/10 text-success border-success/20',
17
27
  failed: 'bg-destructive/10 text-destructive border-destructive/20',
18
28
  };
19
29
 
20
- const statusLabels: Record<ToolCallType['status'], string> = {
30
+ const defaultStatusLabels: Record<ToolCallType['status'], string> = {
21
31
  pending: 'Pending',
22
32
  running: 'Running',
23
33
  completed: 'Completed',
@@ -29,8 +39,7 @@ const statusLabels: Record<ToolCallType['status'], string> = {
29
39
  * arguments, optional result, and an error block on failure.
30
40
  */
31
41
  const ToolCall = React.forwardRef<HTMLDivElement, ToolCallProps>(
32
- ({ toolCall, className, showResult = true, ...props }, ref) => {
33
- // Serialize once per tool-call change instead of on every render.
42
+ ({ toolCall, className, showResult = true, labels, ...props }, ref) => {
34
43
  const serializedArguments = React.useMemo(
35
44
  () => JSON.stringify(toolCall.arguments, null, 2),
36
45
  [toolCall.arguments]
@@ -42,6 +51,13 @@ const ToolCall = React.forwardRef<HTMLDivElement, ToolCallProps>(
42
51
  : JSON.stringify(toolCall.result, null, 2);
43
52
  }, [toolCall.result]);
44
53
 
54
+ const statusLabels: Record<ToolCallType['status'], string> = {
55
+ pending: labels?.pending ?? defaultStatusLabels.pending,
56
+ running: labels?.running ?? defaultStatusLabels.running,
57
+ completed: labels?.completed ?? defaultStatusLabels.completed,
58
+ failed: labels?.failed ?? defaultStatusLabels.failed,
59
+ };
60
+
45
61
  return (
46
62
  <div
47
63
  ref={ref}
@@ -68,7 +84,7 @@ const ToolCall = React.forwardRef<HTMLDivElement, ToolCallProps>(
68
84
 
69
85
  <div className="space-y-2">
70
86
  <div>
71
- <div className="text-xs font-medium text-muted-foreground mb-1">Arguments</div>
87
+ <div className="text-xs font-medium text-muted-foreground mb-1">{labels?.arguments ?? 'Arguments'}</div>
72
88
  <pre className="text-xs bg-muted p-2 rounded overflow-x-auto">
73
89
  <code>{serializedArguments}</code>
74
90
  </pre>
@@ -76,7 +92,7 @@ const ToolCall = React.forwardRef<HTMLDivElement, ToolCallProps>(
76
92
 
77
93
  {showResult && serializedResult !== undefined && (
78
94
  <div>
79
- <div className="text-xs font-medium text-muted-foreground mb-1">Result</div>
95
+ <div className="text-xs font-medium text-muted-foreground mb-1">{labels?.result ?? 'Result'}</div>
80
96
  <pre className="text-xs bg-muted p-2 rounded overflow-x-auto">
81
97
  <code>{serializedResult}</code>
82
98
  </pre>
@@ -85,7 +101,7 @@ const ToolCall = React.forwardRef<HTMLDivElement, ToolCallProps>(
85
101
 
86
102
  {toolCall.status === 'failed' && toolCall.error && (
87
103
  <div role="alert">
88
- <div className="text-xs font-medium text-destructive mb-1">Error</div>
104
+ <div className="text-xs font-medium text-destructive mb-1">{labels?.error ?? 'Error'}</div>
89
105
  <pre className="text-xs bg-destructive/10 text-destructive p-2 rounded overflow-x-auto whitespace-pre-wrap">
90
106
  <code>{toolCall.error}</code>
91
107
  </pre>
@@ -27,6 +27,8 @@
27
27
  { "feature": "Upload status", "implementation": "Uploading state is announced via role=status." },
28
28
  { "feature": "Decorative thumbnails", "implementation": "Preview images are aria-hidden; the file name carries the meaning." }
29
29
  ],
30
+ "sourceDir": "attachment",
31
+ "exportName": "Attachment",
30
32
  "package": "@slipstream-ui/ai",
31
33
  "importPath": "@slipstream-ui/ai",
32
34
  "version": "0.0.1"
@@ -59,6 +59,8 @@
59
59
  "implementation": "N/M indicator uses aria-live=polite."
60
60
  }
61
61
  ],
62
+ "sourceDir": "branch",
63
+ "exportName": "Branch",
62
64
  "package": "@slipstream-ui/ai",
63
65
  "importPath": "@slipstream-ui/ai",
64
66
  "version": "0.0.1"