create-kai 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 (176) hide show
  1. package/README.md +311 -0
  2. package/dist/index.js +4433 -0
  3. package/dist/templates/angular/_gitignore +7 -0
  4. package/dist/templates/angular/angular.json +53 -0
  5. package/dist/templates/angular/package.json +30 -0
  6. package/dist/templates/angular/src/app/app.config.ts +11 -0
  7. package/dist/templates/angular/src/app/app.css +36 -0
  8. package/dist/templates/angular/src/app/app.html +51 -0
  9. package/dist/templates/angular/src/app/app.ts +78 -0
  10. package/dist/templates/angular/src/app/components/composer/composer.css +31 -0
  11. package/dist/templates/angular/src/app/components/composer/composer.html +18 -0
  12. package/dist/templates/angular/src/app/components/composer/composer.ts +81 -0
  13. package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.css +3 -0
  14. package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.html +12 -0
  15. package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.ts +15 -0
  16. package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.css +3 -0
  17. package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.html +14 -0
  18. package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.ts +13 -0
  19. package/dist/templates/angular/src/app/components/sidebar/sidebar.css +24 -0
  20. package/dist/templates/angular/src/app/components/sidebar/sidebar.html +12 -0
  21. package/dist/templates/angular/src/app/components/sidebar/sidebar.ts +43 -0
  22. package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.html +13 -0
  23. package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.ts +21 -0
  24. package/dist/templates/angular/src/app/components/thread-view/thread-view.css +6 -0
  25. package/dist/templates/angular/src/app/components/thread-view/thread-view.html +6 -0
  26. package/dist/templates/angular/src/app/components/thread-view/thread-view.ts +45 -0
  27. package/dist/templates/angular/src/app/state/chat.store.ts +47 -0
  28. package/dist/templates/angular/src/app/state/conversations.store.ts +42 -0
  29. package/dist/templates/angular/src/app/state/voice-input.ts +57 -0
  30. package/dist/templates/angular/src/app/types.ts +2 -0
  31. package/dist/templates/angular/src/chat-data.ts +106 -0
  32. package/dist/templates/angular/src/index.html +16 -0
  33. package/dist/templates/angular/src/main.ts +16 -0
  34. package/dist/templates/angular/src/styles.css +10 -0
  35. package/dist/templates/angular/tsconfig.app.json +9 -0
  36. package/dist/templates/angular/tsconfig.json +22 -0
  37. package/dist/templates/nextjs/_gitignore +11 -0
  38. package/dist/templates/nextjs/app/chat-data.ts +125 -0
  39. package/dist/templates/nextjs/app/components/Composer.tsx +88 -0
  40. package/dist/templates/nextjs/app/components/HydrationBadge.tsx +68 -0
  41. package/dist/templates/nextjs/app/components/Sidebar.tsx +48 -0
  42. package/dist/templates/nextjs/app/components/ThemeToggle.tsx +27 -0
  43. package/dist/templates/nextjs/app/components/ThreadView.tsx +48 -0
  44. package/dist/templates/nextjs/app/components/icons/MoonIcon.tsx +25 -0
  45. package/dist/templates/nextjs/app/components/icons/SunIcon.tsx +25 -0
  46. package/dist/templates/nextjs/app/components/icons/index.ts +2 -0
  47. package/dist/templates/nextjs/app/globals.css +153 -0
  48. package/dist/templates/nextjs/app/hooks/index.ts +1 -0
  49. package/dist/templates/nextjs/app/hooks/useConversations.ts +42 -0
  50. package/dist/templates/nextjs/app/layout.tsx +50 -0
  51. package/dist/templates/nextjs/app/page.tsx +17 -0
  52. package/dist/templates/nextjs/app/theme.ts +10 -0
  53. package/dist/templates/nextjs/app/workspace.tsx +148 -0
  54. package/dist/templates/nextjs/next.config.mjs +22 -0
  55. package/dist/templates/nextjs/package.json +23 -0
  56. package/dist/templates/nextjs/postcss.config.mjs +16 -0
  57. package/dist/templates/nextjs/tsconfig.json +23 -0
  58. package/dist/templates/react/_gitignore +6 -0
  59. package/dist/templates/react/index.html +16 -0
  60. package/dist/templates/react/package.json +25 -0
  61. package/dist/templates/react/src/App.tsx +108 -0
  62. package/dist/templates/react/src/chat-data.ts +106 -0
  63. package/dist/templates/react/src/components/Composer.tsx +87 -0
  64. package/dist/templates/react/src/components/Sidebar.tsx +38 -0
  65. package/dist/templates/react/src/components/ThemeToggle.tsx +22 -0
  66. package/dist/templates/react/src/components/ThreadView.tsx +40 -0
  67. package/dist/templates/react/src/components/icons/MoonIcon.tsx +25 -0
  68. package/dist/templates/react/src/components/icons/SunIcon.tsx +25 -0
  69. package/dist/templates/react/src/components/icons/index.ts +2 -0
  70. package/dist/templates/react/src/hooks/index.ts +1 -0
  71. package/dist/templates/react/src/hooks/useConversations.ts +37 -0
  72. package/dist/templates/react/src/index.css +111 -0
  73. package/dist/templates/react/src/main.tsx +15 -0
  74. package/dist/templates/react/src/vite-env.d.ts +1 -0
  75. package/dist/templates/react/tsconfig.app.json +28 -0
  76. package/dist/templates/react/tsconfig.json +7 -0
  77. package/dist/templates/react/tsconfig.node.json +25 -0
  78. package/dist/templates/react/vite.config.ts +11 -0
  79. package/dist/templates/solid/_gitignore +6 -0
  80. package/dist/templates/solid/index.html +13 -0
  81. package/dist/templates/solid/package.json +23 -0
  82. package/dist/templates/solid/src/App.tsx +151 -0
  83. package/dist/templates/solid/src/chat-data.ts +234 -0
  84. package/dist/templates/solid/src/components/Composer.tsx +90 -0
  85. package/dist/templates/solid/src/components/Sidebar.tsx +36 -0
  86. package/dist/templates/solid/src/components/ThemeToggle.tsx +25 -0
  87. package/dist/templates/solid/src/components/ThreadView.tsx +122 -0
  88. package/dist/templates/solid/src/components/icons/index.tsx +84 -0
  89. package/dist/templates/solid/src/index.tsx +5 -0
  90. package/dist/templates/solid/src/lib/chat.ts +64 -0
  91. package/dist/templates/solid/src/lib/conversations.ts +54 -0
  92. package/dist/templates/solid/src/lib/types.ts +2 -0
  93. package/dist/templates/solid/src/styles.css +11 -0
  94. package/dist/templates/solid/tsconfig.json +18 -0
  95. package/dist/templates/solid/vite.config.ts +10 -0
  96. package/dist/templates/svelte/_gitignore +6 -0
  97. package/dist/templates/svelte/index.html +16 -0
  98. package/dist/templates/svelte/package.json +23 -0
  99. package/dist/templates/svelte/src/App.svelte +116 -0
  100. package/dist/templates/svelte/src/app.d.ts +20 -0
  101. package/dist/templates/svelte/src/chat-data.ts +106 -0
  102. package/dist/templates/svelte/src/components/Composer.svelte +102 -0
  103. package/dist/templates/svelte/src/components/Sidebar.svelte +61 -0
  104. package/dist/templates/svelte/src/components/ThemeToggle.svelte +45 -0
  105. package/dist/templates/svelte/src/components/ThreadView.svelte +49 -0
  106. package/dist/templates/svelte/src/components/icons/MoonIcon.svelte +24 -0
  107. package/dist/templates/svelte/src/components/icons/SunIcon.svelte +24 -0
  108. package/dist/templates/svelte/src/index.css +111 -0
  109. package/dist/templates/svelte/src/lib/chat.svelte.ts +52 -0
  110. package/dist/templates/svelte/src/lib/conversations.svelte.ts +46 -0
  111. package/dist/templates/svelte/src/lib/types.ts +2 -0
  112. package/dist/templates/svelte/src/lib/voiceInput.ts +57 -0
  113. package/dist/templates/svelte/src/main.ts +18 -0
  114. package/dist/templates/svelte/svelte.config.js +8 -0
  115. package/dist/templates/svelte/tsconfig.app.json +28 -0
  116. package/dist/templates/svelte/tsconfig.json +7 -0
  117. package/dist/templates/svelte/tsconfig.node.json +25 -0
  118. package/dist/templates/svelte/vite.config.ts +15 -0
  119. package/dist/templates/tanstack-start/_gitignore +14 -0
  120. package/dist/templates/tanstack-start/package.json +28 -0
  121. package/dist/templates/tanstack-start/serve.mjs +129 -0
  122. package/dist/templates/tanstack-start/serve.traversal.test.mjs +445 -0
  123. package/dist/templates/tanstack-start/src/chat-data.ts +124 -0
  124. package/dist/templates/tanstack-start/src/components/Composer.tsx +87 -0
  125. package/dist/templates/tanstack-start/src/components/HydrationBadge.tsx +63 -0
  126. package/dist/templates/tanstack-start/src/components/Sidebar.tsx +43 -0
  127. package/dist/templates/tanstack-start/src/components/ThemeToggle.tsx +26 -0
  128. package/dist/templates/tanstack-start/src/components/ThreadView.tsx +48 -0
  129. package/dist/templates/tanstack-start/src/components/icons/MoonIcon.tsx +25 -0
  130. package/dist/templates/tanstack-start/src/components/icons/SunIcon.tsx +25 -0
  131. package/dist/templates/tanstack-start/src/components/icons/index.ts +2 -0
  132. package/dist/templates/tanstack-start/src/hooks/index.ts +1 -0
  133. package/dist/templates/tanstack-start/src/hooks/useConversations.ts +42 -0
  134. package/dist/templates/tanstack-start/src/router.tsx +16 -0
  135. package/dist/templates/tanstack-start/src/routes/__root.tsx +62 -0
  136. package/dist/templates/tanstack-start/src/routes/index.tsx +135 -0
  137. package/dist/templates/tanstack-start/src/styles.css +153 -0
  138. package/dist/templates/tanstack-start/src/theme.ts +10 -0
  139. package/dist/templates/tanstack-start/tsconfig.json +16 -0
  140. package/dist/templates/tanstack-start/vite.config.ts +28 -0
  141. package/dist/templates/vanilla/_gitignore +6 -0
  142. package/dist/templates/vanilla/index.html +16 -0
  143. package/dist/templates/vanilla/package.json +19 -0
  144. package/dist/templates/vanilla/src/chat-data.ts +106 -0
  145. package/dist/templates/vanilla/src/index.css +111 -0
  146. package/dist/templates/vanilla/src/main.ts +53 -0
  147. package/dist/templates/vanilla/src/state.ts +119 -0
  148. package/dist/templates/vanilla/src/view.ts +222 -0
  149. package/dist/templates/vanilla/src/vite-env.d.ts +1 -0
  150. package/dist/templates/vanilla/src/voice-input.ts +55 -0
  151. package/dist/templates/vanilla/tsconfig.json +24 -0
  152. package/dist/templates/vanilla/vite.config.ts +14 -0
  153. package/dist/templates/vue/_gitignore +6 -0
  154. package/dist/templates/vue/index.html +16 -0
  155. package/dist/templates/vue/package.json +23 -0
  156. package/dist/templates/vue/src/App.vue +104 -0
  157. package/dist/templates/vue/src/chat-data.ts +106 -0
  158. package/dist/templates/vue/src/components/Composer.vue +96 -0
  159. package/dist/templates/vue/src/components/Sidebar.vue +48 -0
  160. package/dist/templates/vue/src/components/ThemeToggle.vue +27 -0
  161. package/dist/templates/vue/src/components/ThreadView.vue +50 -0
  162. package/dist/templates/vue/src/components/icons/MoonIcon.vue +24 -0
  163. package/dist/templates/vue/src/components/icons/SunIcon.vue +24 -0
  164. package/dist/templates/vue/src/composables/index.ts +3 -0
  165. package/dist/templates/vue/src/composables/useChat.ts +47 -0
  166. package/dist/templates/vue/src/composables/useConversations.ts +42 -0
  167. package/dist/templates/vue/src/composables/useVoiceInput.ts +57 -0
  168. package/dist/templates/vue/src/index.css +111 -0
  169. package/dist/templates/vue/src/main.ts +18 -0
  170. package/dist/templates/vue/src/types.ts +4 -0
  171. package/dist/templates/vue/src/vite-env.d.ts +1 -0
  172. package/dist/templates/vue/tsconfig.app.json +29 -0
  173. package/dist/templates/vue/tsconfig.json +7 -0
  174. package/dist/templates/vue/tsconfig.node.json +25 -0
  175. package/dist/templates/vue/vite.config.ts +24 -0
  176. package/package.json +63 -0
@@ -0,0 +1,63 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * THE SERVER-RENDERING PROOF, ON THE PAGE.
5
+ *
6
+ * This starter's original job was to show that a `kai-*` element survives SSR +
7
+ * hydration, and this badge is what makes that visible without opening devtools.
8
+ * It is worth keeping alongside the working chat because HYDRATION FAILURE LOOKS
9
+ * LIKE SUCCESS: TanStack Start can emit perfectly correct HTML and then throw
10
+ * during hydration, leaving a page that reads exactly right and does nothing. A
11
+ * screenshot cannot tell those apart. This badge can, because the only way it
12
+ * turns green is for client JavaScript to have run.
13
+ *
14
+ * HOW IT AVOIDS BEING THE BUG IT REPORTS. The state starts `null` — the same value
15
+ * on the server and on React's first client render — so the two trees agree and
16
+ * hydration has nothing to reconcile. Only afterwards does the effect (which never
17
+ * runs on the server) look at `customElements` and re-render. Reading
18
+ * `customElements` during render instead would produce "none" in the server HTML
19
+ * and "5" in the client tree, i.e. a hydration mismatch inside the hydration
20
+ * detector.
21
+ *
22
+ * The tags are polled on a rAF loop rather than checked once: each React wrapper
23
+ * registers its own element from a layout effect and the element modules load as
24
+ * dynamic imports, so the definitions land over the next few frames rather than
25
+ * all at once.
26
+ */
27
+ const TAGS = ['kai-button', 'kai-conversations', 'kai-thread', 'kai-prompt-input', 'kai-resizable'] as const;
28
+
29
+ export function HydrationBadge() {
30
+ const [defined, setDefined] = useState<number | null>(null);
31
+
32
+ useEffect(() => {
33
+ let raf = 0;
34
+ const check = () => {
35
+ const n = TAGS.filter((t) => !!customElements.get(t)).length;
36
+ setDefined(n);
37
+ if (n < TAGS.length) raf = requestAnimationFrame(check);
38
+ };
39
+ check();
40
+ return () => cancelAnimationFrame(raf);
41
+ }, []);
42
+
43
+ const hydrated = defined === TAGS.length;
44
+ return (
45
+ <span
46
+ className="hydration"
47
+ data-testid="registration-status"
48
+ data-state={hydrated ? 'hydrated' : 'server'}
49
+ title={
50
+ hydrated
51
+ ? `Client JS ran: all ${TAGS.length} kai-* elements are defined in customElements.`
52
+ : 'Server-rendered markup. The kai-* tags are still bare — no client JS has registered them yet.'
53
+ }
54
+ >
55
+ <span className="hydration-dot" aria-hidden />
56
+ {defined === null
57
+ ? 'server-rendered · elements not yet registered'
58
+ : hydrated
59
+ ? `hydrated · ${defined}/${TAGS.length} kai-* elements registered`
60
+ : `hydrating · ${defined}/${TAGS.length} kai-* elements registered`}
61
+ </span>
62
+ );
63
+ }
@@ -0,0 +1,43 @@
1
+ import { Conversations } from '@kitn.ai/ui/react';
2
+ import type { Theme } from '../theme';
3
+ import type { Conversation } from '../chat-data';
4
+
5
+ interface SidebarProps {
6
+ theme: Theme;
7
+ conversations: Conversation[];
8
+ activeId: string;
9
+ collapsed: boolean;
10
+ onSelect: (id: string) => void;
11
+ onNewChat: () => void;
12
+ onToggle: () => void;
13
+ }
14
+
15
+ /**
16
+ * The conversation rail — a thin wrapper over `<kai-conversations>`.
17
+ *
18
+ * `conversations` is an ARRAY, so it crosses as a JS property rather than an HTML
19
+ * attribute. That is the half of the contract server rendering makes visible: the
20
+ * SSR pass emits `<kai-conversations></kai-conversations>` with no `conversations`
21
+ * attribute anywhere, because the wrapper assigns the property from a layout
22
+ * effect that only ever runs in the browser.
23
+ *
24
+ * The `.sidebar` div owns the shell's right border (kept OFF the element so it follows
25
+ * the shell's light/dark tokens, not the light-mode ones the element pins on itself at
26
+ * `:host`).
27
+ */
28
+ export function Sidebar({ theme, conversations, activeId, collapsed, onSelect, onNewChat, onToggle }: SidebarProps) {
29
+ return (
30
+ <aside className="sidebar">
31
+ <Conversations
32
+ theme={theme}
33
+ groups={[]} /* flat list: the element buckets `conversations` by recency itself */
34
+ conversations={conversations}
35
+ activeId={activeId}
36
+ collapsed={collapsed}
37
+ onConversationSelect={(e) => onSelect(e.detail.id)}
38
+ onNewChat={onNewChat}
39
+ onToggleSidebar={onToggle}
40
+ />
41
+ </aside>
42
+ );
43
+ }
@@ -0,0 +1,26 @@
1
+ import { Button } from '@kitn.ai/ui/react';
2
+ import type { Theme } from '../theme';
3
+ import { MoonIcon, SunIcon } from './icons';
4
+
5
+ interface ThemeToggleProps {
6
+ theme: Theme;
7
+ onToggle: () => void;
8
+ }
9
+
10
+ /**
11
+ * Light/dark switch for the top bar. Shows the moon in light mode (tap → dark) and
12
+ * the sun in dark mode. The `<Button>` is icon-only, so it carries the accessible
13
+ * label; the glyph is decorative (`aria-hidden`) and sits in the `icon` slot.
14
+ *
15
+ * The glyph is chosen from `theme`, which is route state seeded to the same value
16
+ * on both sides of hydration — NOT from `prefers-color-scheme`, which the server
17
+ * cannot read and which would therefore render a different icon than the client.
18
+ */
19
+ export function ThemeToggle({ theme, onToggle }: ThemeToggleProps) {
20
+ const Glyph = theme === 'light' ? MoonIcon : SunIcon;
21
+ return (
22
+ <Button theme={theme} variant="ghost" size="icon" label="Toggle light/dark theme" onClick={onToggle}>
23
+ <Glyph slot="icon" aria-hidden />
24
+ </Button>
25
+ );
26
+ }
@@ -0,0 +1,48 @@
1
+ import { Thread } from '@kitn.ai/ui/react';
2
+ import type { ChatMessage } from '@kitn.ai/ui/react';
3
+ import { partsToText } from '@kitn.ai/ui/state';
4
+ import type { Theme } from '../theme';
5
+
6
+ interface ThreadViewProps {
7
+ theme: Theme;
8
+ messages: ChatMessage[];
9
+ }
10
+
11
+ /**
12
+ * The scrolling message list, and the piece that carries the server-rendering
13
+ * proof.
14
+ *
15
+ * `<kai-thread>` (React `<Thread>`) owns the message rendering, the centered
16
+ * fixed-width column and stick-to-bottom scroll. The server emits it as an EMPTY
17
+ * tag — no `messages` attribute, no serialized thread — and the array only arrives
18
+ * once the wrapper's layout effect assigns `el.messages` in the browser. Streaming
19
+ * is that same channel repeated: a NEW array reference per chunk, which is why
20
+ * mutating the existing array in place would render nothing.
21
+ *
22
+ * This component just bakes the per-message actions onto the assistant turns and
23
+ * wires the custom `speak` action to the browser's speech synthesis. `copy` (and
24
+ * the feedback votes) are handled inside the element.
25
+ */
26
+ export function ThreadView({ theme, messages }: ThreadViewProps) {
27
+ // <Thread> reads `actions` off each message; only assistant replies get them.
28
+ const withActions: ChatMessage[] = messages.map((m) =>
29
+ m.role === 'assistant'
30
+ ? { ...m, actions: ['copy', { id: 'speak', label: 'Read aloud', icon: 'volume-2' }] }
31
+ : m,
32
+ );
33
+ return (
34
+ <Thread
35
+ className="thread"
36
+ theme={theme}
37
+ messages={withActions}
38
+ onMessageAction={(e) => {
39
+ if (e.detail.action === 'speak') {
40
+ const m = messages.find((x) => x.id === e.detail.messageId);
41
+ if (!m) return;
42
+ window.speechSynthesis.cancel();
43
+ window.speechSynthesis.speak(new SpeechSynthesisUtterance(partsToText(m.parts)));
44
+ }
45
+ }}
46
+ />
47
+ );
48
+ }
@@ -0,0 +1,25 @@
1
+ import type { SVGProps } from 'react';
2
+
3
+ /**
4
+ * Moon glyph — shown in light mode (tap the toggle → dark). moon/sun aren't in the
5
+ * kit's `<Icon>` set, so the example owns them. Props spread straight onto the
6
+ * `<svg>`, so `slot="icon"`, `className`, and `aria-hidden` pass through and it
7
+ * drops into `<Button>`'s icon slot.
8
+ */
9
+ export function MoonIcon({ size = 20, ...props }: { size?: number } & SVGProps<SVGSVGElement>) {
10
+ return (
11
+ <svg
12
+ viewBox="0 0 24 24"
13
+ width={size}
14
+ height={size}
15
+ fill="none"
16
+ stroke="currentColor"
17
+ strokeWidth={2}
18
+ strokeLinecap="round"
19
+ strokeLinejoin="round"
20
+ {...props}
21
+ >
22
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
23
+ </svg>
24
+ );
25
+ }
@@ -0,0 +1,25 @@
1
+ import type { SVGProps } from 'react';
2
+
3
+ /**
4
+ * Sun glyph — shown in dark mode (tap the toggle → light). See `MoonIcon` for why
5
+ * the example owns these icons. Props spread straight onto the `<svg>`.
6
+ */
7
+ export function SunIcon({ size = 20, ...props }: { size?: number } & SVGProps<SVGSVGElement>) {
8
+ return (
9
+ <svg
10
+ viewBox="0 0 24 24"
11
+ width={size}
12
+ height={size}
13
+ fill="none"
14
+ stroke="currentColor"
15
+ strokeWidth={2}
16
+ strokeLinecap="round"
17
+ strokeLinejoin="round"
18
+ {...props}
19
+ >
20
+ <circle cx="12" cy="12" r="4" />
21
+ <path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" />
22
+ <path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" />
23
+ </svg>
24
+ );
25
+ }
@@ -0,0 +1,2 @@
1
+ export { MoonIcon } from './MoonIcon';
2
+ export { SunIcon } from './SunIcon';
@@ -0,0 +1 @@
1
+ export { useConversations } from './useConversations';
@@ -0,0 +1,42 @@
1
+ import { useCallback, useState } from 'react';
2
+ import type { KaiChatController } from '@kitn.ai/ui/react';
3
+ import { THREADS, newId, type Conversation } from '../chat-data';
4
+
5
+ /**
6
+ * Owns the conversation list + which one is active, plus the in-memory `THREADS`
7
+ * stash: on select/new we swap the OPEN thread out to `THREADS` and swap the
8
+ * picked one in. Takes the `useKaiChat` controller so it can read the live
9
+ * messages when stashing and reset them when loading another thread.
10
+ *
11
+ * In-memory on purpose — this is where a real app would read and write a database
12
+ * from a TanStack server function. Nothing here runs on the server: the hook's
13
+ * initial state is the same on both sides of hydration, and every mutation is
14
+ * driven by a user event.
15
+ */
16
+ export function useConversations(chat: KaiChatController, initial: Conversation[]) {
17
+ const [conversations, setConversations] = useState<Conversation[]>(initial);
18
+ const [activeId, setActiveId] = useState(initial[0].id);
19
+
20
+ const selectConversation = useCallback(
21
+ (id: string) => {
22
+ THREADS[activeId] = chat.messages; // stash the open thread
23
+ setActiveId(id);
24
+ chat.setMessages(THREADS[id] ?? []); // load the picked one
25
+ },
26
+ [activeId, chat],
27
+ );
28
+
29
+ const newChat = useCallback(() => {
30
+ const id = newId();
31
+ THREADS[activeId] = chat.messages;
32
+ THREADS[id] = [];
33
+ setConversations((cs) => [
34
+ { id, title: 'New conversation', scope: { type: 'collection' }, messageCount: 0, lastMessageAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
35
+ ...cs,
36
+ ]);
37
+ setActiveId(id);
38
+ chat.setMessages([]);
39
+ }, [activeId, chat]);
40
+
41
+ return { conversations, activeId, selectConversation, newChat };
42
+ }
@@ -0,0 +1,16 @@
1
+ import { createRouter } from '@tanstack/react-router';
2
+ import { routeTree } from './routeTree.gen';
3
+
4
+ export function getRouter() {
5
+ const router = createRouter({
6
+ routeTree,
7
+ scrollRestoration: true,
8
+ });
9
+ return router;
10
+ }
11
+
12
+ declare module '@tanstack/react-router' {
13
+ interface Register {
14
+ router: ReturnType<typeof getRouter>;
15
+ }
16
+ }
@@ -0,0 +1,62 @@
1
+ import type { ReactNode } from 'react';
2
+ import {
3
+ Outlet,
4
+ createRootRoute,
5
+ HeadContent,
6
+ Scripts,
7
+ } from '@tanstack/react-router';
8
+
9
+ // The kit's design tokens, as PLAIN COMPILED CSS.
10
+ //
11
+ // IT MUST BE `theme.tokens.css`, NOT `theme.css`. They are not two spellings of
12
+ // one file: `@kitn.ai/ui/theme.css` is Tailwind v4 SOURCE — its light tokens live
13
+ // inside an `@theme { … }` block, which is a Tailwind at-rule, not CSS. The test
14
+ // is whether TAILWIND PROCESSES THE FILE, not which specifier you import: this app
15
+ // runs none, so `theme.css` would reach the browser verbatim, where an unknown
16
+ // at-rule is DISCARDED WHOLE. (The Solid starter imports that same file and is
17
+ // right to — it runs Tailwind, so the block is compiled rather than discarded.)
18
+ //
19
+ // The damage is narrow, which is why it survives review. Dark mode is unaffected
20
+ // (those tokens are a plain `.dark` rule), and anything inside a `kai-*` element
21
+ // still resolves. What loses its colours is the chrome OUTSIDE every element —
22
+ // here `html`/`body` and `.app`. It builds green either way.
23
+ //
24
+ // Importing it from the root route is what puts it in the SERVER-rendered <head>:
25
+ // React 19 hoists the stylesheet link out of the module graph and emits it with
26
+ // `data-precedence`, so the SSR'd document already carries
27
+ // `<link rel="stylesheet">` and there is no flash of unstyled custom elements.
28
+ import '@kitn.ai/ui/theme.tokens.css';
29
+ import '../styles.css';
30
+
31
+ export const Route = createRootRoute({
32
+ head: () => ({
33
+ meta: [
34
+ { charSet: 'utf-8' },
35
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
36
+ { title: '@kitn.ai/ui — TanStack Start example' },
37
+ ],
38
+ }),
39
+ component: RootComponent,
40
+ });
41
+
42
+ function RootComponent() {
43
+ return (
44
+ <RootDocument>
45
+ <Outlet />
46
+ </RootDocument>
47
+ );
48
+ }
49
+
50
+ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
51
+ return (
52
+ <html lang="en">
53
+ <head>
54
+ <HeadContent />
55
+ </head>
56
+ <body>
57
+ {children}
58
+ <Scripts />
59
+ </body>
60
+ </html>
61
+ );
62
+ }
@@ -0,0 +1,135 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { createFileRoute } from '@tanstack/react-router';
3
+ import { Button, Resizable, ResizableItem, useKaiChat } from '@kitn.ai/ui/react';
4
+ import { readOpenAIStream } from '@kitn.ai/ui/wire';
5
+ import { CONVERSATIONS, THREADS, SUGGESTIONS, TRIGGERS, newId, mockResponse } from '../chat-data';
6
+ import { Sidebar } from '../components/Sidebar';
7
+ import { ThreadView } from '../components/ThreadView';
8
+ import { Composer } from '../components/Composer';
9
+ import { ThemeToggle } from '../components/ThemeToggle';
10
+ import { HydrationBadge } from '../components/HydrationBadge';
11
+ import { useConversations } from '../hooks';
12
+ import type { Theme } from '../theme';
13
+
14
+ /**
15
+ * A mini chat **workspace composed by hand** from @kitn.ai/ui's individual
16
+ * elements — server-rendered by TanStack Start, then hydrated and streamed into.
17
+ *
18
+ * <Resizable>/<ResizableItem> — the draggable sidebar | main split
19
+ * <Conversations> — the sidebar list (fed `conversations`, emits select/new)
20
+ * <Thread> — the scrolling message list (kai-thread; stick-to-bottom built in)
21
+ * <PromptInput> — the composer at the bottom
22
+ *
23
+ * `useKaiChat` owns the message array + streaming; everything else is plain React
24
+ * state. Swap `mockResponse(text)` for a real `fetch` to ship a real app.
25
+ *
26
+ * WHAT THIS ROUTE PROVES ABOUT SERVER RENDERING
27
+ * ---------------------------------------------
28
+ * This starter exists to show that the `kai-*` web components survive SSR. It used
29
+ * to show that with a static `<Chat>` fed a frozen array. A composed thread that
30
+ * hydrates and then STREAMS proves strictly more, so the demonstration moved here
31
+ * rather than being kept alongside:
32
+ *
33
+ * 1. The server emits BARE TAGS. `@kitn.ai/ui/react` registers each element from
34
+ * a layout effect, and layout effects do not run on the server, so the SSR'd
35
+ * HTML contains `<kai-thread></kai-thread>` and nothing inside it. View source
36
+ * and you will find empty elements — that is correct, not a bug.
37
+ * 2. Array and object props NEVER become attributes. `messages`, `conversations`
38
+ * and `triggers` are assigned as live DOM properties after hydration, so
39
+ * there is no serialize/parse step and no `messages="[{...}]"` in the markup.
40
+ * The static version proved this ONCE, at mount.
41
+ * 3. Streaming proves the channel KEEPS working. Every chunk assigns a new
42
+ * `messages` array reference to the same element that the server rendered
43
+ * empty. A page that hydrated badly can still look right; it cannot stream.
44
+ * The badge in the top bar reports which of those you are looking at.
45
+ *
46
+ * Nothing on this page is fetched from the server: no loader, no server function.
47
+ * The reply comes from the kit's mock responder in the browser. That keeps the SSR
48
+ * question — does the ELEMENT survive hydration — separate from a data-loading
49
+ * question this starter is not about.
50
+ */
51
+ export const Route = createFileRoute('/')({
52
+ component: Home,
53
+ });
54
+
55
+ function Home() {
56
+ const [theme, setTheme] = useState<Theme>('dark');
57
+ const [collapsed, setCollapsed] = useState(false);
58
+ const chat = useKaiChat({ initialMessages: THREADS[CONVERSATIONS[0].id] ?? [] });
59
+ const { conversations, activeId, selectConversation, newChat } = useConversations(chat, CONVERSATIONS);
60
+
61
+ const send = useCallback(
62
+ async (raw: string) => {
63
+ const text = raw.trim();
64
+ if (!text) return;
65
+ // The Composer already cleared its own input; here we just append the user
66
+ // message and stream the (mock) assistant reply.
67
+ chat.append({ id: newId(), role: 'user', parts: [{ type: 'text', text }] });
68
+ const stream = chat.streamAssistant();
69
+ // NO BACKEND AND NO PROVIDER. mockResponse() yields canned SSE frames that
70
+ // go through the SAME reader a real model's response would, so this preview
71
+ // exercises the real path. To go live, only this one expression changes —
72
+ // `mockResponse(text)` becomes a POST to a TanStack server route, with
73
+ // toOpenAIMessages(chat.messages) as the body. The line below stays.
74
+ await readOpenAIStream(mockResponse(text), stream);
75
+ stream.done();
76
+ },
77
+ [chat],
78
+ );
79
+
80
+ return (
81
+ <div className={`app${theme === 'dark' ? ' dark' : ''}`}>
82
+ {/* <kai-resizable> owns the sidebar width + the divider. The handle defaults
83
+ to the `line` hairline (transparent at rest, tinting on hover/drag);
84
+ collapsing the sidebar maps to <ResizableItem collapsed>. */}
85
+ <Resizable theme={theme} orientation="horizontal">
86
+ <ResizableItem theme={theme} size="280px" min="220px" max="420px" collapsed={collapsed}>
87
+ <Sidebar
88
+ theme={theme}
89
+ conversations={conversations}
90
+ activeId={activeId}
91
+ collapsed={collapsed}
92
+ onSelect={selectConversation}
93
+ onNewChat={newChat}
94
+ onToggle={() => setCollapsed((c) => !c)}
95
+ />
96
+ </ResizableItem>
97
+
98
+ <ResizableItem theme={theme}>
99
+ <main className="main">
100
+ <header className="bar">
101
+ <div className="bar-left">
102
+ {collapsed && (
103
+ <Button
104
+ theme={theme}
105
+ variant="ghost"
106
+ size="icon"
107
+ icon="panel-left"
108
+ label="Show sidebar"
109
+ onClick={() => setCollapsed(false)}
110
+ />
111
+ )}
112
+ <span className="brand">@kitn.ai/ui · composed chat on TanStack Start</span>
113
+ </div>
114
+ <div className="bar-right">
115
+ <HydrationBadge />
116
+ <ThemeToggle theme={theme} onToggle={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))} />
117
+ </div>
118
+ </header>
119
+
120
+ <ThreadView theme={theme} messages={chat.messages} />
121
+
122
+ <Composer
123
+ theme={theme}
124
+ loading={chat.loading}
125
+ suggestions={chat.messages.length <= 2 ? SUGGESTIONS : []}
126
+ triggers={TRIGGERS}
127
+ onSubmit={send}
128
+ onSuggestionClick={send}
129
+ />
130
+ </main>
131
+ </ResizableItem>
132
+ </Resizable>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,153 @@
1
+ /* The app shell. Colors come from the kit's `--color-*` tokens, imported in
2
+ `routes/__root.tsx` as `@kitn.ai/ui/theme.tokens.css`.
3
+
4
+ READ THE TOKEN IMPORT NOTE IN `__root.tsx` BEFORE CHANGING IT. The tokens have
5
+ to be the COMPILED stylesheet, not `@kitn.ai/ui/theme.css` — that one is
6
+ Tailwind v4 source, this app runs no Tailwind, and `html`/`body`/`.app` would
7
+ silently lose their light-mode colours with a completely green build. The rules
8
+ that sit INSIDE <kai-resizable> (`.sidebar`, `.bar`, `.composer`) would survive
9
+ it, which is what makes the failure hard to see. */
10
+
11
+ html,
12
+ body {
13
+ height: 100%;
14
+ margin: 0;
15
+ font-family: system-ui, -apple-system, sans-serif;
16
+ background: var(--color-background);
17
+ color: var(--color-foreground);
18
+ }
19
+
20
+ /* Full-height workspace shell. `.dark` is toggled on THIS element rather than on
21
+ <html>, which is what keeps the theme out of the server-rendered document: the
22
+ theme is route state, so the server and the first client render agree on it and
23
+ there is nothing for hydration to disagree about. <kai-resizable> fills this box
24
+ (its `:host` is `height:100%`) and owns the sidebar | main split. */
25
+ .app {
26
+ height: 100dvh;
27
+ background: var(--color-background);
28
+ color: var(--color-foreground);
29
+ }
30
+
31
+ /* The sidebar is its own surface: `--color-muted` sits a step off
32
+ `--color-background` in both themes. That step is subtle in light mode, so a 1px
33
+ right border marks the at-rest division — <kai-resizable>'s `line` handle is
34
+ transparent until hover/drag, so the boundary has to come from the panel itself.
35
+ `box-sizing: border-box` keeps the border inside the 280px item so it doesn't
36
+ shift the content width. */
37
+ .sidebar {
38
+ background: var(--color-muted);
39
+ border-right: 1px solid var(--color-border);
40
+ box-sizing: border-box;
41
+ height: 100%;
42
+ min-height: 0;
43
+ overflow: hidden;
44
+ }
45
+
46
+ .sidebar > * {
47
+ display: block;
48
+ width: 100%;
49
+ height: 100%;
50
+ }
51
+
52
+ .main {
53
+ display: flex;
54
+ flex-direction: column;
55
+ min-width: 0;
56
+ height: 100%;
57
+ }
58
+
59
+ .bar {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ gap: 0.75rem;
64
+ padding: 0.75rem 1rem;
65
+ border-bottom: 1px solid var(--color-border);
66
+ }
67
+
68
+ .bar-left,
69
+ .bar-right {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 0.5rem;
73
+ min-width: 0;
74
+ }
75
+
76
+ .brand {
77
+ font-size: 0.875rem;
78
+ font-weight: 600;
79
+ color: var(--color-muted-foreground);
80
+ white-space: nowrap;
81
+ overflow: hidden;
82
+ text-overflow: ellipsis;
83
+ }
84
+
85
+ /* The hydration badge (see components/HydrationBadge.tsx). Two states, and the
86
+ colours are the point: server-rendered HTML shows the neutral one, and it only
87
+ goes green once a client effect has run. */
88
+ .hydration {
89
+ display: inline-flex;
90
+ align-items: center;
91
+ gap: 0.375rem;
92
+ font-size: 0.75rem;
93
+ font-variant-numeric: tabular-nums;
94
+ padding: 0.2rem 0.5rem;
95
+ border-radius: 999px;
96
+ border: 1px solid var(--color-border);
97
+ background: var(--color-muted);
98
+ color: var(--color-muted-foreground);
99
+ white-space: nowrap;
100
+ }
101
+
102
+ .hydration[data-state='hydrated'] {
103
+ color: var(--color-foreground);
104
+ }
105
+
106
+ .hydration-dot {
107
+ width: 0.5rem;
108
+ height: 0.5rem;
109
+ border-radius: 50%;
110
+ background: var(--color-muted-foreground);
111
+ }
112
+
113
+ .hydration[data-state='hydrated'] .hydration-dot {
114
+ background: #22c55e;
115
+ }
116
+
117
+ /* <kai-thread> scrolls internally and centers its own fixed-width column, so the
118
+ host just needs to fill the remaining height of the main flex column. */
119
+ .thread {
120
+ flex: 1;
121
+ min-height: 0;
122
+ }
123
+
124
+ /* No top divider above the input — the composer floats against the thread. Extra
125
+ bottom padding lifts the input + hint off the viewport edge. */
126
+ .composer {
127
+ padding: 0.75rem 1rem 1.5rem;
128
+ }
129
+
130
+ /* Match the composer to the centered message column above it. */
131
+ .composer > * {
132
+ display: block;
133
+ width: 100%;
134
+ max-width: 48rem;
135
+ margin: 0 auto;
136
+ }
137
+
138
+ .composer-hint {
139
+ margin: 0.5rem auto 0;
140
+ font-size: 0.75rem;
141
+ text-align: center;
142
+ color: var(--color-muted-foreground);
143
+ }
144
+
145
+ .composer-hint kbd {
146
+ font-family: inherit;
147
+ font-size: 0.6875rem;
148
+ padding: 0.05rem 0.3rem;
149
+ border-radius: 0.25rem;
150
+ border: 1px solid var(--color-border);
151
+ background: var(--color-muted);
152
+ color: var(--color-foreground);
153
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The shell's color mode.
3
+ *
4
+ * Its own module rather than an export from the route: `src/routes/*` is compiled
5
+ * by the TanStack Router plugin, which rewrites those files to build the route
6
+ * tree, so components in `src/components/` importing a type back out of a route
7
+ * would point the dependency the wrong way round. Everything shared between the
8
+ * route and its components lives outside `routes/`.
9
+ */
10
+ export type Theme = 'light' | 'dark';