@getjack/jack 0.1.34 → 0.1.36

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 (90) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/down.ts +39 -7
  4. package/src/commands/link.ts +2 -4
  5. package/src/commands/logs.ts +2 -4
  6. package/src/commands/mcp.ts +12 -10
  7. package/src/commands/services.ts +4 -2
  8. package/src/commands/sync.ts +5 -6
  9. package/src/commands/update.ts +1 -0
  10. package/src/index.ts +8 -0
  11. package/src/lib/auth/client.ts +5 -2
  12. package/src/lib/binding-validator.ts +39 -3
  13. package/src/lib/build-helper.ts +18 -19
  14. package/src/lib/control-plane.ts +45 -0
  15. package/src/lib/do-config.ts +110 -0
  16. package/src/lib/do-export-validator.ts +26 -0
  17. package/src/lib/jsonc-edit.ts +292 -0
  18. package/src/lib/managed-deploy.ts +36 -1
  19. package/src/lib/project-link.ts +37 -0
  20. package/src/lib/project-operations.ts +31 -66
  21. package/src/lib/resources.ts +4 -5
  22. package/src/lib/schema.ts +8 -12
  23. package/src/lib/services/db-create.ts +2 -2
  24. package/src/lib/services/db-execute.ts +9 -6
  25. package/src/lib/services/db-list.ts +6 -4
  26. package/src/lib/services/endpoint-test.ts +275 -0
  27. package/src/lib/services/project-delete.ts +190 -0
  28. package/src/lib/services/project-environment.ts +579 -0
  29. package/src/lib/services/storage-config.ts +7 -309
  30. package/src/lib/services/storage-create.ts +2 -1
  31. package/src/lib/services/storage-delete.ts +3 -2
  32. package/src/lib/services/storage-info.ts +2 -1
  33. package/src/lib/services/storage-list.ts +6 -3
  34. package/src/lib/services/vectorize-config.ts +7 -264
  35. package/src/lib/services/vectorize-create.ts +2 -1
  36. package/src/lib/services/vectorize-delete.ts +6 -4
  37. package/src/lib/services/vectorize-list.ts +6 -3
  38. package/src/lib/storage/index.ts +21 -23
  39. package/src/lib/telemetry.ts +1 -0
  40. package/src/lib/wrangler-config.ts +43 -312
  41. package/src/lib/zip-packager.ts +28 -0
  42. package/src/mcp/test-utils.ts +31 -0
  43. package/src/mcp/tools/index.ts +280 -2
  44. package/src/templates/index.ts +5 -0
  45. package/src/templates/types.ts +4 -0
  46. package/templates/AI-BINDINGS.md +34 -76
  47. package/templates/CLAUDE.md +1 -1
  48. package/templates/ai-chat/src/index.ts +7 -14
  49. package/templates/ai-chat/src/jack-ai.ts +0 -6
  50. package/templates/chat/.jack.json +45 -0
  51. package/templates/chat/bun.lock +1584 -0
  52. package/templates/chat/components.json +23 -0
  53. package/templates/chat/index.html +12 -0
  54. package/templates/chat/package.json +41 -0
  55. package/templates/chat/src/chat-agent.ts +63 -0
  56. package/templates/chat/src/client/app.tsx +189 -0
  57. package/templates/chat/src/client/chat.tsx +222 -0
  58. package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
  59. package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
  60. package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
  61. package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
  62. package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
  63. package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
  64. package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
  65. package/templates/chat/src/client/components/ui/button.tsx +38 -0
  66. package/templates/chat/src/client/lib/utils.ts +6 -0
  67. package/templates/chat/src/client/main.tsx +11 -0
  68. package/templates/chat/src/client/styles.css +125 -0
  69. package/templates/chat/src/index.ts +25 -0
  70. package/templates/chat/src/jack-ai.ts +94 -0
  71. package/templates/chat/tsconfig.json +18 -0
  72. package/templates/chat/vite.config.ts +14 -0
  73. package/templates/chat/wrangler.jsonc +18 -0
  74. package/templates/cron/.jack.json +18 -28
  75. package/templates/cron/schema.sql +10 -20
  76. package/templates/cron/src/admin.ts +321 -0
  77. package/templates/cron/src/index.ts +151 -81
  78. package/templates/cron/src/monitor.ts +124 -0
  79. package/templates/semantic-search/src/index.ts +5 -43
  80. package/templates/semantic-search/src/jack-ai.ts +0 -6
  81. package/templates/telegram-bot/.jack.json +56 -0
  82. package/templates/telegram-bot/bun.lock +41 -0
  83. package/templates/telegram-bot/package.json +16 -0
  84. package/templates/telegram-bot/src/index.ts +236 -0
  85. package/templates/telegram-bot/src/jack-ai.ts +100 -0
  86. package/templates/telegram-bot/tsconfig.json +11 -0
  87. package/templates/telegram-bot/wrangler.jsonc +8 -0
  88. package/templates/cron/src/jobs.ts +0 -139
  89. package/templates/cron/src/webhooks.ts +0 -95
  90. package/templates/semantic-search/src/jack-vectorize.ts +0 -169
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/client/styles.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "registries": {}
23
+ }
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>jack-template</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/client/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "jack-template",
3
+ "type": "module",
4
+ "scripts": {
5
+ "dev": "vite",
6
+ "build": "vite build",
7
+ "preview": "vite preview"
8
+ },
9
+ "dependencies": {
10
+ "@ai-sdk/react": "^3.0.88",
11
+ "@cloudflare/ai-chat": "^0.1.0",
12
+ "@tailwindcss/typography": "^0.5.19",
13
+ "agents": "^0.5",
14
+ "ai": "^6",
15
+ "class-variance-authority": "^0.7.1",
16
+ "clsx": "^2.1.1",
17
+ "lucide-react": "^0.564.0",
18
+ "radix-ui": "^1.4.3",
19
+ "react": "^19",
20
+ "react-dom": "^19",
21
+ "react-markdown": "^10.1.0",
22
+ "remark-breaks": "^4.0.0",
23
+ "remark-gfm": "^4.0.1",
24
+ "tailwind-merge": "^3.4.1",
25
+ "use-stick-to-bottom": "^1.1.3",
26
+ "workers-ai-provider": "^3"
27
+ },
28
+ "devDependencies": {
29
+ "@cloudflare/vite-plugin": "^1",
30
+ "@cloudflare/workers-types": "^4.20241205.0",
31
+ "@tailwindcss/vite": "^4",
32
+ "@types/react": "^19",
33
+ "@types/react-dom": "^19",
34
+ "@vitejs/plugin-react": "^4",
35
+ "shadcn": "^3.8.5",
36
+ "tailwindcss": "^4",
37
+ "tw-animate-css": "^1.4.0",
38
+ "typescript": "^5",
39
+ "vite": "^6"
40
+ }
41
+ }
@@ -0,0 +1,63 @@
1
+ import { AIChatAgent } from "@cloudflare/ai-chat";
2
+ import {
3
+ type StreamTextOnFinishCallback,
4
+ type ToolSet,
5
+ convertToModelMessages,
6
+ extractReasoningMiddleware,
7
+ streamText,
8
+ wrapLanguageModel,
9
+ } from "ai";
10
+ import { createWorkersAI } from "workers-ai-provider";
11
+ import { createJackAI } from "./jack-ai";
12
+
13
+ interface Env {
14
+ AI?: Ai;
15
+ __AI_PROXY?: Fetcher;
16
+ ASSETS: Fetcher;
17
+ Chat: DurableObjectNamespace;
18
+ }
19
+
20
+ function getAIProvider(env: Env) {
21
+ if (env.__AI_PROXY) {
22
+ const jackAI = createJackAI(env as { __AI_PROXY: Fetcher });
23
+ return createWorkersAI({ binding: jackAI as unknown as Ai });
24
+ }
25
+ if (env.AI) {
26
+ return createWorkersAI({ binding: env.AI });
27
+ }
28
+ throw new Error("No AI binding available");
29
+ }
30
+
31
+ export class Chat extends AIChatAgent<Env> {
32
+ maxPersistedMessages = 500;
33
+
34
+ async onChatMessage(onFinish: StreamTextOnFinishCallback<ToolSet>) {
35
+ const provider = getAIProvider(this.env);
36
+
37
+ // Fast general-purpose model (recommended default)
38
+ const model = provider(
39
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast" as Parameters<typeof provider>[0],
40
+ );
41
+
42
+ // Reasoning model — uncomment for chain-of-thought (shows thinking process):
43
+ // const baseModel = provider(
44
+ // "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b" as Parameters<typeof provider>[0],
45
+ // );
46
+ // const model = wrapLanguageModel({
47
+ // model: baseModel,
48
+ // middleware: extractReasoningMiddleware({ tagName: "think" }),
49
+ // });
50
+
51
+ const result = streamText({
52
+ model,
53
+ system:
54
+ "You are a helpful assistant. Be concise and direct. " +
55
+ "Use short paragraphs. Only use markdown formatting when it genuinely helps clarity.",
56
+ messages: await convertToModelMessages(this.messages),
57
+ maxOutputTokens: 2048,
58
+ onFinish,
59
+ });
60
+
61
+ return result.toUIMessageStreamResponse();
62
+ }
63
+ }
@@ -0,0 +1,189 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import { MessageSquarePlus, Pencil, Share2 } from "lucide-react";
3
+ import { Suspense, useCallback, useEffect, useRef, useState } from "react";
4
+ import Chat from "./chat";
5
+
6
+ const ADJECTIVES = [
7
+ "Happy",
8
+ "Fuzzy",
9
+ "Cosmic",
10
+ "Crispy",
11
+ "Spicy",
12
+ "Chill",
13
+ "Zesty",
14
+ "Snappy",
15
+ "Bouncy",
16
+ "Groovy",
17
+ ];
18
+ const FRUITS = [
19
+ "Mango",
20
+ "Peach",
21
+ "Lemon",
22
+ "Kiwi",
23
+ "Melon",
24
+ "Berry",
25
+ "Guava",
26
+ "Plum",
27
+ "Fig",
28
+ "Grape",
29
+ ];
30
+
31
+ function generateUsername(): string {
32
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
33
+ const fruit = FRUITS[Math.floor(Math.random() * FRUITS.length)];
34
+ const num = Math.floor(Math.random() * 100);
35
+ return `${adj}${fruit}${num}`;
36
+ }
37
+
38
+ function getOrCreateUsername(): string {
39
+ const stored = localStorage.getItem("jack-chat-username");
40
+ if (stored) return stored;
41
+ const name = generateUsername();
42
+ localStorage.setItem("jack-chat-username", name);
43
+ return name;
44
+ }
45
+
46
+ function generateRoomId(): string {
47
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 12);
48
+ }
49
+
50
+ function getRoomFromPath(): string | null {
51
+ const match = window.location.pathname.match(/^\/room\/([^/]+)/);
52
+ return match ? match[1] : null;
53
+ }
54
+
55
+ export default function App() {
56
+ const [username, setUsername] = useState(getOrCreateUsername);
57
+ const [isEditingName, setIsEditingName] = useState(false);
58
+ const [nameInput, setNameInput] = useState(username);
59
+ const nameInputRef = useRef<HTMLInputElement>(null);
60
+
61
+ const pathRoom = getRoomFromPath();
62
+ const [roomId, setRoomId] = useState<string>(() => pathRoom || generateRoomId());
63
+ const [isSharedRoom, setIsSharedRoom] = useState(!!pathRoom);
64
+
65
+ useEffect(() => {
66
+ if (!getRoomFromPath()) {
67
+ window.history.replaceState(null, "", `/room/${roomId}`);
68
+ }
69
+ }, [roomId]);
70
+
71
+ useEffect(() => {
72
+ const handlePopState = () => {
73
+ const room = getRoomFromPath();
74
+ if (room) {
75
+ setRoomId(room);
76
+ setIsSharedRoom(true);
77
+ }
78
+ };
79
+ window.addEventListener("popstate", handlePopState);
80
+ return () => window.removeEventListener("popstate", handlePopState);
81
+ }, []);
82
+
83
+ const handleNewChat = useCallback(() => {
84
+ const newId = generateRoomId();
85
+ setRoomId(newId);
86
+ setIsSharedRoom(false);
87
+ window.history.pushState(null, "", `/room/${newId}`);
88
+ }, []);
89
+
90
+ const [copied, setCopied] = useState(false);
91
+ const copiedTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
92
+
93
+ const handleShareRoom = useCallback(async () => {
94
+ try {
95
+ await navigator.clipboard.writeText(window.location.href);
96
+ setCopied(true);
97
+ clearTimeout(copiedTimer.current);
98
+ copiedTimer.current = setTimeout(() => setCopied(false), 2000);
99
+ } catch {
100
+ // Fallback: silently fail if clipboard API is unavailable
101
+ }
102
+ }, []);
103
+
104
+ const saveName = () => {
105
+ const trimmed = nameInput.trim();
106
+ if (trimmed) {
107
+ setUsername(trimmed);
108
+ localStorage.setItem("jack-chat-username", trimmed);
109
+ } else {
110
+ setNameInput(username);
111
+ }
112
+ setIsEditingName(false);
113
+ };
114
+
115
+ useEffect(() => {
116
+ if (isEditingName && nameInputRef.current) {
117
+ nameInputRef.current.focus();
118
+ nameInputRef.current.select();
119
+ }
120
+ }, [isEditingName]);
121
+
122
+ const chatContent = isSharedRoom ? (
123
+ <Suspense
124
+ fallback={
125
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
126
+ Loading conversation...
127
+ </div>
128
+ }
129
+ >
130
+ <Chat key={roomId} roomId={roomId} username={username} loadHistory />
131
+ </Suspense>
132
+ ) : (
133
+ <Chat key={roomId} roomId={roomId} username={username} />
134
+ );
135
+
136
+ return (
137
+ <div className="flex h-dvh flex-col bg-background text-foreground">
138
+ <header className="flex items-center justify-between border-b border-border px-4 py-2.5">
139
+ <div className="flex items-center gap-3">
140
+ <h1 className="text-base font-semibold tracking-tight">jack-template</h1>
141
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
142
+ <span className="hidden sm:inline">as</span>
143
+ {isEditingName ? (
144
+ <input
145
+ ref={nameInputRef}
146
+ value={nameInput}
147
+ onChange={(e) => setNameInput(e.target.value)}
148
+ onBlur={saveName}
149
+ onKeyDown={(e) => {
150
+ if (e.key === "Enter") saveName();
151
+ if (e.key === "Escape") {
152
+ setNameInput(username);
153
+ setIsEditingName(false);
154
+ }
155
+ }}
156
+ className="w-24 rounded border border-input bg-secondary px-1.5 py-0.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
157
+ maxLength={20}
158
+ />
159
+ ) : (
160
+ <button
161
+ type="button"
162
+ onClick={() => {
163
+ setNameInput(username);
164
+ setIsEditingName(true);
165
+ }}
166
+ className="flex items-center gap-1 rounded px-1.5 py-0.5 text-foreground hover:bg-secondary transition-colors"
167
+ >
168
+ {username}
169
+ <Pencil className="h-3 w-3 text-muted-foreground" />
170
+ </button>
171
+ )}
172
+ </div>
173
+ </div>
174
+ <div className="flex items-center gap-2">
175
+ <Button variant="ghost" size="sm" onClick={handleShareRoom}>
176
+ <Share2 className="h-4 w-4" />
177
+ {copied ? "Copied!" : "Share"}
178
+ </Button>
179
+ <Button variant="secondary" size="sm" onClick={handleNewChat}>
180
+ <MessageSquarePlus className="h-4 w-4" />
181
+ New Chat
182
+ </Button>
183
+ </div>
184
+ </header>
185
+
186
+ <main className="min-h-0 flex-1">{chatContent}</main>
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,222 @@
1
+ import {
2
+ ChatContainerContent,
3
+ ChatContainerRoot,
4
+ ChatContainerScrollAnchor,
5
+ } from "@/components/prompt-kit/chat-container";
6
+ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
7
+ import { Message, MessageAvatar, MessageContent } from "@/components/prompt-kit/message";
8
+ import { PromptSuggestion } from "@/components/prompt-kit/prompt-suggestion";
9
+ import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/prompt-kit/reasoning";
10
+ import { ScrollButton } from "@/components/prompt-kit/scroll-button";
11
+ import { Button } from "@/components/ui/button";
12
+ import { cn } from "@/lib/utils";
13
+ import { useAgentChat } from "@cloudflare/ai-chat/react";
14
+ import { useAgent } from "agents/react";
15
+ import type { UIMessage } from "ai";
16
+ import { ArrowUp, Square } from "lucide-react";
17
+ import { type KeyboardEvent, useEffect, useRef, useState } from "react";
18
+
19
+ const SUGGESTIONS = [
20
+ "What can you help me with?",
21
+ "Write a haiku about coding",
22
+ "Explain quantum computing simply",
23
+ "Tell me a fun fact",
24
+ ];
25
+
26
+ interface ChatProps {
27
+ roomId: string;
28
+ username: string;
29
+ loadHistory?: boolean;
30
+ }
31
+
32
+ export default function Chat({ roomId, username, loadHistory }: ChatProps) {
33
+ const agent = useAgent({ agent: "chat", name: roomId });
34
+
35
+ const { messages, sendMessage, status, stop } = useAgentChat({
36
+ agent,
37
+ getInitialMessages: loadHistory ? undefined : null,
38
+ });
39
+
40
+ const [input, setInput] = useState("");
41
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
42
+
43
+ const isStreaming = status === "streaming";
44
+ const isLoading = status === "submitted";
45
+
46
+ // Auto-resize textarea
47
+ const inputLength = input.length;
48
+ // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on input change
49
+ useEffect(() => {
50
+ const textarea = textareaRef.current;
51
+ if (textarea) {
52
+ textarea.style.height = "auto";
53
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
54
+ }
55
+ }, [inputLength]);
56
+
57
+ const send = (text?: string) => {
58
+ const msg = text ?? input.trim();
59
+ if (!msg || isStreaming || isLoading) return;
60
+ sendMessage({ text: msg });
61
+ setInput("");
62
+ if (textareaRef.current) {
63
+ textareaRef.current.style.height = "auto";
64
+ }
65
+ };
66
+
67
+ const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
68
+ if (e.key === "Enter" && !e.shiftKey) {
69
+ e.preventDefault();
70
+ send();
71
+ }
72
+ };
73
+
74
+ function getMessageText(message: UIMessage): string {
75
+ return message.parts
76
+ .filter((part) => part.type === "text")
77
+ .map((part) => part.text)
78
+ .join("");
79
+ }
80
+
81
+ function getReasoningText(message: UIMessage): string | null {
82
+ const texts: string[] = [];
83
+ for (const part of message.parts) {
84
+ if (part.type === "reasoning") {
85
+ texts.push(part.text);
86
+ }
87
+ }
88
+ return texts.length > 0 ? texts.join("") : null;
89
+ }
90
+
91
+ const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
92
+ const isWaitingForResponse = isLoading || (isStreaming && lastMessage?.role === "user");
93
+
94
+ const userInitials = username.slice(0, 2).toUpperCase();
95
+
96
+ return (
97
+ <div className="flex h-full flex-col">
98
+ <ChatContainerRoot className="flex-1">
99
+ <ChatContainerContent className="mx-auto max-w-3xl px-4 py-6">
100
+ {messages.length === 0 && (
101
+ <div className="flex flex-1 flex-col items-center justify-center gap-8">
102
+ <div className="max-w-lg space-y-3 text-center">
103
+ <h2 className="text-2xl font-semibold text-foreground">jack-template</h2>
104
+ <p className="text-sm leading-relaxed text-muted-foreground">
105
+ Real-time AI chat with persistent rooms. Share the link — anyone
106
+ who opens it joins this conversation live. No database, no
107
+ WebSocket server, just code.
108
+ </p>
109
+ </div>
110
+ <div className="flex flex-wrap justify-center gap-2">
111
+ {SUGGESTIONS.map((s) => (
112
+ <PromptSuggestion key={s} onClick={() => send(s)}>
113
+ {s}
114
+ </PromptSuggestion>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ )}
119
+
120
+ {messages.map((message: UIMessage, index: number) => {
121
+ const isUser = message.role === "user";
122
+ const text = getMessageText(message);
123
+ const reasoning = isUser ? null : getReasoningText(message);
124
+ const isLast = index === messages.length - 1;
125
+ const isStreamingThis = isLast && !isUser && isStreaming;
126
+
127
+ if (isUser) {
128
+ return (
129
+ <Message key={message.id} className={cn("mb-4 flex-row-reverse")}>
130
+ <MessageAvatar
131
+ initials={userInitials}
132
+ className="bg-primary text-primary-foreground"
133
+ />
134
+ <div className="min-w-0 max-w-[75%]">
135
+ <div className="mb-1 text-right text-xs text-muted-foreground">{username}</div>
136
+ <div className="rounded-2xl rounded-tr-md bg-secondary px-4 py-2.5 text-sm">
137
+ {text}
138
+ </div>
139
+ </div>
140
+ </Message>
141
+ );
142
+ }
143
+
144
+ return (
145
+ <Message key={message.id} className="mb-4">
146
+ <MessageAvatar initials="AI" className="bg-accent text-accent-foreground" />
147
+ <div className="min-w-0 max-w-[75%] space-y-1">
148
+ <div className="text-xs text-muted-foreground">AI</div>
149
+ {reasoning && (
150
+ <Reasoning isStreaming={isStreamingThis}>
151
+ <ReasoningTrigger>Reasoning</ReasoningTrigger>
152
+ <ReasoningContent markdown className="mt-1">
153
+ {reasoning}
154
+ </ReasoningContent>
155
+ </Reasoning>
156
+ )}
157
+ <MessageContent markdown>{isStreamingThis ? `${text}▍` : text}</MessageContent>
158
+ </div>
159
+ </Message>
160
+ );
161
+ })}
162
+
163
+ {isWaitingForResponse && (
164
+ <Message className="mb-4">
165
+ <MessageAvatar initials="AI" className="bg-accent text-accent-foreground" />
166
+ <div className="pt-1">
167
+ <TextShimmerLoader />
168
+ </div>
169
+ </Message>
170
+ )}
171
+
172
+ <ChatContainerScrollAnchor />
173
+ </ChatContainerContent>
174
+
175
+ <div className="pointer-events-none sticky bottom-4 flex justify-center">
176
+ <ScrollButton className="pointer-events-auto" />
177
+ </div>
178
+ </ChatContainerRoot>
179
+
180
+ {/* Input area */}
181
+ <div className="border-t border-border bg-background p-4">
182
+ <div className="mx-auto max-w-3xl">
183
+ <div className="flex items-end gap-2 rounded-2xl border border-input bg-secondary p-2">
184
+ <textarea
185
+ ref={textareaRef}
186
+ value={input}
187
+ onChange={(e) => setInput(e.target.value)}
188
+ onKeyDown={onKeyDown}
189
+ placeholder="Type a message..."
190
+ rows={1}
191
+ className="min-h-[36px] flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-foreground placeholder-muted-foreground focus:outline-none"
192
+ />
193
+ {isStreaming ? (
194
+ <Button
195
+ size="icon"
196
+ variant="ghost"
197
+ onClick={() => stop()}
198
+ className="h-9 w-9 shrink-0 rounded-xl"
199
+ aria-label="Stop generating"
200
+ >
201
+ <Square className="h-4 w-4" />
202
+ </Button>
203
+ ) : (
204
+ <Button
205
+ size="icon"
206
+ onClick={() => send()}
207
+ disabled={!input.trim() || isLoading}
208
+ className="h-9 w-9 shrink-0 rounded-xl"
209
+ aria-label="Send message"
210
+ >
211
+ <ArrowUp className="h-5 w-5" />
212
+ </Button>
213
+ )}
214
+ </div>
215
+ <p className="mt-1.5 text-center text-xs text-muted-foreground/60">
216
+ Shift+Enter for new line
217
+ </p>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,47 @@
1
+ import { cn } from "@/lib/utils";
2
+ import { StickToBottom } from "use-stick-to-bottom";
3
+
4
+ function ChatContainerRoot({
5
+ children,
6
+ className,
7
+ ...props
8
+ }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLDivElement>) {
9
+ return (
10
+ <StickToBottom
11
+ className={cn("flex overflow-y-auto", className)}
12
+ resize="smooth"
13
+ initial="instant"
14
+ role="log"
15
+ {...props}
16
+ >
17
+ {children}
18
+ </StickToBottom>
19
+ );
20
+ }
21
+
22
+ function ChatContainerContent({
23
+ children,
24
+ className,
25
+ ...props
26
+ }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLDivElement>) {
27
+ return (
28
+ <StickToBottom.Content className={cn("flex w-full flex-col", className)} {...props}>
29
+ {children}
30
+ </StickToBottom.Content>
31
+ );
32
+ }
33
+
34
+ function ChatContainerScrollAnchor({
35
+ className,
36
+ ...props
37
+ }: { className?: string } & React.HTMLAttributes<HTMLDivElement>) {
38
+ return (
39
+ <div
40
+ className={cn("h-px w-full shrink-0 scroll-mt-4", className)}
41
+ aria-hidden="true"
42
+ {...props}
43
+ />
44
+ );
45
+ }
46
+
47
+ export { ChatContainerRoot, ChatContainerContent, ChatContainerScrollAnchor };
@@ -0,0 +1,33 @@
1
+ import { cn } from "@/lib/utils";
2
+
3
+ function TypingLoader({
4
+ className,
5
+ size = "md",
6
+ }: { className?: string; size?: "sm" | "md" | "lg" }) {
7
+ const dotSizes = { sm: "h-1 w-1", md: "h-1.5 w-1.5", lg: "h-2 w-2" };
8
+ return (
9
+ <div className={cn("flex items-center space-x-1", className)}>
10
+ {[0, 1, 2].map((i) => (
11
+ <div
12
+ key={i}
13
+ className={cn("bg-muted-foreground/60 animate-bounce rounded-full", dotSizes[size])}
14
+ style={{ animationDelay: `${i * 150}ms`, animationDuration: "1s" }}
15
+ />
16
+ ))}
17
+ <span className="sr-only">Loading</span>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function TextShimmerLoader({
23
+ text = "Thinking",
24
+ className,
25
+ }: { text?: string; className?: string }) {
26
+ return (
27
+ <span className={cn("animate-pulse text-sm font-medium text-muted-foreground", className)}>
28
+ {text}...
29
+ </span>
30
+ );
31
+ }
32
+
33
+ export { TypingLoader, TextShimmerLoader };