@kitnai/chat 0.1.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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +314 -0
  3. package/dist/bash-InADTalH.js +6 -0
  4. package/dist/core-AYMC6_lb.js +5874 -0
  5. package/dist/engine-javascript-vq0WuIJl.js +2643 -0
  6. package/dist/github-dark-dimmed-DUshB20C.js +4 -0
  7. package/dist/github-light-JYsPkUQd.js +4 -0
  8. package/dist/javascript-C25yR2R2.js +6 -0
  9. package/dist/json-DxJze_jm.js +6 -0
  10. package/dist/kitn-chat.es.js +6632 -0
  11. package/dist/tsx-B8rCNbgL.js +6 -0
  12. package/dist/typescript-RycA9KXf.js +6 -0
  13. package/package.json +80 -0
  14. package/src/components/attachments.stories.tsx +304 -0
  15. package/src/components/attachments.tsx +394 -0
  16. package/src/components/chain-of-thought.stories.tsx +212 -0
  17. package/src/components/chain-of-thought.tsx +139 -0
  18. package/src/components/chat-container.stories.tsx +188 -0
  19. package/src/components/chat-container.tsx +78 -0
  20. package/src/components/chat-scope-picker.tsx +47 -0
  21. package/src/components/checkpoint.stories.tsx +103 -0
  22. package/src/components/checkpoint.tsx +81 -0
  23. package/src/components/code-block.stories.tsx +151 -0
  24. package/src/components/code-block.tsx +99 -0
  25. package/src/components/context.stories.tsx +180 -0
  26. package/src/components/context.tsx +323 -0
  27. package/src/components/conversation-item.stories.tsx +126 -0
  28. package/src/components/conversation-item.tsx +18 -0
  29. package/src/components/conversation-list.stories.tsx +134 -0
  30. package/src/components/conversation-list.tsx +100 -0
  31. package/src/components/empty.stories.tsx +435 -0
  32. package/src/components/empty.tsx +166 -0
  33. package/src/components/feedback-bar.stories.tsx +101 -0
  34. package/src/components/feedback-bar.tsx +58 -0
  35. package/src/components/file-upload.stories.tsx +157 -0
  36. package/src/components/file-upload.tsx +161 -0
  37. package/src/components/image.stories.tsx +90 -0
  38. package/src/components/image.tsx +67 -0
  39. package/src/components/loader.stories.tsx +182 -0
  40. package/src/components/loader.tsx +333 -0
  41. package/src/components/markdown.stories.tsx +181 -0
  42. package/src/components/markdown.tsx +81 -0
  43. package/src/components/message-narrow.stories.tsx +330 -0
  44. package/src/components/message-skills.stories.tsx +212 -0
  45. package/src/components/message-skills.tsx +36 -0
  46. package/src/components/message.stories.tsx +282 -0
  47. package/src/components/message.tsx +149 -0
  48. package/src/components/model-switcher.stories.tsx +98 -0
  49. package/src/components/model-switcher.tsx +36 -0
  50. package/src/components/prompt-input.stories.tsx +223 -0
  51. package/src/components/prompt-input.tsx +190 -0
  52. package/src/components/prompt-suggestion.stories.tsx +143 -0
  53. package/src/components/prompt-suggestion.tsx +115 -0
  54. package/src/components/reasoning.stories.tsx +141 -0
  55. package/src/components/reasoning.tsx +157 -0
  56. package/src/components/response-stream.tsx +103 -0
  57. package/src/components/scroll-button.stories.tsx +101 -0
  58. package/src/components/scroll-button.tsx +33 -0
  59. package/src/components/slash-command.stories.tsx +164 -0
  60. package/src/components/slash-command.tsx +223 -0
  61. package/src/components/source.stories.tsx +125 -0
  62. package/src/components/source.tsx +129 -0
  63. package/src/components/text-shimmer.stories.tsx +88 -0
  64. package/src/components/text-shimmer.tsx +37 -0
  65. package/src/components/thinking-bar.stories.tsx +88 -0
  66. package/src/components/thinking-bar.tsx +50 -0
  67. package/src/components/tool.stories.tsx +154 -0
  68. package/src/components/tool.tsx +173 -0
  69. package/src/components/voice-input.stories.tsx +84 -0
  70. package/src/components/voice-input.tsx +103 -0
  71. package/src/elements/chat-types.ts +14 -0
  72. package/src/elements/chat.tsx +111 -0
  73. package/src/elements/compiled.css +2 -0
  74. package/src/elements/conversation-list.tsx +26 -0
  75. package/src/elements/css.ts +5 -0
  76. package/src/elements/default-input.tsx +53 -0
  77. package/src/elements/define.tsx +54 -0
  78. package/src/elements/kitn-chat.stories.tsx +105 -0
  79. package/src/elements/kitn-conversation-list.stories.tsx +177 -0
  80. package/src/elements/kitn-prompt-input.stories.tsx +123 -0
  81. package/src/elements/prompt-input.tsx +39 -0
  82. package/src/elements/register.ts +9 -0
  83. package/src/elements/styles.css +12 -0
  84. package/src/index.ts +128 -0
  85. package/src/primitives/chat-config.tsx +76 -0
  86. package/src/primitives/highlighter.ts +150 -0
  87. package/src/primitives/use-auto-resize.ts +31 -0
  88. package/src/primitives/use-stick-to-bottom.ts +43 -0
  89. package/src/primitives/use-text-stream.ts +112 -0
  90. package/src/primitives/use-voice-recorder.ts +50 -0
  91. package/src/stories/chat-panel-layout.stories.tsx +144 -0
  92. package/src/stories/chat-scene.tsx +570 -0
  93. package/src/stories/checkpoint-restore.stories.tsx +224 -0
  94. package/src/stories/context-usage.stories.tsx +155 -0
  95. package/src/stories/conversation-with-reasoning.stories.tsx +151 -0
  96. package/src/stories/conversation-with-sources.stories.tsx +165 -0
  97. package/src/stories/docs/GettingStarted.mdx +76 -0
  98. package/src/stories/docs/Installation.mdx +48 -0
  99. package/src/stories/docs/Integrations.mdx +110 -0
  100. package/src/stories/docs/Introduction.mdx +29 -0
  101. package/src/stories/docs/Theming.mdx +87 -0
  102. package/src/stories/docs/theme-editor/canvas.tsx +32 -0
  103. package/src/stories/docs/theme-editor/inspector.tsx +66 -0
  104. package/src/stories/docs/theme-editor/presets.test.ts +32 -0
  105. package/src/stories/docs/theme-editor/presets.ts +64 -0
  106. package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
  107. package/src/stories/docs/theme-editor/theme-css.ts +15 -0
  108. package/src/stories/docs/theme-editor/theme-editor.tsx +145 -0
  109. package/src/stories/docs/theme-tokens.tsx +174 -0
  110. package/src/stories/full-chat.stories.tsx +18 -0
  111. package/src/stories/message-actions.stories.tsx +167 -0
  112. package/src/stories/prompt-input-variants.stories.tsx +179 -0
  113. package/src/stories/streaming-response.stories.tsx +234 -0
  114. package/src/stories/theme-editor.stories.tsx +16 -0
  115. package/src/stories/token-reference.stories.tsx +18 -0
  116. package/src/types.ts +41 -0
  117. package/src/ui/avatar.stories.tsx +104 -0
  118. package/src/ui/avatar.tsx +23 -0
  119. package/src/ui/badge.stories.tsx +87 -0
  120. package/src/ui/badge.tsx +21 -0
  121. package/src/ui/button.stories.tsx +146 -0
  122. package/src/ui/button.tsx +37 -0
  123. package/src/ui/collapsible.tsx +14 -0
  124. package/src/ui/dialog.tsx +21 -0
  125. package/src/ui/dropdown.tsx +26 -0
  126. package/src/ui/hover-card.tsx +48 -0
  127. package/src/ui/resizable.stories.tsx +171 -0
  128. package/src/ui/resizable.tsx +219 -0
  129. package/src/ui/scroll-area.tsx +13 -0
  130. package/src/ui/separator.stories.tsx +82 -0
  131. package/src/ui/separator.tsx +10 -0
  132. package/src/ui/skeleton.stories.tsx +338 -0
  133. package/src/ui/skeleton.tsx +16 -0
  134. package/src/ui/textarea.tsx +21 -0
  135. package/src/ui/tooltip.stories.tsx +75 -0
  136. package/src/ui/tooltip.tsx +22 -0
  137. package/src/utils/cn.ts +6 -0
  138. package/theme.css +115 -0
@@ -0,0 +1,98 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { fn } from 'storybook/test';
3
+ import { ModelSwitcher } from './model-switcher';
4
+
5
+ const multipleModels = [
6
+ { id: 'claude-sonnet', name: 'Claude Sonnet', provider: 'Anthropic' },
7
+ { id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic' },
8
+ { id: 'gpt-4o', name: 'GPT-4o', provider: 'OpenAI' },
9
+ { id: 'gemini-pro', name: 'Gemini Pro', provider: 'Google' },
10
+ ];
11
+
12
+ const meta = {
13
+ title: 'Components/ModelSwitcher',
14
+ component: ModelSwitcher,
15
+ tags: ['autodocs'],
16
+ parameters: {
17
+ layout: 'padded',
18
+ docs: {
19
+ controls: { exclude: ['use:eventListener'] },
20
+ description: {
21
+ component: [
22
+ 'A compact dropdown that shows the active model and lets the user switch between the available **models** (grouped by optional `provider` label).',
23
+ '**When to use:** when a chat surface offers more than one model. It renders nothing when fewer than two models are provided, so it is safe to mount unconditionally.',
24
+ '**How to use:** pass the `models` list and the `currentModelId`, then handle `onModelChange` to update your selected-model state.',
25
+ '**Placement:** the prompt input action bar, a chat header, or a settings/toolbar row near the composer.',
26
+ ].join('\n\n'),
27
+ },
28
+ },
29
+ },
30
+ argTypes: {
31
+ models: {
32
+ control: 'object',
33
+ description: 'Selectable models. Each has `id`, `name`, and an optional `provider` label.',
34
+ },
35
+ currentModelId: {
36
+ control: 'text',
37
+ description: 'The `id` of the currently selected model.',
38
+ },
39
+ onModelChange: {
40
+ action: 'modelChange',
41
+ description: 'Fired with the chosen model `id` when the user picks a model.',
42
+ table: { category: 'Events' },
43
+ },
44
+ class: {
45
+ control: 'text',
46
+ description: 'Extra classes applied to the trigger button.',
47
+ },
48
+ },
49
+ args: {
50
+ models: multipleModels,
51
+ currentModelId: 'claude-sonnet',
52
+ onModelChange: fn(),
53
+ },
54
+ render: (args) => <ModelSwitcher {...args} />,
55
+ } satisfies Meta<typeof ModelSwitcher>;
56
+
57
+ export default meta;
58
+ type Story = StoryObj<typeof meta>;
59
+
60
+ const IMPORT = `import { ModelSwitcher } from '@kitnai/chat';`;
61
+ const src = (code: string) => ({
62
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
63
+ });
64
+
65
+ /** Interactive playground — tweak the controls to explore the switcher. */
66
+ export const Playground: Story = {
67
+ ...src(`<ModelSwitcher
68
+ models={models}
69
+ currentModelId={modelId()}
70
+ onModelChange={setModelId}
71
+ />`),
72
+ };
73
+
74
+ /** Several models across providers — the dropdown lists them with provider labels. */
75
+ export const MultipleModels: Story = {
76
+ args: { models: multipleModels, currentModelId: 'claude-sonnet' },
77
+ ...src(`<ModelSwitcher
78
+ models={[
79
+ { id: 'claude-sonnet', name: 'Claude Sonnet', provider: 'Anthropic' },
80
+ { id: 'gpt-4o', name: 'GPT-4o', provider: 'OpenAI' },
81
+ ]}
82
+ currentModelId={modelId()}
83
+ onModelChange={setModelId}
84
+ />`),
85
+ };
86
+
87
+ /** A single model — the switcher renders nothing (needs 2+ models). */
88
+ export const SingleModel: Story = {
89
+ args: {
90
+ models: [{ id: 'claude-sonnet', name: 'Claude Sonnet' }],
91
+ currentModelId: 'claude-sonnet',
92
+ },
93
+ ...src(`<ModelSwitcher
94
+ models={[{ id: 'claude-sonnet', name: 'Claude Sonnet' }]}
95
+ currentModelId="claude-sonnet"
96
+ onModelChange={setModelId}
97
+ />`),
98
+ };
@@ -0,0 +1,36 @@
1
+ import { splitProps, For, Show } from 'solid-js';
2
+ import { cn } from '../utils/cn';
3
+ import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem } from '../ui/dropdown';
4
+ import { Button } from '../ui/button';
5
+ import type { ModelOption } from '../types';
6
+
7
+ export interface ModelSwitcherProps { models: ModelOption[]; currentModelId: string; onModelChange: (modelId: string) => void; class?: string; }
8
+
9
+ export function ModelSwitcher(props: ModelSwitcherProps) {
10
+ const [local] = splitProps(props, ['models', 'currentModelId', 'onModelChange', 'class']);
11
+ const currentModel = () => local.models.find((m) => m.id === local.currentModelId);
12
+ return (
13
+ <Show when={local.models.length > 1}>
14
+ <Dropdown>
15
+ <DropdownTrigger as={(triggerProps: any) => (
16
+ <Button variant="ghost" size="sm" class={cn('gap-1 text-xs text-muted-foreground', local.class)} {...triggerProps}>
17
+ {currentModel()?.name ?? local.currentModelId}
18
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
19
+ </Button>
20
+ )} />
21
+ <DropdownContent>
22
+ <For each={local.models}>
23
+ {(model) => (
24
+ <DropdownItem onSelect={() => local.onModelChange(model.id)}>
25
+ <div class="flex flex-col">
26
+ <span class={cn('text-sm', model.id === local.currentModelId && 'font-medium text-foreground')}>{model.name}</span>
27
+ <Show when={model.provider}><span class="text-xs text-muted-foreground">{model.provider}</span></Show>
28
+ </div>
29
+ </DropdownItem>
30
+ )}
31
+ </For>
32
+ </DropdownContent>
33
+ </Dropdown>
34
+ </Show>
35
+ );
36
+ }
@@ -0,0 +1,223 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { fn } from 'storybook/test';
3
+ import { createSignal } from 'solid-js';
4
+ import { PromptInput, PromptInputTextarea, PromptInputActions } from './prompt-input';
5
+ import { Button } from '../ui/button';
6
+
7
+ const meta = {
8
+ title: 'Components/PromptInput',
9
+ component: PromptInput,
10
+ tags: ['autodocs'],
11
+ parameters: {
12
+ layout: 'padded',
13
+ docs: {
14
+ controls: { exclude: ['use:eventListener'] },
15
+ description: {
16
+ component: [
17
+ 'A composer shell that hosts an auto-resizing `PromptInputTextarea` and a `PromptInputActions` toolbar, with controlled or uncontrolled value, loading, and disabled states.',
18
+ '**When to use:** as the message input at the bottom of any chat surface, wherever the user types and submits a prompt.',
19
+ '**How to use:** control text via `value` + `onValueChange` (or leave uncontrolled), wire `onSubmit` (also fired on Enter without Shift), and place your send/stop controls inside `PromptInputActions`. Toggle `isLoading` / `disabled` for in-flight and read-only states.',
20
+ '**Placement:** pinned at the bottom of the chat column, below the message transcript.',
21
+ ].join('\n\n'),
22
+ },
23
+ },
24
+ },
25
+ argTypes: {
26
+ value: {
27
+ control: 'text',
28
+ description: 'Controlled text value of the textarea.',
29
+ },
30
+ isLoading: {
31
+ control: 'boolean',
32
+ description: 'Marks a response as in-flight (e.g. to show a Stop action).',
33
+ table: { defaultValue: { summary: 'false' } },
34
+ },
35
+ disabled: {
36
+ control: 'boolean',
37
+ description: 'Disables the textarea and dims the composer.',
38
+ table: { defaultValue: { summary: 'false' } },
39
+ },
40
+ maxHeight: {
41
+ control: 'number',
42
+ description: 'Max auto-resize height in px (or a CSS length string) before the textarea scrolls.',
43
+ table: { defaultValue: { summary: '240' } },
44
+ },
45
+ onValueChange: {
46
+ action: 'valueChange',
47
+ description: 'Fired with the new text whenever the textarea value changes.',
48
+ table: { category: 'Events' },
49
+ },
50
+ onSubmit: {
51
+ action: 'submit',
52
+ description: 'Fired on Enter (without Shift), and wherever you call it from an action.',
53
+ table: { category: 'Events' },
54
+ },
55
+ children: {
56
+ control: false,
57
+ description: 'Composer contents — usually a textarea plus an actions row.',
58
+ },
59
+ class: {
60
+ control: 'text',
61
+ description: 'Extra classes for the composer shell.',
62
+ },
63
+ },
64
+ args: {
65
+ value: '',
66
+ isLoading: false,
67
+ disabled: false,
68
+ maxHeight: 240,
69
+ onValueChange: fn(),
70
+ onSubmit: fn(),
71
+ },
72
+ render: (args) => (
73
+ <div class="max-w-xl">
74
+ <PromptInput {...args}>
75
+ <PromptInputTextarea placeholder="Ask anything..." />
76
+ <PromptInputActions>
77
+ <Button variant="default" size="sm">Send</Button>
78
+ </PromptInputActions>
79
+ </PromptInput>
80
+ </div>
81
+ ),
82
+ } satisfies Meta<typeof PromptInput>;
83
+
84
+ export default meta;
85
+ type Story = StoryObj<typeof meta>;
86
+
87
+ const IMPORT = `import { PromptInput, PromptInputTextarea, PromptInputActions, Button } from '@kitnai/chat';`;
88
+ const src = (code: string) => ({
89
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
90
+ });
91
+
92
+ /** Interactive playground — toggle loading/disabled and edit the value via controls. */
93
+ export const Playground: Story = {
94
+ ...src(`<PromptInput value={value()} onValueChange={setValue} onSubmit={send}>
95
+ <PromptInputTextarea placeholder="Ask anything..." />
96
+ <PromptInputActions>
97
+ <Button variant="default" size="sm">Send</Button>
98
+ </PromptInputActions>
99
+ </PromptInput>`),
100
+ };
101
+
102
+ /** Empty composer with a Send button disabled until there is text. */
103
+ export const Default: Story = {
104
+ render: () => {
105
+ const [value, setValue] = createSignal('');
106
+ return (
107
+ <div class="max-w-xl">
108
+ <PromptInput value={value()} onValueChange={setValue}>
109
+ <PromptInputTextarea placeholder="Ask anything..." />
110
+ <PromptInputActions>
111
+ <Button variant="default" size="sm" disabled={!value()}>
112
+ Send
113
+ </Button>
114
+ </PromptInputActions>
115
+ </PromptInput>
116
+ </div>
117
+ );
118
+ },
119
+ ...src(`<PromptInput value={value()} onValueChange={setValue}>
120
+ <PromptInputTextarea placeholder="Ask anything..." />
121
+ <PromptInputActions>
122
+ <Button variant="default" size="sm" disabled={!value()}>Send</Button>
123
+ </PromptInputActions>
124
+ </PromptInput>`),
125
+ };
126
+
127
+ /** Pre-filled with a prompt. */
128
+ export const WithContent: Story = {
129
+ render: () => {
130
+ const [value, setValue] = createSignal('Tell me about SolidJS reactive primitives');
131
+ return (
132
+ <div class="max-w-xl">
133
+ <PromptInput value={value()} onValueChange={setValue}>
134
+ <PromptInputTextarea placeholder="Ask anything..." />
135
+ <PromptInputActions>
136
+ <Button variant="default" size="sm">Send</Button>
137
+ </PromptInputActions>
138
+ </PromptInput>
139
+ </div>
140
+ );
141
+ },
142
+ ...src(`<PromptInput value={value()} onValueChange={setValue}>
143
+ <PromptInputTextarea placeholder="Ask anything..." />
144
+ <PromptInputActions>
145
+ <Button variant="default" size="sm">Send</Button>
146
+ </PromptInputActions>
147
+ </PromptInput>`),
148
+ };
149
+
150
+ /** Read-only composer — `disabled` dims it and blocks input. */
151
+ export const Disabled: Story = {
152
+ render: () => (
153
+ <div class="max-w-xl">
154
+ <PromptInput disabled value="" onValueChange={() => {}}>
155
+ <PromptInputTextarea placeholder="Chat is disabled..." />
156
+ <PromptInputActions>
157
+ <Button variant="default" size="sm" disabled>Send</Button>
158
+ </PromptInputActions>
159
+ </PromptInput>
160
+ </div>
161
+ ),
162
+ ...src(`<PromptInput disabled value="" onValueChange={setValue}>
163
+ <PromptInputTextarea placeholder="Chat is disabled..." />
164
+ <PromptInputActions>
165
+ <Button variant="default" size="sm" disabled>Send</Button>
166
+ </PromptInputActions>
167
+ </PromptInput>`),
168
+ };
169
+
170
+ /** In-flight — `isLoading` typically pairs with a Stop action. */
171
+ export const Loading: Story = {
172
+ render: () => (
173
+ <div class="max-w-xl">
174
+ <PromptInput isLoading value="" onValueChange={() => {}}>
175
+ <PromptInputTextarea placeholder="Generating response..." />
176
+ <PromptInputActions>
177
+ <Button variant="outline" size="sm">
178
+ Stop
179
+ </Button>
180
+ </PromptInputActions>
181
+ </PromptInput>
182
+ </div>
183
+ ),
184
+ ...src(`<PromptInput isLoading value={value()} onValueChange={setValue}>
185
+ <PromptInputTextarea placeholder="Generating response..." />
186
+ <PromptInputActions>
187
+ <Button variant="outline" size="sm">Stop</Button>
188
+ </PromptInputActions>
189
+ </PromptInput>`),
190
+ };
191
+
192
+ /** A split actions row — a leading icon control and a trailing Send (showcase). */
193
+ export const WithMultipleActions: Story = {
194
+ render: () => {
195
+ const [value, setValue] = createSignal('');
196
+ return (
197
+ <div class="max-w-xl">
198
+ <PromptInput value={value()} onValueChange={setValue}>
199
+ <PromptInputTextarea placeholder="Ask anything..." />
200
+ <PromptInputActions class="justify-between w-full px-2 pb-1">
201
+ <div class="flex items-center gap-1">
202
+ <Button variant="ghost" size="icon-sm">
203
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
204
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
205
+ </svg>
206
+ </Button>
207
+ </div>
208
+ <Button variant="default" size="sm" disabled={!value()}>
209
+ Send
210
+ </Button>
211
+ </PromptInputActions>
212
+ </PromptInput>
213
+ </div>
214
+ );
215
+ },
216
+ ...src(`<PromptInput value={value()} onValueChange={setValue}>
217
+ <PromptInputTextarea placeholder="Ask anything..." />
218
+ <PromptInputActions class="justify-between w-full px-2 pb-1">
219
+ <Button variant="ghost" size="icon-sm"><AttachIcon /></Button>
220
+ <Button variant="default" size="sm" disabled={!value()}>Send</Button>
221
+ </PromptInputActions>
222
+ </PromptInput>`),
223
+ };
@@ -0,0 +1,190 @@
1
+ import { type JSX, splitProps, createSignal, createContext, useContext, createEffect, on } from 'solid-js';
2
+ import { cn } from '../utils/cn';
3
+ import { useChatConfig, textClass } from '../primitives/chat-config';
4
+
5
+ // --- Context ---
6
+
7
+ interface PromptInputContextType {
8
+ isLoading: boolean;
9
+ value: () => string;
10
+ setValue: (value: string) => void;
11
+ maxHeight: number | string;
12
+ onSubmit?: () => void;
13
+ disabled?: boolean;
14
+ textareaRef: HTMLTextAreaElement | undefined;
15
+ setTextareaRef: (el: HTMLTextAreaElement) => void;
16
+ }
17
+
18
+ const PromptInputContext = createContext<PromptInputContextType>();
19
+
20
+ function usePromptInput() {
21
+ const ctx = useContext(PromptInputContext);
22
+ if (!ctx) throw new Error('PromptInput subcomponents must be used within PromptInput');
23
+ return ctx;
24
+ }
25
+
26
+ // --- PromptInput (Root) ---
27
+
28
+ export interface PromptInputProps extends JSX.HTMLAttributes<HTMLDivElement> {
29
+ isLoading?: boolean;
30
+ value?: string;
31
+ onValueChange?: (value: string) => void;
32
+ maxHeight?: number | string;
33
+ onSubmit?: () => void;
34
+ children: JSX.Element;
35
+ disabled?: boolean;
36
+ }
37
+
38
+ function PromptInput(props: PromptInputProps) {
39
+ const [local, rest] = splitProps(props, [
40
+ 'isLoading', 'value', 'onValueChange', 'maxHeight', 'onSubmit',
41
+ 'children', 'disabled', 'class', 'onClick',
42
+ ]);
43
+
44
+ const [internalValue, setInternalValue] = createSignal(local.value ?? '');
45
+ let textareaRef: HTMLTextAreaElement | undefined;
46
+
47
+ const handleChange = (newValue: string) => {
48
+ setInternalValue(newValue);
49
+ local.onValueChange?.(newValue);
50
+ };
51
+
52
+ const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (e) => {
53
+ if (!local.disabled) textareaRef?.focus();
54
+ if (typeof local.onClick === 'function') {
55
+ (local.onClick as (e: MouseEvent & { currentTarget: HTMLDivElement }) => void)(e);
56
+ }
57
+ };
58
+
59
+ return (
60
+ <PromptInputContext.Provider
61
+ value={{
62
+ isLoading: local.isLoading ?? false,
63
+ value: () => local.value ?? internalValue(),
64
+ setValue: local.onValueChange ?? handleChange,
65
+ maxHeight: local.maxHeight ?? 240,
66
+ onSubmit: local.onSubmit,
67
+ disabled: local.disabled,
68
+ get textareaRef() { return textareaRef; },
69
+ setTextareaRef: (el) => { textareaRef = el; },
70
+ }}
71
+ >
72
+ <div
73
+ onClick={handleClick}
74
+ class={cn(
75
+ 'bg-muted/40 cursor-text rounded-xl p-2 shadow-xs',
76
+ local.disabled && 'cursor-not-allowed opacity-60',
77
+ local.class
78
+ )}
79
+ {...rest}
80
+ >
81
+ {local.children}
82
+ </div>
83
+ </PromptInputContext.Provider>
84
+ );
85
+ }
86
+
87
+ // --- PromptInputTextarea ---
88
+
89
+ export interface PromptInputTextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
90
+ disableAutosize?: boolean;
91
+ }
92
+
93
+ function PromptInputTextarea(props: PromptInputTextareaProps) {
94
+ const [local, rest] = splitProps(props, ['class', 'onKeyDown', 'disableAutosize']);
95
+ const ctx = usePromptInput();
96
+ const config = useChatConfig();
97
+
98
+ function adjustHeight(el: HTMLTextAreaElement | undefined) {
99
+ if (!el || local.disableAutosize) return;
100
+ el.style.height = 'auto';
101
+ const maxH = ctx.maxHeight;
102
+ if (typeof maxH === 'number') {
103
+ el.style.height = `${Math.min(el.scrollHeight, maxH)}px`;
104
+ } else {
105
+ el.style.height = `min(${el.scrollHeight}px, ${maxH})`;
106
+ }
107
+ }
108
+
109
+ function handleRef(el: HTMLTextAreaElement) {
110
+ ctx.setTextareaRef(el);
111
+ adjustHeight(el);
112
+ }
113
+
114
+ createEffect(on(
115
+ () => [ctx.value(), ctx.maxHeight, local.disableAutosize],
116
+ () => {
117
+ if (ctx.textareaRef && !local.disableAutosize) {
118
+ adjustHeight(ctx.textareaRef);
119
+ }
120
+ }
121
+ ));
122
+
123
+ function handleInput(e: InputEvent & { currentTarget: HTMLTextAreaElement }) {
124
+ adjustHeight(e.currentTarget);
125
+ ctx.setValue(e.currentTarget.value);
126
+ }
127
+
128
+ function handleKeyDown(e: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
129
+ if (e.key === 'Enter' && !e.shiftKey) {
130
+ e.preventDefault();
131
+ ctx.onSubmit?.();
132
+ }
133
+ if (typeof local.onKeyDown === 'function') {
134
+ (local.onKeyDown as (e: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => void)(e);
135
+ }
136
+ }
137
+
138
+ return (
139
+ <textarea
140
+ ref={handleRef}
141
+ value={ctx.value()}
142
+ onInput={handleInput}
143
+ onKeyDown={handleKeyDown}
144
+ class={cn(
145
+ 'text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
146
+ textClass(config.proseSize()),
147
+ local.class
148
+ )}
149
+ rows={1}
150
+ disabled={ctx.disabled}
151
+ {...rest}
152
+ />
153
+ );
154
+ }
155
+
156
+ // --- PromptInputActions ---
157
+
158
+ export interface PromptInputActionsProps extends JSX.HTMLAttributes<HTMLDivElement> {
159
+ children: JSX.Element;
160
+ }
161
+
162
+ function PromptInputActions(props: PromptInputActionsProps) {
163
+ const [local, rest] = splitProps(props, ['children', 'class']);
164
+ return (
165
+ <div class={cn('flex items-center gap-2', local.class)} {...rest}>
166
+ {local.children}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ // --- PromptInputAction ---
172
+
173
+ export interface PromptInputActionProps {
174
+ tooltip?: string;
175
+ children: JSX.Element;
176
+ side?: 'top' | 'bottom' | 'left' | 'right';
177
+ class?: string;
178
+ }
179
+
180
+ function PromptInputAction(props: PromptInputActionProps) {
181
+ return <>{props.children}</>;
182
+ }
183
+
184
+ export {
185
+ PromptInput,
186
+ PromptInputTextarea,
187
+ PromptInputActions,
188
+ PromptInputAction,
189
+ usePromptInput,
190
+ };
@@ -0,0 +1,143 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { fn } from 'storybook/test';
3
+ import { PromptSuggestion } from './prompt-suggestion';
4
+
5
+ const meta = {
6
+ title: 'Components/PromptSuggestion',
7
+ component: PromptSuggestion,
8
+ tags: ['autodocs'],
9
+ parameters: {
10
+ layout: 'padded',
11
+ docs: {
12
+ description: {
13
+ component: [
14
+ 'A clickable suggestion chip built on `Button` — renders as a rounded pill, a full-width list row (`block`), or a search-style row that highlights a matching substring (`highlight`).',
15
+ '**When to use:** offer the user ready-made prompts to send — empty-state starter questions, follow-up suggestions, or a filtered list of matching prompts as they type.',
16
+ '**How to use:** pass the prompt text as children and wire `onClick` to submit it. Use `block` for stacked, left-aligned list rows; pass `highlight` to emphasize a matched substring (forces list-row layout). Override `variant`/`size` to restyle.',
17
+ '**Placement:** chat empty states, below the prompt input as follow-ups, or in a suggestion dropdown.',
18
+ ].join('\n\n'),
19
+ },
20
+ controls: { exclude: ['use:eventListener'] },
21
+ },
22
+ },
23
+ argTypes: {
24
+ children: {
25
+ control: 'text',
26
+ description: 'Suggestion content — the prompt text (or an element).',
27
+ },
28
+ variant: {
29
+ control: 'select',
30
+ options: ['default', 'ghost', 'outline'],
31
+ description: 'Button visual emphasis. Defaults vary by mode (`outline` pill, `ghost` highlight).',
32
+ table: { defaultValue: { summary: 'outline' } },
33
+ },
34
+ size: {
35
+ control: 'select',
36
+ options: ['sm', 'md', 'lg', 'icon', 'icon-sm'],
37
+ description: 'Button size preset. Defaults vary by mode.',
38
+ table: { defaultValue: { summary: 'lg' } },
39
+ },
40
+ highlight: {
41
+ control: 'text',
42
+ description: 'Substring to emphasize within the text. When set, renders as a list row with the match highlighted.',
43
+ },
44
+ block: {
45
+ control: 'boolean',
46
+ description: 'Render as a full-width, left-aligned list row that wraps long text instead of a pill. Ignored in highlight mode.',
47
+ table: { defaultValue: { summary: 'false' } },
48
+ },
49
+ onClick: {
50
+ action: 'click',
51
+ description: 'Fired when the suggestion is clicked.',
52
+ table: { category: 'Events' },
53
+ },
54
+ },
55
+ args: {
56
+ children: 'Tell me about TypeScript',
57
+ variant: 'outline',
58
+ block: false,
59
+ onClick: fn(),
60
+ },
61
+ render: (args) => <PromptSuggestion {...args} />,
62
+ } satisfies Meta<typeof PromptSuggestion>;
63
+
64
+ export default meta;
65
+ type Story = StoryObj<typeof meta>;
66
+
67
+ const IMPORT = `import { PromptSuggestion } from '@kitnai/chat';`;
68
+ const src = (code: string) => ({
69
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
70
+ });
71
+
72
+ /** Interactive playground — tweak the controls to explore every mode. */
73
+ export const Playground: Story = {
74
+ ...src(`<PromptSuggestion onClick={() => send('Tell me about TypeScript')}>
75
+ Tell me about TypeScript
76
+ </PromptSuggestion>`),
77
+ };
78
+
79
+ export const WithHighlight: Story = {
80
+ args: { highlight: 'TypeScript', children: 'Tell me about TypeScript generics' },
81
+ ...src(`<PromptSuggestion highlight="TypeScript">
82
+ Tell me about TypeScript generics
83
+ </PromptSuggestion>`),
84
+ };
85
+
86
+ export const Block: Story = {
87
+ name: 'Block (list idiom)',
88
+ args: { block: true, children: 'What does being a Catalyst mean for how I work with others?' },
89
+ ...src(`<PromptSuggestion block>
90
+ What does being a Catalyst mean for how I work with others?
91
+ </PromptSuggestion>`),
92
+ };
93
+
94
+ /** A row of pill suggestions (showcase — not driven by controls). */
95
+ export const MultipleSuggestions: Story = {
96
+ render: () => (
97
+ <div class="flex flex-wrap gap-2">
98
+ <PromptSuggestion>What is SolidJS?</PromptSuggestion>
99
+ <PromptSuggestion>Explain reactive signals</PromptSuggestion>
100
+ <PromptSuggestion>Compare SolidJS vs React</PromptSuggestion>
101
+ <PromptSuggestion>Best practices for state management</PromptSuggestion>
102
+ </div>
103
+ ),
104
+ ...src(`<div class="flex flex-wrap gap-2">
105
+ <PromptSuggestion>What is SolidJS?</PromptSuggestion>
106
+ <PromptSuggestion>Explain reactive signals</PromptSuggestion>
107
+ <PromptSuggestion>Compare SolidJS vs React</PromptSuggestion>
108
+ <PromptSuggestion>Best practices for state management</PromptSuggestion>
109
+ </div>`),
110
+ };
111
+
112
+ /** Filtered list with a highlighted match (showcase). */
113
+ export const WithHighlightedSearch: Story = {
114
+ render: () => (
115
+ <div class="w-72 space-y-1">
116
+ <PromptSuggestion highlight="solid">How does SolidJS handle reactivity?</PromptSuggestion>
117
+ <PromptSuggestion highlight="solid">What makes SolidJS fast?</PromptSuggestion>
118
+ <PromptSuggestion highlight="solid">SolidJS vs Svelte comparison</PromptSuggestion>
119
+ </div>
120
+ ),
121
+ ...src(`<div class="w-72 space-y-1">
122
+ <PromptSuggestion highlight="solid">How does SolidJS handle reactivity?</PromptSuggestion>
123
+ <PromptSuggestion highlight="solid">What makes SolidJS fast?</PromptSuggestion>
124
+ <PromptSuggestion highlight="solid">SolidJS vs Svelte comparison</PromptSuggestion>
125
+ </div>`),
126
+ };
127
+
128
+ /** Stacked, full-width list rows (showcase). */
129
+ export const BlockList: Story = {
130
+ name: 'Block list',
131
+ render: () => (
132
+ <div class="w-72 flex flex-col gap-1">
133
+ <PromptSuggestion block>What does being a Catalyst mean for how I work with others?</PromptSuggestion>
134
+ <PromptSuggestion block>How do my Dominance and Influence styles play off each other?</PromptSuggestion>
135
+ <PromptSuggestion block>Where might my lower Conscientiousness trip me up?</PromptSuggestion>
136
+ </div>
137
+ ),
138
+ ...src(`<div class="w-72 flex flex-col gap-1">
139
+ <PromptSuggestion block>What does being a Catalyst mean for how I work with others?</PromptSuggestion>
140
+ <PromptSuggestion block>How do my Dominance and Influence styles play off each other?</PromptSuggestion>
141
+ <PromptSuggestion block>Where might my lower Conscientiousness trip me up?</PromptSuggestion>
142
+ </div>`),
143
+ };