@slipstream-ui/ai 0.0.0-stage → 0.3.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 (111) hide show
  1. package/LICENSE +25 -0
  2. package/dist/index.d.mts +579 -0
  3. package/dist/index.d.ts +579 -0
  4. package/dist/index.js +1778 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +1706 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/manifests/attachment.manifest.json +33 -0
  9. package/dist/manifests/branch.manifest.json +65 -0
  10. package/dist/manifests/chain-thought.manifest.json +39 -0
  11. package/dist/manifests/code-block.manifest.json +50 -0
  12. package/dist/manifests/command-palette.manifest.json +80 -0
  13. package/dist/manifests/confirmation.manifest.json +143 -0
  14. package/dist/manifests/conversation.manifest.json +290 -0
  15. package/dist/manifests/inline-citation.manifest.json +46 -0
  16. package/dist/manifests/message-actions.manifest.json +35 -0
  17. package/dist/manifests/message.manifest.json +235 -0
  18. package/dist/manifests/prompt-input.manifest.json +334 -0
  19. package/dist/manifests/reasoning.manifest.json +177 -0
  20. package/dist/manifests/response.manifest.json +51 -0
  21. package/dist/manifests/source.manifest.json +78 -0
  22. package/dist/manifests/streaming-indicator.manifest.json +39 -0
  23. package/dist/manifests/suggestions.manifest.json +45 -0
  24. package/dist/manifests/tool-call.manifest.json +158 -0
  25. package/package.json +61 -3
  26. package/src/adapter/adapter.test.tsx +83 -0
  27. package/src/adapter/hooks.ts +82 -0
  28. package/src/adapter/index.ts +3 -0
  29. package/src/adapter/messages.ts +49 -0
  30. package/src/adapter/types.ts +24 -0
  31. package/src/components/attachment/Attachment.test.tsx +72 -0
  32. package/src/components/attachment/Attachment.tsx +84 -0
  33. package/src/components/attachment/AttachmentList.tsx +30 -0
  34. package/src/components/attachment/index.ts +5 -0
  35. package/src/components/branch/Branch.test.tsx +52 -0
  36. package/src/components/branch/Branch.tsx +77 -0
  37. package/src/components/branch/index.ts +2 -0
  38. package/src/components/chainthought/ChainThought.test.tsx +34 -0
  39. package/src/components/chainthought/ChainThought.tsx +45 -0
  40. package/src/components/chainthought/index.ts +2 -0
  41. package/src/components/code-block/CodeBlock.test.tsx +46 -0
  42. package/src/components/code-block/CodeBlock.tsx +95 -0
  43. package/src/components/code-block/index.ts +2 -0
  44. package/src/components/command-palette/CommandPalette.test.tsx +74 -0
  45. package/src/components/command-palette/CommandPalette.tsx +156 -0
  46. package/src/components/command-palette/index.ts +2 -0
  47. package/src/components/confirmation/Confirmation.test.tsx +97 -0
  48. package/src/components/confirmation/Confirmation.tsx +146 -0
  49. package/src/components/confirmation/index.ts +2 -0
  50. package/src/components/conversation/Conversation.test.tsx +49 -0
  51. package/src/components/conversation/Conversation.tsx +301 -0
  52. package/src/components/conversation/Conversation.welcome.test.tsx +73 -0
  53. package/src/components/conversation/index.ts +2 -0
  54. package/src/components/inline-citation/InlineCitation.test.tsx +42 -0
  55. package/src/components/inline-citation/InlineCitation.tsx +56 -0
  56. package/src/components/inline-citation/index.ts +2 -0
  57. package/src/components/message/Message.error-edit.test.tsx +60 -0
  58. package/src/components/message/Message.test.tsx +80 -0
  59. package/src/components/message/Message.tsx +205 -0
  60. package/src/components/message/index.ts +2 -0
  61. package/src/components/message-actions/MessageActions.test.tsx +77 -0
  62. package/src/components/message-actions/MessageActions.tsx +134 -0
  63. package/src/components/message-actions/index.ts +2 -0
  64. package/src/components/prompt-input/PromptInput.compound.test.tsx +150 -0
  65. package/src/components/prompt-input/PromptInput.test.tsx +74 -0
  66. package/src/components/prompt-input/PromptInput.tsx +584 -0
  67. package/src/components/prompt-input/index.ts +21 -0
  68. package/src/components/reasoning/Reasoning.duration.test.tsx +35 -0
  69. package/src/components/reasoning/Reasoning.test.tsx +58 -0
  70. package/src/components/reasoning/Reasoning.tsx +85 -0
  71. package/src/components/reasoning/index.ts +2 -0
  72. package/src/components/response/Response.test.tsx +69 -0
  73. package/src/components/response/Response.tsx +75 -0
  74. package/src/components/response/close-incomplete-markdown.ts +47 -0
  75. package/src/components/response/index.ts +3 -0
  76. package/src/components/source/Source.tsx +51 -0
  77. package/src/components/source/Sources.tsx +42 -0
  78. package/src/components/source/index.ts +5 -0
  79. package/src/components/source/source.test.tsx +72 -0
  80. package/src/components/streaming/StreamingIndicator.test.tsx +26 -0
  81. package/src/components/streaming/StreamingIndicator.tsx +46 -0
  82. package/src/components/streaming/index.ts +2 -0
  83. package/src/components/suggestion/Suggestion.test.tsx +43 -0
  84. package/src/components/suggestion/Suggestion.tsx +47 -0
  85. package/src/components/suggestion/index.ts +2 -0
  86. package/src/components/tool-call/ToolCall.test.tsx +45 -0
  87. package/src/components/tool-call/ToolCall.tsx +101 -0
  88. package/src/components/tool-call/index.ts +2 -0
  89. package/src/index.ts +29 -0
  90. package/src/manifests/attachment.manifest.json +33 -0
  91. package/src/manifests/branch.manifest.json +65 -0
  92. package/src/manifests/chain-thought.manifest.json +39 -0
  93. package/src/manifests/code-block.manifest.json +50 -0
  94. package/src/manifests/command-palette.manifest.json +80 -0
  95. package/src/manifests/confirmation.manifest.json +143 -0
  96. package/src/manifests/conversation.manifest.json +290 -0
  97. package/src/manifests/inline-citation.manifest.json +46 -0
  98. package/src/manifests/message-actions.manifest.json +35 -0
  99. package/src/manifests/message.manifest.json +235 -0
  100. package/src/manifests/prompt-input.manifest.json +334 -0
  101. package/src/manifests/reasoning.manifest.json +177 -0
  102. package/src/manifests/response.manifest.json +51 -0
  103. package/src/manifests/source.manifest.json +78 -0
  104. package/src/manifests/streaming-indicator.manifest.json +39 -0
  105. package/src/manifests/suggestions.manifest.json +45 -0
  106. package/src/manifests/tool-call.manifest.json +158 -0
  107. package/src/test/jest-axe.d.ts +16 -0
  108. package/src/test/setup.ts +18 -0
  109. package/src/test/vitest-matchers.d.ts +10 -0
  110. package/src/types/index.ts +40 -0
  111. package/README.md +0 -3
@@ -0,0 +1,46 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { CodeBlock } from './CodeBlock';
5
+
6
+ describe('CodeBlock', () => {
7
+ it('renders code with a language label', () => {
8
+ const { container } = render(<CodeBlock code="const x = 1;" language="ts" />);
9
+ expect(screen.getByText('ts')).toBeInTheDocument();
10
+ // Prism splits code into tokens; assert on aggregate content.
11
+ expect(container.textContent).toContain('const x = 1;');
12
+ });
13
+
14
+ it('copies code to the clipboard and confirms', async () => {
15
+ const user = userEvent.setup();
16
+ const writeText = vi.fn().mockResolvedValue(undefined);
17
+ Object.defineProperty(navigator, 'clipboard', {
18
+ value: { writeText },
19
+ configurable: true,
20
+ });
21
+
22
+ render(<CodeBlock code={'line one\nline two'} />);
23
+ await user.click(screen.getByRole('button', { name: 'Copy code' }));
24
+
25
+ expect(writeText).toHaveBeenCalledWith('line one\nline two');
26
+ expect(await screen.findByText('Copied!')).toBeInTheDocument();
27
+ });
28
+
29
+ it('hides the copy button when showCopyButton is false', () => {
30
+ render(<CodeBlock code="x" showCopyButton={false} />);
31
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
32
+ });
33
+
34
+ it('trims trailing newline from copied content', async () => {
35
+ const user = userEvent.setup();
36
+ const writeText = vi.fn().mockResolvedValue(undefined);
37
+ Object.defineProperty(navigator, 'clipboard', {
38
+ value: { writeText },
39
+ configurable: true,
40
+ });
41
+
42
+ render(<CodeBlock code={'hello\n'} />);
43
+ await user.click(screen.getByRole('button', { name: 'Copy code' }));
44
+ expect(writeText).toHaveBeenCalledWith('hello');
45
+ });
46
+ });
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Highlight, themes } from 'prism-react-renderer';
5
+ import { cn } from '@slipstream-ui/core';
6
+
7
+ export type CodeBlockProps = {
8
+ /** Raw code to display (no trailing newline needed). */
9
+ code: string;
10
+ /** Language identifier used for Prism highlighting. */
11
+ language?: string;
12
+ /** Show a copy-to-clipboard button (default true). */
13
+ showCopyButton?: boolean;
14
+ className?: string;
15
+ };
16
+
17
+ /** Tracks whether the `.dark` class is present on `<html>`, staying in sync with toggles. */
18
+ function useIsDark(): boolean {
19
+ const [dark, setDark] = React.useState(false);
20
+
21
+ React.useEffect(() => {
22
+ const el = document.documentElement;
23
+ setDark(el.classList.contains('dark'));
24
+ const observer = new MutationObserver(() => setDark(el.classList.contains('dark')));
25
+ observer.observe(el, { attributes: true, attributeFilter: ['class'] });
26
+ return () => observer.disconnect();
27
+ }, []);
28
+
29
+ return dark;
30
+ }
31
+
32
+ const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
33
+ ({ code, language = 'tsx', showCopyButton = true, className, ...props }, ref) => {
34
+ const [copied, setCopied] = React.useState(false);
35
+ const isDark = useIsDark();
36
+ const trimmedCode = code.replace(/\n$/, '');
37
+
38
+ const handleCopy = async () => {
39
+ try {
40
+ await navigator.clipboard.writeText(trimmedCode);
41
+ } catch {
42
+ // Clipboard unavailable (insecure context); still show feedback so
43
+ // consumers can hook onCopy-style behavior via DOM if needed.
44
+ }
45
+ setCopied(true);
46
+ window.setTimeout(() => setCopied(false), 2000);
47
+ };
48
+
49
+ return (
50
+ <div
51
+ ref={ref}
52
+ className={cn(
53
+ // bg-background (not muted): Prism light themes assume a white
54
+ // surface — on gray the comment/keyword tokens fall below AA.
55
+ 'relative group rounded-md border border-border bg-background overflow-hidden',
56
+ className
57
+ )}
58
+ {...props}
59
+ >
60
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/60">
61
+ <span className="text-xs font-mono text-muted-foreground uppercase">{language}</span>
62
+ {showCopyButton && (
63
+ <button
64
+ type="button"
65
+ onClick={handleCopy}
66
+ aria-label={copied ? 'Copied' : 'Copy code'}
67
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded px-1"
68
+ >
69
+ {copied ? 'Copied!' : 'Copy'}
70
+ </button>
71
+ )}
72
+ </div>
73
+ <Highlight theme={isDark ? themes.vsDark : themes.github} code={trimmedCode} language={language}>
74
+ {({ style, tokens, getLineProps, getTokenProps }) => (
75
+ <pre
76
+ className="overflow-x-auto p-4 text-xs leading-relaxed m-0"
77
+ style={{ ...style, background: 'transparent' }}
78
+ >
79
+ {tokens.map((line, i) => (
80
+ <div key={i} {...getLineProps({ line })}>
81
+ {line.map((token, key) => (
82
+ <span key={key} {...getTokenProps({ token })} />
83
+ ))}
84
+ </div>
85
+ ))}
86
+ </pre>
87
+ )}
88
+ </Highlight>
89
+ </div>
90
+ );
91
+ }
92
+ );
93
+ CodeBlock.displayName = 'CodeBlock';
94
+
95
+ export { CodeBlock };
@@ -0,0 +1,2 @@
1
+ export { CodeBlock } from './CodeBlock';
2
+ export type { CodeBlockProps } from './CodeBlock';
@@ -0,0 +1,74 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { axe } from 'jest-axe';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { useState } from 'react';
6
+ import { CommandPalette, type Command } from './CommandPalette';
7
+
8
+ const commands: Command[] = [
9
+ { id: 'go-docs', label: 'Open documentation', section: 'Navigation', keywords: ['docs', 'help'] },
10
+ { id: 'go-billing', label: 'Open billing', section: 'Navigation', keywords: ['plan', 'invoices'] },
11
+ { id: 'act-copy', label: 'Copy code', section: 'Actions', keywords: ['clipboard'], shortcut: '⌘C' },
12
+ ];
13
+
14
+ function Harness({ onSelect }: { onSelect: (command: Command) => void }) {
15
+ const [open, setOpen] = useState(true);
16
+ return <CommandPalette open={open} onOpenChange={setOpen} commands={commands} onSelect={onSelect} />;
17
+ }
18
+
19
+ describe('CommandPalette', () => {
20
+ it('renders commands grouped by section as listbox options', () => {
21
+ render(<Harness onSelect={() => undefined} />);
22
+ expect(screen.getByRole('combobox', { name: 'Command palette' })).toBeInTheDocument();
23
+ const labels = screen.getAllByRole('option').map((option) => option.textContent);
24
+ expect(labels).toEqual(['Open documentation', 'Open billing', 'Copy code⌘C']);
25
+ expect(screen.getByText('Navigation')).toBeInTheDocument(); // header rendered once per section
26
+ expect(screen.getByText('Actions')).toBeInTheDocument();
27
+ });
28
+
29
+ it('filters lexically by label and keywords', async () => {
30
+ const user = userEvent.setup();
31
+ render(<Harness onSelect={() => undefined} />);
32
+ await user.type(screen.getByRole('combobox'), 'invoice');
33
+ expect(screen.getAllByRole('option')).toHaveLength(1);
34
+ expect(screen.getByRole('option', { name: /Open billing/ })).toBeInTheDocument();
35
+ });
36
+
37
+ it('navigates with arrows and selects with Enter', async () => {
38
+ const user = userEvent.setup();
39
+ const onSelect = vi.fn();
40
+ render(<Harness onSelect={onSelect} />);
41
+ const input = screen.getByRole('combobox');
42
+ await user.keyboard('{ArrowDown}');
43
+ expect(screen.getByRole('option', { name: /Open billing/ })).toHaveAttribute('aria-selected', 'true');
44
+ expect(input).toHaveAttribute('aria-activedescendant', screen.getByRole('option', { name: /Open billing/ }).id);
45
+ await user.keyboard('{Enter}');
46
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'go-billing' }));
47
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
48
+ });
49
+
50
+ it('closes on Escape and backdrop click', async () => {
51
+ const user = userEvent.setup();
52
+ render(<Harness onSelect={() => undefined} />);
53
+ await user.keyboard('{Escape}');
54
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
55
+ });
56
+
57
+ it('shows an empty state for no matches', async () => {
58
+ const user = userEvent.setup();
59
+ render(<Harness onSelect={() => undefined} />);
60
+ await user.type(screen.getByRole('combobox'), 'zzzznope');
61
+ expect(screen.queryAllByRole('option')).toHaveLength(0);
62
+ expect(screen.getByText('No matching commands.')).toBeInTheDocument();
63
+ });
64
+
65
+ it('renders nothing when closed', () => {
66
+ render(<CommandPalette open={false} onOpenChange={() => undefined} commands={commands} onSelect={() => undefined} />);
67
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
68
+ });
69
+
70
+ it('has no accessibility violations', async () => {
71
+ const { container } = render(<Harness onSelect={() => undefined} />);
72
+ expect(await axe(container)).toHaveNoViolations();
73
+ });
74
+ });
@@ -0,0 +1,156 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@slipstream-ui/core';
5
+
6
+ export type Command = {
7
+ id: string;
8
+ label: string;
9
+ /** Optional grouping header, e.g. "Navigation". */
10
+ section?: string;
11
+ /** Extra lexical search terms. */
12
+ keywords?: string[];
13
+ shortcut?: string;
14
+ };
15
+
16
+ export type CommandPaletteProps = {
17
+ open: boolean;
18
+ onOpenChange: (open: boolean) => void;
19
+ commands: Command[];
20
+ onSelect: (command: Command) => void;
21
+ placeholder?: string;
22
+ };
23
+
24
+ /**
25
+ * Cmd+K command palette: combobox + listbox with aria-activedescendant
26
+ * keyboard navigation, lexical filtering over label/keywords, and
27
+ * Escape-to-close. No focus leaves the input while navigating.
28
+ */
29
+ export function CommandPalette({
30
+ open,
31
+ onOpenChange,
32
+ commands,
33
+ onSelect,
34
+ placeholder = 'Type a command…',
35
+ }: CommandPaletteProps) {
36
+ const [query, setQuery] = React.useState('');
37
+ const [activeIndex, setActiveIndex] = React.useState(0);
38
+ const inputRef = React.useRef<HTMLInputElement | null>(null);
39
+ const listboxId = React.useId();
40
+ const optionId = (index: number) => `${listboxId}-option-${index}`;
41
+
42
+ const filtered = React.useMemo(() => {
43
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
44
+ if (terms.length === 0) return commands;
45
+ return commands.filter((command) => {
46
+ const haystack = `${command.label} ${command.section ?? ''} ${(command.keywords ?? []).join(' ')}`.toLowerCase();
47
+ return terms.every((term) => haystack.includes(term));
48
+ });
49
+ }, [commands, query]);
50
+
51
+ React.useEffect(() => {
52
+ if (open) {
53
+ setQuery('');
54
+ setActiveIndex(0);
55
+ // Focus after paint so the portal/DOM node exists.
56
+ const timer = setTimeout(() => inputRef.current?.focus(), 0);
57
+ return () => clearTimeout(timer);
58
+ }
59
+ return undefined;
60
+ }, [open]);
61
+
62
+ React.useEffect(() => {
63
+ setActiveIndex((index) => Math.min(index, Math.max(0, filtered.length - 1)));
64
+ }, [filtered.length]);
65
+
66
+ if (!open) return null;
67
+
68
+ const onKeyDown = (event: React.KeyboardEvent) => {
69
+ if (event.key === 'Escape') {
70
+ event.preventDefault();
71
+ onOpenChange(false);
72
+ } else if (event.key === 'ArrowDown') {
73
+ event.preventDefault();
74
+ setActiveIndex((index) => (filtered.length === 0 ? 0 : (index + 1) % filtered.length));
75
+ } else if (event.key === 'ArrowUp') {
76
+ event.preventDefault();
77
+ setActiveIndex((index) => (filtered.length === 0 ? 0 : (index - 1 + filtered.length) % filtered.length));
78
+ } else if (event.key === 'Enter') {
79
+ event.preventDefault();
80
+ const command = filtered[activeIndex];
81
+ if (command) {
82
+ onSelect(command);
83
+ onOpenChange(false);
84
+ }
85
+ }
86
+ };
87
+
88
+ let lastSection: string | undefined;
89
+
90
+ return (
91
+ <div
92
+ className="fixed inset-0 z-50 flex items-start justify-center bg-background/80 p-4 pt-[15vh]"
93
+ onMouseDown={(event) => {
94
+ if (event.target === event.currentTarget) onOpenChange(false);
95
+ }}
96
+ >
97
+ <div className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
98
+ <input
99
+ ref={inputRef}
100
+ type="text"
101
+ role="combobox"
102
+ aria-expanded="true"
103
+ aria-controls={listboxId}
104
+ aria-activedescendant={filtered.length > 0 ? optionId(activeIndex) : undefined}
105
+ aria-autocomplete="list"
106
+ aria-label="Command palette"
107
+ placeholder={placeholder}
108
+ value={query}
109
+ onChange={(event) => {
110
+ setQuery(event.target.value);
111
+ setActiveIndex(0);
112
+ }}
113
+ onKeyDown={onKeyDown}
114
+ 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
+ />
116
+ <ul id={listboxId} role="listbox" aria-label="Commands" className="max-h-72 overflow-y-auto py-1">
117
+ {filtered.length === 0 && (
118
+ <li role="presentation" className="px-4 py-3 text-sm text-muted-foreground">
119
+ No matching commands.
120
+ </li>
121
+ )}
122
+ {filtered.map((command, index) => {
123
+ const showSection = command.section && command.section !== lastSection;
124
+ lastSection = command.section ?? lastSection;
125
+ return (
126
+ <React.Fragment key={command.id}>
127
+ {showSection && (
128
+ <li role="presentation" className="px-4 pb-1 pt-3 text-xs font-medium uppercase text-muted-foreground">
129
+ {command.section}
130
+ </li>
131
+ )}
132
+ <li
133
+ id={optionId(index)}
134
+ role="option"
135
+ aria-selected={index === activeIndex}
136
+ onMouseEnter={() => setActiveIndex(index)}
137
+ onClick={() => {
138
+ onSelect(command);
139
+ onOpenChange(false);
140
+ }}
141
+ className={cn(
142
+ 'flex cursor-pointer items-center justify-between px-4 py-2 text-sm',
143
+ index === activeIndex ? 'bg-primary/10 text-foreground' : 'text-muted-foreground'
144
+ )}
145
+ >
146
+ <span>{command.label}</span>
147
+ {command.shortcut && <span className="font-mono text-xs">{command.shortcut}</span>}
148
+ </li>
149
+ </React.Fragment>
150
+ );
151
+ })}
152
+ </ul>
153
+ </div>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,2 @@
1
+ export { CommandPalette } from './CommandPalette';
2
+ export type { CommandPaletteProps, Command } from './CommandPalette';
@@ -0,0 +1,97 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { axe } from 'jest-axe';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { Confirmation } from './Confirmation';
6
+
7
+ describe('Confirmation', () => {
8
+ it('renders a pending alertdialog with approve/reject', () => {
9
+ render(<Confirmation title="Delete file" toolName="delete_file" />);
10
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument();
11
+ expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument();
12
+ expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument();
13
+ expect(screen.getByText('Pending')).toBeInTheDocument();
14
+ });
15
+
16
+ it('reports approval and hides the buttons', async () => {
17
+ const user = userEvent.setup();
18
+ const onApprove = vi.fn();
19
+ const onReject = vi.fn();
20
+ render(<Confirmation title="Delete file" onApprove={onApprove} onReject={onReject} />);
21
+
22
+ await user.click(screen.getByRole('button', { name: 'Approve' }));
23
+ expect(onApprove).toHaveBeenCalledOnce();
24
+ expect(onReject).not.toHaveBeenCalled();
25
+ expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument();
26
+ expect(screen.getByText('Approved')).toBeInTheDocument();
27
+ });
28
+
29
+ it('reports rejection', async () => {
30
+ const user = userEvent.setup();
31
+ const onApprove = vi.fn();
32
+ const onReject = vi.fn();
33
+ render(<Confirmation title="Delete file" onApprove={onApprove} onReject={onReject} />);
34
+
35
+ await user.click(screen.getByRole('button', { name: 'Reject' }));
36
+ expect(onReject).toHaveBeenCalledOnce();
37
+ expect(onApprove).not.toHaveBeenCalled();
38
+ expect(screen.getByText('Rejected')).toBeInTheDocument();
39
+ });
40
+
41
+ it('respects controlled status', () => {
42
+ render(<Confirmation title="Delete" status="rejected" onReject={() => {}} />);
43
+ expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument();
44
+ expect(screen.getByText('Rejected')).toBeInTheDocument();
45
+ });
46
+
47
+ it('supports localized labels', () => {
48
+ render(
49
+ <Confirmation
50
+ title="Datei löschen"
51
+ labels={{ approve: 'Genehmigen', reject: 'Ablehnen' }}
52
+ />
53
+ );
54
+ expect(screen.getByRole('button', { name: 'Genehmigen' })).toBeInTheDocument();
55
+ });
56
+
57
+ it('has no accessibility violations while pending', async () => {
58
+ const { container } = render(<Confirmation title="Delete file" description="notes.txt" />);
59
+ expect(await axe(container)).toHaveNoViolations();
60
+ });
61
+ });
62
+
63
+ describe('Confirmation action variants and visibility', () => {
64
+ it('showActions={false} hides both action buttons but keeps the status chip', () => {
65
+ render(
66
+ <Confirmation title="Delete" description="This cannot be undone." showActions={false} />
67
+ );
68
+ expect(screen.getByText('Delete')).toBeInTheDocument();
69
+ expect(screen.getByText('Pending')).toBeInTheDocument();
70
+ expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument();
71
+ expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument();
72
+ });
73
+
74
+ it('approveVariant/rejectVariant control button themes', () => {
75
+ render(
76
+ <Confirmation
77
+ title="Terminate run"
78
+ description="The run is still active."
79
+ labels={{ approve: 'Terminate', reject: 'Keep running' }}
80
+ approveVariant="destructive"
81
+ rejectVariant="ghost"
82
+ />
83
+ );
84
+ expect(screen.getByRole('button', { name: 'Terminate' })).toHaveClass('bg-destructive');
85
+ expect(screen.getByRole('button', { name: 'Keep running' })).toHaveClass('hover:bg-accent');
86
+ });
87
+
88
+ it('rejecting flips the chip to Rejected and calls onReject', async () => {
89
+ const user = userEvent.setup();
90
+ const onReject = vi.fn();
91
+ render(<Confirmation title="Confirm" description="Sure?" onReject={onReject} />);
92
+ await user.click(screen.getByRole('button', { name: 'Reject' }));
93
+ expect(onReject).toHaveBeenCalledOnce();
94
+ expect(screen.getByText('Rejected')).toBeInTheDocument();
95
+ expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument();
96
+ });
97
+ });
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Button } from '@slipstream-ui/core';
5
+ import { cn } from '@slipstream-ui/core';
6
+
7
+ export type ConfirmationStatus = 'pending' | 'approved' | 'rejected';
8
+
9
+ export type ConfirmationVariant = 'default' | 'warning' | 'destructive' | 'info' | 'success';
10
+
11
+ /** Button variants accepted for the approve/reject actions (Button's variant union). */
12
+ export type ConfirmationActionVariant =
13
+ | 'default'
14
+ | 'destructive'
15
+ | 'outline'
16
+ | 'secondary'
17
+ | 'ghost'
18
+ | 'link';
19
+
20
+ export type ConfirmationProps = {
21
+ /** Human-readable summary of the action requiring approval. */
22
+ title: string;
23
+ /** Optional detail: affected resource, arguments preview, risk note. */
24
+ description?: string;
25
+ /** Tool or action name shown as a mono chip. */
26
+ toolName?: string;
27
+ /** Controlled outcome; omit for fully interactive uncontrolled use. */
28
+ status?: ConfirmationStatus;
29
+ /** Accent color of the pending card (outcome chips always follow the result). */
30
+ variant?: ConfirmationVariant;
31
+ onApprove?: () => void;
32
+ onReject?: () => void;
33
+ /** Button styling for the approve/reject actions. */
34
+ approveVariant?: ConfirmationActionVariant;
35
+ rejectVariant?: ConfirmationActionVariant;
36
+ /** Hide the action buttons (controlled-only gate; status drives the chip). */
37
+ showActions?: boolean;
38
+ labels?: {
39
+ approve?: string;
40
+ reject?: string;
41
+ approved?: string;
42
+ rejected?: string;
43
+ };
44
+ className?: string;
45
+ };
46
+
47
+ const statusChipClasses: Record<ConfirmationStatus, string> = {
48
+ pending: 'bg-warning/10 text-warning border-warning/20',
49
+ approved: 'bg-success/10 text-success border-success/20',
50
+ rejected: 'bg-destructive/10 text-destructive border-destructive/20',
51
+ };
52
+
53
+ const pendingVariantClasses: Record<ConfirmationVariant, string> = {
54
+ default: 'border-warning/40',
55
+ warning: 'border-warning/40',
56
+ destructive: 'border-destructive/40 bg-destructive/5',
57
+ info: 'border-info/40',
58
+ success: 'border-success/40',
59
+ };
60
+
61
+ /**
62
+ * Human-in-the-loop approval gate for agent actions (tool execution,
63
+ * destructive operations). The agent pauses until the user decides.
64
+ */
65
+ const Confirmation = React.forwardRef<HTMLDivElement, ConfirmationProps>(
66
+ (
67
+ {
68
+ title,
69
+ description,
70
+ toolName,
71
+ status: controlledStatus,
72
+ variant = 'default',
73
+ onApprove,
74
+ onReject,
75
+ approveVariant = 'default',
76
+ rejectVariant = 'outline',
77
+ showActions = true,
78
+ labels,
79
+ className,
80
+ ...props
81
+ },
82
+ ref
83
+ ) => {
84
+ const [internalStatus, setInternalStatus] = React.useState<ConfirmationStatus>('pending');
85
+ const status = controlledStatus ?? internalStatus;
86
+
87
+ const t = {
88
+ approve: labels?.approve ?? 'Approve',
89
+ reject: labels?.reject ?? 'Reject',
90
+ approved: labels?.approved ?? 'Approved',
91
+ rejected: labels?.rejected ?? 'Rejected',
92
+ };
93
+
94
+ const decide = (next: Exclude<ConfirmationStatus, 'pending'>) => {
95
+ setInternalStatus(next);
96
+ (next === 'approved' ? onApprove : onReject)?.();
97
+ };
98
+
99
+ return (
100
+ <div
101
+ ref={ref}
102
+ role={status === 'pending' ? 'alertdialog' : undefined}
103
+ aria-label={status === 'pending' ? title : undefined}
104
+ className={cn(
105
+ 'rounded-lg border p-4 bg-muted/30 max-w-md',
106
+ status === 'pending' && pendingVariantClasses[variant],
107
+ className
108
+ )}
109
+ {...props}
110
+ >
111
+ <div className="flex items-center justify-between gap-2 mb-2">
112
+ <div className="text-sm font-medium text-foreground">{title}</div>
113
+ <span
114
+ className={cn(
115
+ 'text-xs px-2 py-0.5 rounded-full border whitespace-nowrap',
116
+ statusChipClasses[status]
117
+ )}
118
+ >
119
+ {status === 'approved' ? t.approved : status === 'rejected' ? t.rejected : 'Pending'}
120
+ </span>
121
+ </div>
122
+
123
+ {description && (
124
+ <div className="text-sm text-muted-foreground mb-1">{description}</div>
125
+ )}
126
+ {toolName && (
127
+ <div className="text-xs font-mono text-muted-foreground mb-3">{toolName}</div>
128
+ )}
129
+
130
+ {status === 'pending' && showActions && (
131
+ <div className="flex gap-2 mt-2">
132
+ <Button size="sm" variant={approveVariant} onClick={() => decide('approved')}>
133
+ {t.approve}
134
+ </Button>
135
+ <Button size="sm" variant={rejectVariant} onClick={() => decide('rejected')}>
136
+ {t.reject}
137
+ </Button>
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }
143
+ );
144
+ Confirmation.displayName = 'Confirmation';
145
+
146
+ export { Confirmation };
@@ -0,0 +1,2 @@
1
+ export { Confirmation } from './Confirmation';
2
+ export type { ConfirmationProps, ConfirmationStatus } from './Confirmation';
@@ -0,0 +1,49 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { axe } from 'jest-axe';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { Conversation } from './Conversation';
5
+ import type { Message as MessageType } from '../../types';
6
+
7
+ const messages: MessageType[] = [
8
+ { id: 'm1', role: 'user', content: 'Hello!' },
9
+ { id: 'm2', role: 'assistant', content: 'Hi, how can I help?' },
10
+ ];
11
+
12
+ describe('Conversation', () => {
13
+ it('renders every message in order', () => {
14
+ render(<Conversation messages={messages} />);
15
+ expect(screen.getByText('Hello!')).toBeInTheDocument();
16
+ expect(screen.getByText('Hi, how can I help?')).toBeInTheDocument();
17
+ });
18
+
19
+ it('shows a loading placeholder while isLoading is true', () => {
20
+ render(<Conversation messages={messages} isLoading />);
21
+ expect(screen.getByText('Thinking...')).toBeInTheDocument();
22
+ });
23
+
24
+ it('supports a custom loading message', () => {
25
+ render(<Conversation messages={messages} isLoading loadingMessage="Generiere..." />);
26
+ expect(screen.getByText('Generiere...')).toBeInTheDocument();
27
+ });
28
+
29
+ it('hides the loading placeholder by default', () => {
30
+ render(<Conversation messages={messages} />);
31
+ expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
32
+ });
33
+
34
+ it('renders a streaming cursor on the message with streamingMessageId', () => {
35
+ render(<Conversation messages={messages} streamingMessageId="m2" />);
36
+ expect(screen.getByTestId('streaming-cursor')).toBeInTheDocument();
37
+ expect(screen.getByText(messages[1].content).closest('[aria-busy]')).not.toBeNull();
38
+ });
39
+
40
+ it('does not render a cursor when no streamingMessageId is given', () => {
41
+ render(<Conversation messages={messages} />);
42
+ expect(screen.queryByTestId('streaming-cursor')).not.toBeInTheDocument();
43
+ });
44
+
45
+ it('has no accessibility violations', async () => {
46
+ const { container } = render(<Conversation messages={messages} isLoading />);
47
+ expect(await axe(container)).toHaveNoViolations();
48
+ });
49
+ });