@slipstream-ui/ai 0.0.0-stage → 0.4.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.
- package/LICENSE +25 -0
- package/dist/index.d.mts +579 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +1778 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1706 -0
- package/dist/index.mjs.map +1 -0
- package/dist/manifests/attachment.manifest.json +33 -0
- package/dist/manifests/branch.manifest.json +65 -0
- package/dist/manifests/chain-thought.manifest.json +39 -0
- package/dist/manifests/code-block.manifest.json +50 -0
- package/dist/manifests/command-palette.manifest.json +80 -0
- package/dist/manifests/confirmation.manifest.json +143 -0
- package/dist/manifests/conversation.manifest.json +290 -0
- package/dist/manifests/inline-citation.manifest.json +46 -0
- package/dist/manifests/message-actions.manifest.json +35 -0
- package/dist/manifests/message.manifest.json +235 -0
- package/dist/manifests/prompt-input.manifest.json +334 -0
- package/dist/manifests/reasoning.manifest.json +177 -0
- package/dist/manifests/response.manifest.json +51 -0
- package/dist/manifests/source.manifest.json +78 -0
- package/dist/manifests/streaming-indicator.manifest.json +39 -0
- package/dist/manifests/suggestions.manifest.json +45 -0
- package/dist/manifests/tool-call.manifest.json +158 -0
- package/package.json +61 -3
- package/src/adapter/adapter.test.tsx +83 -0
- package/src/adapter/hooks.ts +82 -0
- package/src/adapter/index.ts +3 -0
- package/src/adapter/messages.ts +49 -0
- package/src/adapter/types.ts +24 -0
- package/src/components/attachment/Attachment.test.tsx +72 -0
- package/src/components/attachment/Attachment.tsx +84 -0
- package/src/components/attachment/AttachmentList.tsx +30 -0
- package/src/components/attachment/index.ts +5 -0
- package/src/components/branch/Branch.test.tsx +52 -0
- package/src/components/branch/Branch.tsx +77 -0
- package/src/components/branch/index.ts +2 -0
- package/src/components/chainthought/ChainThought.test.tsx +34 -0
- package/src/components/chainthought/ChainThought.tsx +45 -0
- package/src/components/chainthought/index.ts +2 -0
- package/src/components/code-block/CodeBlock.test.tsx +46 -0
- package/src/components/code-block/CodeBlock.tsx +95 -0
- package/src/components/code-block/index.ts +2 -0
- package/src/components/command-palette/CommandPalette.test.tsx +74 -0
- package/src/components/command-palette/CommandPalette.tsx +156 -0
- package/src/components/command-palette/index.ts +2 -0
- package/src/components/confirmation/Confirmation.test.tsx +97 -0
- package/src/components/confirmation/Confirmation.tsx +146 -0
- package/src/components/confirmation/index.ts +2 -0
- package/src/components/conversation/Conversation.test.tsx +49 -0
- package/src/components/conversation/Conversation.tsx +301 -0
- package/src/components/conversation/Conversation.welcome.test.tsx +73 -0
- package/src/components/conversation/index.ts +2 -0
- package/src/components/inline-citation/InlineCitation.test.tsx +42 -0
- package/src/components/inline-citation/InlineCitation.tsx +56 -0
- package/src/components/inline-citation/index.ts +2 -0
- package/src/components/message/Message.error-edit.test.tsx +60 -0
- package/src/components/message/Message.test.tsx +80 -0
- package/src/components/message/Message.tsx +205 -0
- package/src/components/message/index.ts +2 -0
- package/src/components/message-actions/MessageActions.test.tsx +77 -0
- package/src/components/message-actions/MessageActions.tsx +134 -0
- package/src/components/message-actions/index.ts +2 -0
- package/src/components/prompt-input/PromptInput.compound.test.tsx +150 -0
- package/src/components/prompt-input/PromptInput.test.tsx +74 -0
- package/src/components/prompt-input/PromptInput.tsx +584 -0
- package/src/components/prompt-input/index.ts +21 -0
- package/src/components/reasoning/Reasoning.duration.test.tsx +35 -0
- package/src/components/reasoning/Reasoning.test.tsx +58 -0
- package/src/components/reasoning/Reasoning.tsx +85 -0
- package/src/components/reasoning/index.ts +2 -0
- package/src/components/response/Response.test.tsx +69 -0
- package/src/components/response/Response.tsx +75 -0
- package/src/components/response/close-incomplete-markdown.ts +47 -0
- package/src/components/response/index.ts +3 -0
- package/src/components/source/Source.tsx +51 -0
- package/src/components/source/Sources.tsx +42 -0
- package/src/components/source/index.ts +5 -0
- package/src/components/source/source.test.tsx +72 -0
- package/src/components/streaming/StreamingIndicator.test.tsx +26 -0
- package/src/components/streaming/StreamingIndicator.tsx +46 -0
- package/src/components/streaming/index.ts +2 -0
- package/src/components/suggestion/Suggestion.test.tsx +43 -0
- package/src/components/suggestion/Suggestion.tsx +47 -0
- package/src/components/suggestion/index.ts +2 -0
- package/src/components/tool-call/ToolCall.test.tsx +45 -0
- package/src/components/tool-call/ToolCall.tsx +101 -0
- package/src/components/tool-call/index.ts +2 -0
- package/src/index.ts +29 -0
- package/src/manifests/attachment.manifest.json +33 -0
- package/src/manifests/branch.manifest.json +65 -0
- package/src/manifests/chain-thought.manifest.json +39 -0
- package/src/manifests/code-block.manifest.json +50 -0
- package/src/manifests/command-palette.manifest.json +80 -0
- package/src/manifests/confirmation.manifest.json +143 -0
- package/src/manifests/conversation.manifest.json +290 -0
- package/src/manifests/inline-citation.manifest.json +46 -0
- package/src/manifests/message-actions.manifest.json +35 -0
- package/src/manifests/message.manifest.json +235 -0
- package/src/manifests/prompt-input.manifest.json +334 -0
- package/src/manifests/reasoning.manifest.json +177 -0
- package/src/manifests/response.manifest.json +51 -0
- package/src/manifests/source.manifest.json +78 -0
- package/src/manifests/streaming-indicator.manifest.json +39 -0
- package/src/manifests/suggestions.manifest.json +45 -0
- package/src/manifests/tool-call.manifest.json +158 -0
- package/src/test/jest-axe.d.ts +16 -0
- package/src/test/setup.ts +18 -0
- package/src/test/vitest-matchers.d.ts +10 -0
- package/src/types/index.ts +40 -0
- package/README.md +0 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import {
|
|
4
|
+
toSlipstreamMessage,
|
|
5
|
+
toSlipstreamMessages,
|
|
6
|
+
toSlipstreamToolCall,
|
|
7
|
+
toSlipstreamToolCalls,
|
|
8
|
+
useSlipstreamMessages,
|
|
9
|
+
useSlipstreamStream,
|
|
10
|
+
} from './index';
|
|
11
|
+
import type { AiSdkMessage } from './index';
|
|
12
|
+
|
|
13
|
+
describe('AI SDK adapter', () => {
|
|
14
|
+
const assistantWithTools: AiSdkMessage = {
|
|
15
|
+
id: 'msg-1',
|
|
16
|
+
role: 'assistant',
|
|
17
|
+
content: 'Let me look that up.',
|
|
18
|
+
toolInvocations: [
|
|
19
|
+
{ toolCallId: 'call-1', toolName: 'search', args: { q: 'react' }, state: 'call' },
|
|
20
|
+
{ toolCallId: 'call-2', toolName: 'fetch', args: { url: '/x' }, state: 'result', result: { ok: true } },
|
|
21
|
+
{ toolCallId: 'call-3', toolName: 'boom', args: {}, state: 'error', error: new Error('tool exploded') },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('maps roles and content onto Slipstream messages', () => {
|
|
26
|
+
const mapped = toSlipstreamMessage({ id: 'm', role: 'user', content: 'hi' });
|
|
27
|
+
expect(mapped).toEqual({ id: 'm', role: 'user', content: 'hi' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('maps tool invocation states onto ToolCall statuses', () => {
|
|
31
|
+
const calls = toSlipstreamToolCalls(assistantWithTools);
|
|
32
|
+
expect(calls.map((call) => call.status)).toEqual(['pending', 'completed', 'failed']);
|
|
33
|
+
expect(calls[1]).toMatchObject({ result: { ok: true } });
|
|
34
|
+
expect(calls[2]).toMatchObject({ error: 'tool exploded' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('preserves tool calls in message metadata for Message components', () => {
|
|
38
|
+
const mapped = toSlipstreamMessage(assistantWithTools);
|
|
39
|
+
expect(mapped.metadata?.toolCalls).toHaveLength(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('maps lists and keeps ids stable', () => {
|
|
43
|
+
const mapped = toSlipstreamMessages([
|
|
44
|
+
{ id: 'a', role: 'user', content: 'q' },
|
|
45
|
+
assistantWithTools,
|
|
46
|
+
]);
|
|
47
|
+
expect(mapped.map((message) => message.id)).toEqual(['a', 'msg-1']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('useSlipstreamMessages derives the current message list', () => {
|
|
51
|
+
const { result } = renderHook(() => useSlipstreamMessages({ messages: [{ id: 'a', role: 'user', content: 'q' }] }));
|
|
52
|
+
expect(result.current.messages).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('useSlipstreamStream exposes loading, streaming id, errors and retry', () => {
|
|
56
|
+
const streaming = renderHook(() =>
|
|
57
|
+
useSlipstreamStream({ status: 'streaming', messages: [{ id: 'a', role: 'user', content: 'q' }, assistantWithTools] }),
|
|
58
|
+
);
|
|
59
|
+
expect(streaming.result.current.isLoading).toBe(true);
|
|
60
|
+
expect(streaming.result.current.streamingMessageId).toBe('msg-1');
|
|
61
|
+
|
|
62
|
+
const failed = renderHook(() =>
|
|
63
|
+
useSlipstreamStream({
|
|
64
|
+
status: 'error',
|
|
65
|
+
error: new Error('network down'),
|
|
66
|
+
messages: [assistantWithTools],
|
|
67
|
+
onRetry: () => undefined,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
expect(failed.result.current.isLoading).toBe(false);
|
|
71
|
+
expect(failed.result.current.messageErrors).toEqual({ 'msg-1': 'network down' });
|
|
72
|
+
expect(failed.result.current.onRetryMessage).toBeTypeOf('function');
|
|
73
|
+
|
|
74
|
+
const ready = renderHook(() => useSlipstreamStream({ status: 'ready', messages: [] }));
|
|
75
|
+
expect(ready.result.current.isLoading).toBe(false);
|
|
76
|
+
expect(ready.result.current.streamingMessageId).toBeUndefined();
|
|
77
|
+
expect(ready.result.current.messageErrors).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('toSlipstreamToolCall tolerates missing args', () => {
|
|
81
|
+
expect(toSlipstreamToolCall({ toolCallId: 'c', toolName: 't' })).toMatchObject({ arguments: {}, status: 'pending' });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import type { Message as MessageData } from '../types';
|
|
5
|
+
import { toSlipstreamMessages } from './messages';
|
|
6
|
+
import type { AiSdkMessage, AiSdkStatus } from './types';
|
|
7
|
+
|
|
8
|
+
export type UseSlipstreamMessagesOptions = {
|
|
9
|
+
/** `messages` from the AI SDK's useChat()/useCompletion() state. */
|
|
10
|
+
messages: AiSdkMessage[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type UseSlipstreamMessagesResult = {
|
|
14
|
+
messages: MessageData[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bridge AI SDK chat messages to Slipstream `<Conversation>`/`<Message>`
|
|
19
|
+
* input. Pure derivation of the current state — safe to call from any
|
|
20
|
+
* runtime that provides the structural message shape.
|
|
21
|
+
*/
|
|
22
|
+
export function useSlipstreamMessages({ messages }: UseSlipstreamMessagesOptions): UseSlipstreamMessagesResult {
|
|
23
|
+
const mapped = useMemo(() => toSlipstreamMessages(messages), [messages]);
|
|
24
|
+
return { messages: mapped };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type UseSlipstreamStreamOptions = {
|
|
28
|
+
/** `status` from the AI SDK chat state. */
|
|
29
|
+
status?: AiSdkStatus;
|
|
30
|
+
/** `error` from the AI SDK chat state. */
|
|
31
|
+
error?: unknown;
|
|
32
|
+
/** Retry handler wired to the SDK's reload/retry (e.g. `reload`). */
|
|
33
|
+
onRetry?: () => void;
|
|
34
|
+
/** Messages from the SDK chat state; used to locate the streaming id. */
|
|
35
|
+
messages?: AiSdkMessage[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type UseSlipstreamStreamResult = {
|
|
39
|
+
isLoading: boolean;
|
|
40
|
+
streamingMessageId?: string;
|
|
41
|
+
messageErrors?: Record<string, string>;
|
|
42
|
+
onRetryMessage?: (messageId: string) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bridge AI SDK stream status/error to Slipstream Conversation props:
|
|
47
|
+
* `isLoading` gates the loading row, `streamingMessageId` drives the typing
|
|
48
|
+
* cursor on the last assistant message, and errors map to retryable
|
|
49
|
+
* per-message failures (streaming/tool states).
|
|
50
|
+
*/
|
|
51
|
+
export function useSlipstreamStream({
|
|
52
|
+
status,
|
|
53
|
+
error,
|
|
54
|
+
onRetry,
|
|
55
|
+
messages = [],
|
|
56
|
+
}: UseSlipstreamStreamOptions): UseSlipstreamStreamResult {
|
|
57
|
+
const isLoading = status === 'submitted' || status === 'streaming';
|
|
58
|
+
const streamingMessageId = useMemo(() => {
|
|
59
|
+
if (status !== 'streaming') return undefined;
|
|
60
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
61
|
+
const message = messages[index];
|
|
62
|
+
if (message?.role === 'assistant') return message.id;
|
|
63
|
+
}
|
|
64
|
+
return messages.at(-1)?.id;
|
|
65
|
+
}, [status, messages]);
|
|
66
|
+
|
|
67
|
+
const messageErrors = useMemo(() => {
|
|
68
|
+
if (status !== 'error' || !error) return undefined;
|
|
69
|
+
const last = messages.at(-1);
|
|
70
|
+
if (!last) return undefined;
|
|
71
|
+
return {
|
|
72
|
+
[last.id]: error instanceof Error ? error.message : String(error),
|
|
73
|
+
};
|
|
74
|
+
}, [status, error, messages]);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
isLoading,
|
|
78
|
+
...(streamingMessageId ? { streamingMessageId } : {}),
|
|
79
|
+
...(messageErrors ? { messageErrors } : {}),
|
|
80
|
+
...(onRetry ? { onRetryMessage: onRetry } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Message as MessageData, ToolCall as ToolCallData } from '../types';
|
|
2
|
+
import type { AiSdkMessage, AiSdkToolInvocation } from './types';
|
|
3
|
+
|
|
4
|
+
/** Map one AI SDK tool invocation to a Slipstream ToolCall data object. */
|
|
5
|
+
export function toSlipstreamToolCall(invocation: AiSdkToolInvocation): ToolCallData {
|
|
6
|
+
const status =
|
|
7
|
+
invocation.state === 'result' ? 'completed'
|
|
8
|
+
: invocation.state === 'error' ? 'failed'
|
|
9
|
+
: invocation.state === 'call' ? 'pending'
|
|
10
|
+
: 'pending';
|
|
11
|
+
return {
|
|
12
|
+
id: invocation.toolCallId,
|
|
13
|
+
name: invocation.toolName,
|
|
14
|
+
arguments: invocation.args ?? {},
|
|
15
|
+
status,
|
|
16
|
+
...(status === 'completed' && invocation.result !== undefined
|
|
17
|
+
? { result: invocation.result as ToolCallData['result'] }
|
|
18
|
+
: {}),
|
|
19
|
+
...(status === 'failed'
|
|
20
|
+
? { error: invocation.error instanceof Error ? invocation.error.message : String(invocation.error ?? 'Tool call failed') }
|
|
21
|
+
: {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Extract every tool call carried by an AI SDK message. */
|
|
26
|
+
export function toSlipstreamToolCalls(message: AiSdkMessage): ToolCallData[] {
|
|
27
|
+
return (message.toolInvocations ?? []).map(toSlipstreamToolCall);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map one AI SDK message onto a Slipstream Message. Tool invocations are
|
|
32
|
+
* preserved under `metadata.toolCalls` (and via toSlipstreamToolCalls) so
|
|
33
|
+
* ToolCall components can render them.
|
|
34
|
+
*/
|
|
35
|
+
export function toSlipstreamMessage(message: AiSdkMessage): MessageData {
|
|
36
|
+
return {
|
|
37
|
+
id: message.id,
|
|
38
|
+
role: message.role,
|
|
39
|
+
content: message.content,
|
|
40
|
+
...(message.toolInvocations?.length
|
|
41
|
+
? { metadata: { toolCalls: toSlipstreamToolCalls(message) } }
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Map a useChat/useCompletion message list into Slipstream messages. */
|
|
47
|
+
export function toSlipstreamMessages(messages: AiSdkMessage[]): MessageData[] {
|
|
48
|
+
return messages.map(toSlipstreamMessage);
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural types mirroring the parts of the Vercel AI SDK's chat state the
|
|
3
|
+
* adapter consumes. Declared locally (instead of importing the SDK) so the
|
|
4
|
+
* adapter stays runtime-independent: any chat library that produces this
|
|
5
|
+
* shape can be bridged, and consumers install the SDK themselves.
|
|
6
|
+
*/
|
|
7
|
+
export type AiSdkToolInvocation = {
|
|
8
|
+
toolCallId: string;
|
|
9
|
+
toolName: string;
|
|
10
|
+
args?: Record<string, unknown>;
|
|
11
|
+
/** AI SDK states: 'call' (pending), 'result' (completed), 'error'. */
|
|
12
|
+
state?: string;
|
|
13
|
+
result?: unknown;
|
|
14
|
+
error?: unknown;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type AiSdkMessage = {
|
|
18
|
+
id: string;
|
|
19
|
+
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
20
|
+
content: string;
|
|
21
|
+
toolInvocations?: AiSdkToolInvocation[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AiSdkStatus = string;
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { Attachment } from './Attachment';
|
|
6
|
+
import { AttachmentList } from './AttachmentList';
|
|
7
|
+
|
|
8
|
+
describe('Attachment', () => {
|
|
9
|
+
it('renders the file name and size', () => {
|
|
10
|
+
render(<Attachment attachment={{ id: '1', name: 'spec.pdf', size: '2.4 MB', type: 'PDF' }} />);
|
|
11
|
+
expect(screen.getByText('spec.pdf')).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText(/2.4 MB/)).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('shows an uploading status', () => {
|
|
16
|
+
render(<Attachment attachment={{ id: '1', name: 'big.zip', status: 'uploading' }} />);
|
|
17
|
+
expect(screen.getByRole('status', { name: 'Uploading' })).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('renders a thumbnail preview when provided', () => {
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<Attachment
|
|
23
|
+
attachment={{ id: '1', name: 'chart.png', previewUrl: '/chart.png' }}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
const img = container.querySelector('img');
|
|
27
|
+
expect(img).toHaveAttribute('src', '/chart.png');
|
|
28
|
+
// Decorative: the file name carries the meaning.
|
|
29
|
+
expect(img).toHaveAttribute('aria-hidden', 'true');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('calls onRemove with the attachment', async () => {
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
const onRemove = vi.fn();
|
|
35
|
+
const attachment = { id: '1', name: 'notes.txt' };
|
|
36
|
+
render(<Attachment attachment={attachment} onRemove={onRemove} />);
|
|
37
|
+
await user.click(screen.getByRole('button', { name: 'Remove notes.txt' }));
|
|
38
|
+
expect(onRemove).toHaveBeenCalledWith(attachment);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('hides the remove control without onRemove', () => {
|
|
42
|
+
render(<Attachment attachment={{ id: '1', name: 'notes.txt' }} />);
|
|
43
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('has no accessibility violations', async () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<Attachment attachment={{ id: '1', name: 'spec.pdf', size: '2.4 MB' }} onRemove={() => {}} />
|
|
49
|
+
);
|
|
50
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('AttachmentList', () => {
|
|
55
|
+
it('renders every attachment', () => {
|
|
56
|
+
render(
|
|
57
|
+
<AttachmentList
|
|
58
|
+
attachments={[
|
|
59
|
+
{ id: '1', name: 'a.pdf' },
|
|
60
|
+
{ id: '2', name: 'b.png' },
|
|
61
|
+
]}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
expect(screen.getByText('a.pdf')).toBeInTheDocument();
|
|
65
|
+
expect(screen.getByText('b.png')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders nothing for an empty list', () => {
|
|
69
|
+
const { container } = render(<AttachmentList attachments={[]} />);
|
|
70
|
+
expect(container).toBeEmptyDOMElement();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Spinner } from '@slipstream-ui/core';
|
|
3
|
+
import { cn } from '@slipstream-ui/core';
|
|
4
|
+
|
|
5
|
+
/** A single file attached to a message or prompt. Display-only; upload logic stays consumer-side. */
|
|
6
|
+
export type AttachmentItem = {
|
|
7
|
+
id: string;
|
|
8
|
+
/** File name, including extension. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Human-readable size, e.g. "2.4 MB". */
|
|
11
|
+
size?: string;
|
|
12
|
+
/** MIME type or simple kind used for the chip label. */
|
|
13
|
+
type?: string;
|
|
14
|
+
/** Optional image thumbnail URL. */
|
|
15
|
+
previewUrl?: string;
|
|
16
|
+
status?: 'uploading' | 'done' | 'error';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AttachmentProps = {
|
|
20
|
+
attachment: AttachmentItem;
|
|
21
|
+
/** Called when the user removes the attachment; omit to hide the remove control. */
|
|
22
|
+
onRemove?: (attachment: AttachmentItem) => void;
|
|
23
|
+
className?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>(
|
|
27
|
+
({ attachment, onRemove, className, ...props }, ref) => {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
ref={ref}
|
|
31
|
+
className={cn(
|
|
32
|
+
'inline-flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2 py-1.5 text-sm max-w-xs',
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
{attachment.previewUrl ? (
|
|
38
|
+
<img
|
|
39
|
+
src={attachment.previewUrl}
|
|
40
|
+
alt=""
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
className="h-8 w-8 rounded object-cover flex-shrink-0"
|
|
43
|
+
/>
|
|
44
|
+
) : (
|
|
45
|
+
<span
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
className="flex h-8 w-8 items-center justify-center rounded bg-secondary text-[10px] font-semibold uppercase text-secondary-foreground flex-shrink-0"
|
|
48
|
+
>
|
|
49
|
+
{(attachment.type ?? attachment.name.split('.').pop() ?? '?').slice(0, 4)}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<div className="min-w-0 flex-1">
|
|
54
|
+
<div className="truncate font-medium text-foreground" title={attachment.name}>
|
|
55
|
+
{attachment.name}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="text-xs text-muted-foreground">
|
|
58
|
+
{attachment.status === 'uploading'
|
|
59
|
+
? 'Uploading...'
|
|
60
|
+
: [attachment.size, attachment.type].filter(Boolean).join(' · ') || null}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{attachment.status === 'uploading' && (
|
|
65
|
+
<Spinner size="sm" label="Uploading" aria-label="Uploading" className="flex-shrink-0" />
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{onRemove && (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => onRemove(attachment)}
|
|
72
|
+
aria-label={`Remove ${attachment.name}`}
|
|
73
|
+
className="flex-shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
74
|
+
>
|
|
75
|
+
✕
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
Attachment.displayName = 'Attachment';
|
|
83
|
+
|
|
84
|
+
export { Attachment };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '@slipstream-ui/core';
|
|
3
|
+
import { Attachment } from './Attachment';
|
|
4
|
+
import type { AttachmentItem } from './Attachment';
|
|
5
|
+
|
|
6
|
+
export type AttachmentListProps = {
|
|
7
|
+
attachments: AttachmentItem[];
|
|
8
|
+
/** Passed through to every Attachment; omit to hide remove controls. */
|
|
9
|
+
onRemove?: (attachment: AttachmentItem) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const AttachmentList = React.forwardRef<HTMLDivElement, AttachmentListProps>(
|
|
14
|
+
({ attachments, onRemove, className, ...props }, ref) => {
|
|
15
|
+
if (attachments.length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div ref={ref} className={cn('flex flex-wrap gap-2', className)} {...props}>
|
|
21
|
+
{attachments.map((attachment) => (
|
|
22
|
+
<Attachment key={attachment.id} attachment={attachment} onRemove={onRemove} />
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
AttachmentList.displayName = 'AttachmentList';
|
|
29
|
+
|
|
30
|
+
export { AttachmentList };
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { Branch } from './Branch';
|
|
6
|
+
|
|
7
|
+
describe('Branch', () => {
|
|
8
|
+
it('renders nothing for fewer than two variants', () => {
|
|
9
|
+
const { container } = render(<Branch count={1} />);
|
|
10
|
+
expect(container).toBeEmptyDOMElement();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('shows the uncontrolled position and navigates', async () => {
|
|
14
|
+
const user = userEvent.setup();
|
|
15
|
+
const onSelect = vi.fn();
|
|
16
|
+
render(<Branch count={3} defaultSelected={0} onSelect={onSelect} />);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
|
19
|
+
|
|
20
|
+
await user.click(screen.getByRole('button', { name: 'Next response' }));
|
|
21
|
+
expect(onSelect).toHaveBeenCalledWith(1);
|
|
22
|
+
expect(screen.getByText('2 / 3')).toBeInTheDocument();
|
|
23
|
+
|
|
24
|
+
await user.click(screen.getByRole('button', { name: 'Previous response' }));
|
|
25
|
+
expect(onSelect).toHaveBeenLastCalledWith(0);
|
|
26
|
+
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('disables navigation at the boundaries', () => {
|
|
30
|
+
const { rerender } = render(<Branch count={2} selected={0} />);
|
|
31
|
+
expect(screen.getByRole('button', { name: 'Previous response' })).toBeDisabled();
|
|
32
|
+
expect(screen.getByRole('button', { name: 'Next response' })).toBeEnabled();
|
|
33
|
+
|
|
34
|
+
rerender(<Branch count={2} selected={1} />);
|
|
35
|
+
expect(screen.getByRole('button', { name: 'Next response' })).toBeDisabled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('clamps out-of-range controlled selection', () => {
|
|
39
|
+
render(<Branch count={3} selected={9} />);
|
|
40
|
+
expect(screen.getByText('3 / 3')).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('announces position politely for screen readers', () => {
|
|
44
|
+
render(<Branch count={2} selected={1} />);
|
|
45
|
+
expect(screen.getByText('2 / 2')).toHaveAttribute('aria-live', 'polite');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('has no accessibility violations', async () => {
|
|
49
|
+
const { container } = render(<Branch count={3} defaultSelected={1} />);
|
|
50
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '@slipstream-ui/core';
|
|
5
|
+
|
|
6
|
+
export type BranchProps = {
|
|
7
|
+
/** Total number of message variants. Renders nothing when count < 2. */
|
|
8
|
+
count: number;
|
|
9
|
+
/** Zero-based index of the currently displayed variant. */
|
|
10
|
+
selected?: number;
|
|
11
|
+
/** Uncontrolled initial variant when `selected` is omitted. */
|
|
12
|
+
defaultSelected?: number;
|
|
13
|
+
onSelect?: (index: number) => void;
|
|
14
|
+
labels?: { previous?: string; next?: string };
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Variant navigation for branched messages (`‹ 2 / 3 ›`).
|
|
20
|
+
* Purely presentational + minimal internal state; the consumer swaps content.
|
|
21
|
+
*/
|
|
22
|
+
const Branch = React.forwardRef<HTMLDivElement, BranchProps>(
|
|
23
|
+
(
|
|
24
|
+
{ count, selected, defaultSelected = 0, onSelect, labels, className, ...props },
|
|
25
|
+
ref
|
|
26
|
+
) => {
|
|
27
|
+
const [internalIndex, setInternalIndex] = React.useState(defaultSelected);
|
|
28
|
+
const isControlled = selected !== undefined;
|
|
29
|
+
const index = Math.min(Math.max(isControlled ? selected : internalIndex, 0), count - 1);
|
|
30
|
+
|
|
31
|
+
const t = {
|
|
32
|
+
previous: labels?.previous ?? 'Previous response',
|
|
33
|
+
next: labels?.next ?? 'Next response',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (count < 2) return null;
|
|
37
|
+
|
|
38
|
+
const goTo = (next: number) => {
|
|
39
|
+
const clamped = Math.min(Math.max(next, 0), count - 1);
|
|
40
|
+
if (!isControlled) setInternalIndex(clamped);
|
|
41
|
+
onSelect?.(clamped);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={ref}
|
|
47
|
+
className={cn('flex items-center gap-1 text-xs text-muted-foreground', className)}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => goTo(index - 1)}
|
|
53
|
+
disabled={index === 0}
|
|
54
|
+
aria-label={t.previous}
|
|
55
|
+
className="rounded px-1 hover:bg-accent hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
56
|
+
>
|
|
57
|
+
‹
|
|
58
|
+
</button>
|
|
59
|
+
<span aria-live="polite" className="tabular-nums">
|
|
60
|
+
{index + 1} / {count}
|
|
61
|
+
</span>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={() => goTo(index + 1)}
|
|
65
|
+
disabled={index === count - 1}
|
|
66
|
+
aria-label={t.next}
|
|
67
|
+
className="rounded px-1 hover:bg-accent hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
|
|
68
|
+
>
|
|
69
|
+
›
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
Branch.displayName = 'Branch';
|
|
76
|
+
|
|
77
|
+
export { Branch };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { axe } from 'jest-axe';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { ChainThought } from './ChainThought';
|
|
5
|
+
|
|
6
|
+
const steps = [
|
|
7
|
+
{ id: 'c1', content: 'Fetch data', status: 'completed' as const },
|
|
8
|
+
{ id: 'c2', content: 'Analyze results', status: 'pending' as const },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('ChainThought', () => {
|
|
12
|
+
it('renders numbered steps with their content', () => {
|
|
13
|
+
render(<ChainThought steps={steps} />);
|
|
14
|
+
expect(screen.getByText('Step 1')).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText('Step 2')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('Fetch data')).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('Analyze results')).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('renders nothing for an empty step list', () => {
|
|
21
|
+
const { container } = render(<ChainThought steps={[]} />);
|
|
22
|
+
expect(container).toBeEmptyDOMElement();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('applies compact text sizing', () => {
|
|
26
|
+
render(<ChainThought steps={steps} compact />);
|
|
27
|
+
expect(screen.getByText('Fetch data').parentElement).toHaveClass('text-sm');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('has no accessibility violations', async () => {
|
|
31
|
+
const { container } = render(<ChainThought steps={steps} />);
|
|
32
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '@slipstream-ui/core';
|
|
3
|
+
|
|
4
|
+
export type ChainThoughtStep = {
|
|
5
|
+
id: string;
|
|
6
|
+
content: string;
|
|
7
|
+
status?: 'pending' | 'completed' | 'failed';
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ChainThoughtProps = {
|
|
11
|
+
steps: ChainThoughtStep[];
|
|
12
|
+
className?: string;
|
|
13
|
+
compact?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ChainThought = React.forwardRef<HTMLDivElement, ChainThoughtProps>(
|
|
17
|
+
({ steps, className, compact = false, ...props }, ref) => {
|
|
18
|
+
if (steps.length === 0) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div ref={ref} className={cn('space-y-2', className)} {...props}>
|
|
24
|
+
{steps.map((step, index) => (
|
|
25
|
+
<div key={step.id} className="flex gap-3 items-start">
|
|
26
|
+
<div className={cn(
|
|
27
|
+
'w-2 h-2 rounded-full mt-1.5 flex-shrink-0',
|
|
28
|
+
step.status === 'completed' ? 'bg-success' :
|
|
29
|
+
step.status === 'failed' ? 'bg-destructive' :
|
|
30
|
+
step.status === 'pending' ? 'bg-warning' : 'bg-primary'
|
|
31
|
+
)} />
|
|
32
|
+
<div className={cn('flex-1', compact && 'text-sm')}>
|
|
33
|
+
<div className="text-sm text-muted-foreground">
|
|
34
|
+
Step {index + 1}
|
|
35
|
+
</div>
|
|
36
|
+
<div className="text-foreground">{step.content}</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
ChainThought.displayName = 'ChainThought';
|
|
44
|
+
|
|
45
|
+
export { ChainThought };
|