Simple AI
blockchat-pagesource: ok · hit
Chat page
Full-screen chat with tools, files, and mentions.
npx shadcn add "https://www.simple-ai.dev/r/chat-page.json"free
Where it goes
chat
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@ai-sdk/react@shadcn/helpersaistreamdown
Registry dependencies
attachmentbubblebuttoncollapsibledropdown-menuemptyinput-groupmarkermessagemessage-scrollerresizablesidebar@simple-ai/chat-input@simple-ai/reasoning@simple-ai/shell@simple-ai/tool@simple-ai/worked
Files13 · source cached 10/14/2026, 4:55:40 PM
packages/registry/registry/blocks/chat-page/page.tsx
"use client";
import { Shell, ShellInset } from "@/components/ui/shell";
import { AppSidebar } from "./components/app-sidebar";
import { FullScreenChat } from "./components/full-screen-chat";
/** Wire a Next or Start route to this file after install. */
export default function ChatPage() {
return (
<Shell sidebar={<AppSidebar activeId="chat" />}>
<ShellInset>
<FullScreenChat />
</ShellInset>
</Shell>
);
}
packages/registry/registry/blocks/chat-page/lib/mock-chat-messages.ts
import { createChat } from "@shadcn/helpers/ai-sdk";
import type { UIMessage } from "ai";
import type { AIDataPart, AIMetadata } from "./ai-types";
export type GalleryChatMessage = UIMessage<AIMetadata, AIDataPart>;
const FORMAT_MINOR_SRC = `export function formatMinor(amount: number) {
return (amount / 100).toFixed(2);
}
`;
const FORMAT_MINOR_INT = `export function formatMinor(amountMinor: number) {
const sign = amountMinor < 0 ? "-" : "";
const abs = Math.abs(Math.trunc(amountMinor));
const dollars = Math.floor(abs / 100);
const cents = abs % 100;
return \`\${sign}\${dollars}.\${cents.toString().padStart(2, "0")}\`;
}
`;
const PARSE_MINOR_SRC = `export function parseMinor(value: string): number {
const trimmed = value.trim();
const match = /^(-?)(\\d+)(?:\\.(\\d{1,2}))?$/.exec(trimmed);
if (!match) {
throw new Error(\`Invalid money: \${value}\`);
}
const sign = match[1] === "-" ? -1 : 1;
const dollars = Number(match[2]);
const cents = Number((match[3] ?? "0").padEnd(2, "0"));
return sign * (dollars * 100 + cents);
}
`;
const FORMAT_MINOR_FIXED = `${FORMAT_MINOR_INT}
${PARSE_MINOR_SRC}`;
const galleryChat = createChat<GalleryChatMessage>()
.user(
"formatMinor is dropping cents on large invoice totals. Find and fix it."
)
.assistant(({ writer }) => {
writer.stepStart();
writer.reasoning(
`The report is lost cents on large invoice totals, which usually means we are formatting minor units with floating point.
I will search for formatMinor, read the helper and every call site, then run the money tests before touching anything. If the tests already pin 9_140_000, that is the number I will use as the reproduction. I should not invent a new API until I know whether callers pass cents or dollars.`
);
writer
.tool("grep", {
dynamic: true,
input: {
pattern: "formatMinor",
path: "/workspace/src",
},
})
.sleep(1300)
.output({
matches: [
{
path: "/workspace/src/lib/money.ts",
line: 1,
text: "export function formatMinor(amount: number) {",
},
{
path: "/workspace/src/lib/money.ts",
line: 2,
text: " return (amount / 100).toFixed(2);",
},
{
path: "/workspace/src/index.ts",
line: 1,
text: "export { formatMinor } from './lib/money';",
},
],
});
writer
.tool("read", {
dynamic: true,
input: { path: "/workspace/src/lib/money.ts" },
})
.sleep(1200)
.output({
path: "/workspace/src/lib/money.ts",
content: FORMAT_MINOR_SRC,
});
writer
.tool("read", {
dynamic: true,
input: { path: "/workspace/src/index.ts" },
})
.sleep(1000)
.output({
path: "/workspace/src/index.ts",
content: "export { formatMinor } from './lib/money';\n",
});
writer.stepStart();
writer.reasoning(
`The implementation is a one-liner: \`(amount / 100).toFixed(2)\`. That looks harmless and is the classic IEEE-754 trap.
\`9_140_000 / 100\` is not 91400 in binary floating point, so \`toFixed(2)\` can emit \`91399.99\`. Call sites only re-export the helper, so a local change in money.ts is enough. Next I will run the existing test file to confirm it already covers this total before I patch.`
);
writer
.tool("bash", {
dynamic: true,
input: { command: "pnpm test -- src/lib/money.test.ts" },
})
.sleep(2200)
.output(
`FAIL src/lib/money.test.ts
× formatMinor › keeps cents on 9_140_000 4ms
Expected: "91400.00"
Received: "91399.99"
Test Files 1 failed (1)
1 failed
`
);
writer.stepStart();
writer.reasoning(
`The failing case is exactly the total from the bug report: expected \`91400.00\`, received \`91399.99\`.
I will stop dividing as a float. Truncate to an integer number of cents, split dollars with \`Math.floor(abs / 100)\` and cents with \`abs % 100\`, then pad cents to two digits. Negative balances need the sign applied after the abs split so \`-50\` stays \`-0.50\` and not \`0.-50\`. Then I re-run the same file.`
);
writer.sleep(700);
writer
.tool("edit", {
dynamic: true,
input: {
path: "/workspace/src/lib/money.ts",
old_string: FORMAT_MINOR_SRC.trim(),
new_string: FORMAT_MINOR_INT.trim(),
},
})
.sleep(1700)
.output({ path: "/workspace/src/lib/money.ts", ok: true });
writer
.tool("bash", {
dynamic: true,
input: { command: "pnpm test -- src/lib/money.test.ts" },
})
.sleep(2000)
.output(
`PASS src/lib/money.test.ts
✓ formatMinor › keeps cents on 9_140_000
✓ formatMinor › negative balances
Test Files 1 passed (1)
2 passed
`
);
writer.stepStart();
writer.reasoning(
"Tests are green, including the negative-balance case. I will summarize the integer split, show the new helper, and leave invoice CSV formatting alone unless they ask. No other call site in src/ was doing its own toFixed."
);
writer.text(
`The helper was dividing minor units as a float and then calling \`toFixed(2)\`. That is fine for small values and wrong once the integer is large enough that \`amount / 100\` is not representable.
**What I changed**
\`src/lib/money.ts\` now splits dollars and cents with integer math:
\`\`\`ts
export function formatMinor(amountMinor: number) {
const sign = amountMinor < 0 ? "-" : "";
const abs = Math.abs(Math.trunc(amountMinor));
const dollars = Math.floor(abs / 100);
const cents = abs % 100;
return \`\${sign}\${dollars}.\${cents.toString().padStart(2, "0")}\`;
}
\`\`\`
**Checks**
- \`pnpm test -- src/lib/money.test.ts\` was failing on \`9_140_000\` (\`91399.99\` vs \`91400.00\`).
- Same file is green after the patch, including a negative-balance case.
Call sites in \`src/index.ts\` still re-export \`formatMinor\`. I did not touch invoice formatting yet — say if you want that wired through the same helper.`
);
})
.user(
"Add parseMinor and export it from the package entry. Cover round-trips in tests."
)
.assistant(({ writer }) => {
writer.stepStart();
writer.data({
type: "data-plan",
data: {
entries: [
{
content: "Read money.ts and the existing test file",
status: "completed",
},
{
content: "Add parseMinor next to formatMinor",
status: "pending",
},
{ content: "Re-export from src/index.ts", status: "pending" },
{ content: "Add round-trip tests and run them", status: "pending" },
],
},
});
writer.reasoning(
`parseMinor should be the inverse of the integer formatter we just landed, not a second float path.
I will read the current helper and the test file, add the parser next to formatMinor, re-export it from the package entry, and add a round-trip on 91400.00 plus a junk-input reject. Fractions with one digit should pad (\`"12.5"\` → 1250). More than two decimal places should throw until they ask for rounding.`
);
writer
.tool("read", {
dynamic: true,
input: { path: "/workspace/src/lib/money.ts" },
})
.sleep(1200)
.output({
path: "/workspace/src/lib/money.ts",
content: FORMAT_MINOR_INT,
});
writer
.tool("glob", {
dynamic: true,
input: { pattern: "**/*money*", path: "/workspace" },
})
.sleep(1000)
.output({
files: [
"/workspace/src/lib/money.ts",
"/workspace/src/lib/money.test.ts",
],
});
writer
.tool("read", {
dynamic: true,
input: { path: "/workspace/src/lib/money.test.ts" },
})
.sleep(1100)
.output({
path: "/workspace/src/lib/money.test.ts",
content: `import { formatMinor } from "./money";
test("formatMinor keeps cents on 9_140_000", () => {
expect(formatMinor(9_140_000)).toBe("91400.00");
});
`,
});
writer.stepStart();
writer.reasoning(
`The helper is still format-only and the test file only covers formatMinor. I will append parseMinor in money.ts, switch the index re-export to named \`formatMinor, parseMinor\`, and extend the test import.
Round-trip on \`"91400.00"\` is the case that failed last turn. I will also reject strings that are not \`(-?)digits(.digits{1,2})?\` so we do not silently drop extra fractional digits.`
);
writer.text(
`I'll add \`parseMinor\` next to the integer formatter, re-export it from \`src/index.ts\`, and cover a round-trip on \`91400.00\`. That writes three files — approve the patch to apply it.`
);
writer.tool("edit", {
dynamic: true,
needsApproval: true,
input: {
path: "/workspace/src/lib/money.ts",
old_string: FORMAT_MINOR_INT.trim(),
new_string: FORMAT_MINOR_FIXED.trim(),
},
output: { path: "/workspace/src/lib/money.ts", ok: true },
});
})
.assistant(({ writer, toolCall }) => {
writer.stepStart();
if (!toolCall?.approved) {
writer.text(
"Left `money.ts` unchanged. Say if you want a different patch."
);
return;
}
writer.reasoning(
"Patch is approved. I will re-export parseMinor, extend the test import, and run the file."
);
writer
.tool("edit", {
dynamic: true,
input: {
path: "/workspace/src/index.ts",
old_string: "export { formatMinor } from './lib/money';",
new_string: "export { formatMinor, parseMinor } from './lib/money';",
},
})
.sleep(1300)
.output({ path: "/workspace/src/index.ts", ok: true });
writer
.tool("edit", {
dynamic: true,
input: {
path: "/workspace/src/lib/money.test.ts",
old_string: `import { formatMinor } from "./money";`,
new_string: `import { formatMinor, parseMinor } from "./money";`,
},
})
.sleep(1200)
.output({ path: "/workspace/src/lib/money.test.ts", ok: true });
writer
.tool("bash", {
dynamic: true,
input: { command: "pnpm test -- src/lib/money.test.ts" },
})
.sleep(2300)
.output(
`PASS src/lib/money.test.ts
✓ formatMinor › keeps cents on 9_140_000
✓ formatMinor › negative balances
✓ parseMinor › round-trip 91400.00
✓ parseMinor › rejects junk
Test Files 1 passed (1)
4 passed
`
);
writer.stepStart();
writer.text(
`\`parseMinor\` now lives next to \`formatMinor\` and the package entry re-exports both.
\`\`\`ts
export function parseMinor(value: string): number {
const trimmed = value.trim();
const match = /^(-?)(\\d+)(?:\\.(\\d{1,2}))?$/.exec(trimmed);
if (!match) {
throw new Error(\`Invalid money: \${value}\`);
}
const sign = match[1] === "-" ? -1 : 1;
const dollars = Number(match[2]);
const cents = Number((match[3] ?? "0").padEnd(2, "0"));
return sign * (dollars * 100 + cents);
}
\`\`\`
**Files**
- \`src/lib/money.ts\` — parser + the integer formatter from the last turn
- \`src/index.ts\` — \`export { formatMinor, parseMinor }\`
- \`src/lib/money.test.ts\` — round-trip on \`91400.00\`, plus a junk-input reject
\`pnpm test -- src/lib/money.test.ts\` is 4/4. One- and two-digit fractions pad to cents (\`"12.5"\` → \`1250\`). Values with more than two decimal places still throw — say if you want bankers rounding instead.`
);
});
const ASSISTANT_RESPONSE_MS = [26_800];
let assistantIndex = -1;
export const initialGalleryMessages = galleryChat.get().map((message) => {
if (message.role !== "assistant") {
return message;
}
assistantIndex += 1;
return {
...message,
metadata: {
createdAt: message.metadata?.createdAt ?? "2026-06-24T12:00:00.000Z",
status: message.metadata?.status ?? "success",
...message.metadata,
responseTime:
message.metadata?.responseTime ?? ASSISTANT_RESPONSE_MS[assistantIndex],
},
};
});
export const galleryChatTransport = galleryChat.transport({
delayMs: 80,
fallback: ({ writer, messages }) => {
const assistantTurns = messages.filter(
(message) => message.role === "assistant"
).length;
const variants = [
() => {
writer.stepStart();
writer.reasoning(
"They want a reminder draft, not another formatter change. I should pull the open-invoices file so the copy uses a real customer, amount, and due date, then format the minor units with the helper we just patched so the dollars match 7_500_000."
);
writer
.tool("grep", {
dynamic: true,
input: {
pattern: "Northside|Bayview",
path: "/workspace/data",
},
})
.sleep(1300)
.output({
matches: [
{
path: "/workspace/data/open-invoices.csv",
line: 2,
text: "INV-1042,Northside Distributors,7500000",
},
],
});
writer
.tool("read", {
dynamic: true,
input: { path: "/workspace/data/open-invoices.csv" },
})
.sleep(1200)
.output({
path: "/workspace/data/open-invoices.csv",
content:
"invoice_id,customer,balance_minor,due\nINV-1042,Northside Distributors,7500000,2026-06-24\nINV-1038,Bayview Supply,1240000,2026-06-26\n",
});
writer.stepStart();
writer.reasoning(
"INV-1042 is Northside Distributors, 7_500_000 minor, due 24 Jun. That is the $75,000 note. I will run formatMinor once so the draft does not hard-code a string that can drift from the helper, then write the email."
);
writer
.tool("bash", {
dynamic: true,
input: {
command:
"pnpm exec tsx -e \"import { formatMinor } from './src/lib/money.ts'; console.log(formatMinor(7500000))\"",
},
})
.sleep(1600)
.output("75000.00\n");
writer.stepStart();
writer.text(
`Here is a collection note you can send as-is:
> Hi Northside team —
>
> Invoice **INV-1042** still has an open balance of **$75,000.00**, due Tuesday 24 Jun. If payment is already on the way, ignore this; otherwise reply with an ETA and I will hold the reminder.
>
> Thanks,
> Receivables
I used \`formatMinor(7_500_000)\` so the amount matches the helper we just patched. I can swap this to Bayview (INV-1038, $12,400.00, due Thursday) or turn it into a template in \`src/lib/money.ts\` if you want it reused.`
);
},
() => {
writer.stepStart();
writer.data({
type: "data-plan",
data: {
entries: [
{
content: "Find invoice formatting call sites",
status: "completed",
},
{ content: "Read the current helper", status: "completed" },
{
content: "Sketch a shared format for CSV export",
status: "completed",
},
],
},
});
writer.reasoning(
"They asked about reuse. I will search for formatMinor, balanceMinor, and leftover toFixed(2) call sites, then decide whether a CSV helper belongs in money.ts or utils.ts. I should not rewrite open-invoices.csv by hand if nothing in src/ is formatting rows yet."
);
writer
.tool("grep", {
dynamic: true,
input: {
pattern: "formatMinor|balanceMinor|toFixed\\(2\\)",
path: "/workspace/src",
},
})
.sleep(1400)
.output({
matches: [
{
path: "/workspace/src/lib/money.ts",
line: 1,
text: "export function formatMinor(amountMinor: number) {",
},
{
path: "/workspace/src/index.ts",
line: 1,
text: "export { formatMinor, parseMinor } from './lib/money';",
},
],
});
writer
.tool("read", {
dynamic: true,
input: { path: "/workspace/src/lib/utils.ts" },
})
.sleep(1000)
.output({
path: "/workspace/src/lib/utils.ts",
content:
"export function cn(...parts: string[]) {\n return parts.filter(Boolean).join(' ');\n}\n",
});
writer.stepStart();
writer.reasoning(
"utils.ts is class-name joining. Money formatting should stay next to formatMinor. Tests are the next check so I do not propose a csvBalance helper on top of a red suite. If they want the helper I can add it in a follow-up with a fixture against open-invoices.csv."
);
writer
.tool("bash", {
dynamic: true,
input: { command: "pnpm test -- src/lib/money.test.ts" },
})
.sleep(1800)
.output(
`PASS src/lib/money.test.ts
✓ 4 passed
`
);
writer.stepStart();
writer.text(
`Nothing else in \`src/\` formats money yet — only the helper and the re-export. A CSV row can go through the same functions:
\`\`\`ts
import { formatMinor, parseMinor } from "./lib/money";
export function csvBalance(minor: number) {
return formatMinor(minor);
}
\`\`\`
I would keep that next to \`formatMinor\` rather than in \`utils.ts\` (\`cn\` is class names, not money). Tests are still green. Want me to add \`csvBalance\` and a fixture against \`open-invoices.csv\`?`
);
},
() => {
writer.stepStart();
writer.reasoning(
"This looks like a restock question, not more money-helper work. I will glob and grep for inventory, stock, SKU, and reorder before I invent a module. If the workspace has none of that, I should say so and offer a small inventory.ts rather than pretending the data lives in money.ts."
);
writer
.tool("glob", {
dynamic: true,
input: {
pattern: "**/*{inventory,stock,sku}*",
path: "/workspace",
},
})
.sleep(1100)
.output({ files: [] });
writer
.tool("grep", {
dynamic: true,
input: {
pattern: "reorder|onHand|SKU",
path: "/workspace",
},
})
.sleep(1300)
.output({ matches: [] });
writer.stepStart();
writer.reasoning(
"No inventory files and no reorder/SKU hits. I will list src/lib to confirm what is actually there, then stop. Offering inventory.ts with a CL-1180 fixture is fine; wiring it into money.ts is not."
);
writer
.tool("bash", {
dynamic: true,
input: { command: "ls /workspace/src/lib" },
})
.sleep(900)
.output("money.ts\nmoney.test.ts\nutils.ts\n");
writer.stepStart();
writer.text(
`There is no inventory module in this workspace — \`src/lib\` is \`money.ts\`, its test, and \`utils.ts\`.
If you want restock suggestions, the next slice is a small \`src/lib/inventory.ts\` that reads on-hand vs reorder point and a test around **CL-1180** (zero on-hand, reorder 12). I can add that, or stay on the money helpers.`
);
},
] as const;
const writeFallback =
variants[assistantTurns % variants.length] ?? variants[0];
writeFallback();
},
});
packages/registry/registry/blocks/chat-page/lib/chat-transport.ts
/**
* Default install uses a mocked transport (no model).
* Point a live transport at your own API when you have one:
*
* import { DefaultChatTransport } from "ai";
* export const chatTransport = new DefaultChatTransport({ api: "/api/chat" });
* export const initialChatMessages = undefined;
*/
export {
type GalleryChatMessage,
galleryChatTransport as chatTransport,
initialGalleryMessages as initialChatMessages,
} from "./mock-chat-messages";
packages/registry/registry/blocks/chat-page/components/app-sidebar.tsx
"use client";
import { BotIcon, HomeIcon, SettingsIcon, SparklesIcon } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarTrigger,
} from "@/components/ui/sidebar";
const ITEMS = [
{ id: "home", label: "Home", icon: HomeIcon },
{ id: "chat", label: "Chat", icon: BotIcon },
{ id: "settings", label: "Settings", icon: SettingsIcon },
] as const;
export function AppSidebar({
activeId = "chat",
}: {
activeId?: (typeof ITEMS)[number]["id"];
}) {
return (
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<SparklesIcon />
</div>
<div className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="block truncate font-semibold">Acme</span>
<span className="block truncate text-muted-foreground text-xs">
Workspace
</span>
</div>
<SidebarTrigger className="hidden size-8 group-data-[collapsible=icon]:hidden md:flex" />
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu className="hidden group-data-[collapsible=icon]:flex">
<SidebarMenuItem>
<SidebarTrigger className="size-8" />
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarMenu>
{ITEMS.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={item.id === activeId}
tooltip={item.label}
>
<item.icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}
packages/registry/registry/blocks/chat-page/components/full-screen-chat.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import {
isTextUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from "ai";
import {
BotIcon,
ClipboardCopyIcon,
MessageCircleDashedIcon,
MoreHorizontalIcon,
PanelRightIcon,
Trash2Icon,
} from "lucide-react";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
ShellHeader,
ShellHeaderActions,
ShellHeaderIcon,
ShellHeaderSidebarTrigger,
ShellHeaderTitle,
} from "@/components/ui/shell";
import { useChatSidePanel } from "../hooks/use-chat-side-panel";
import {
chatTransport,
type GalleryChatMessage,
initialChatMessages,
} from "../lib/chat-transport";
import { GalleryChatInput, type GalleryPromptMessage } from "./chat-input";
import { ChatMessageRow } from "./chat-message-parts";
import { ChatSidePanel } from "./chat-side-panel";
function EmptyConversation() {
return (
<Empty className="h-full border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircleDashedIcon />
</EmptyMedia>
<EmptyTitle>How can I help?</EmptyTitle>
<EmptyDescription>
Ask a question to see tools, files, and mentions.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
export function FullScreenChat() {
const {
messages,
sendMessage,
setMessages,
status,
stop,
addToolApprovalResponse,
} = useChat<GalleryChatMessage>({
throttle: 50,
messages: initialChatMessages,
transport: chatTransport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
const handleToolApproval = useCallback(
(id: string, approved: boolean) =>
addToolApprovalResponse({ id, approved }),
[addToolApprovalResponse]
);
const streamingMessageId =
status === "streaming" && messages.at(-1)?.role === "assistant"
? (messages.at(-1)?.id ?? null)
: null;
const {
panelOpen,
tabs,
activeTab,
activeTabId,
togglePanel,
closePanel,
openToolTab,
closeTab,
setActiveTabId,
} = useChatSidePanel();
const handleSubmit = useCallback(
async (message: GalleryPromptMessage) => {
const text = message.text.trim();
const hasAttachments = Boolean(message.files?.length);
if (!(text || hasAttachments)) {
return;
}
await sendMessage({
files: message.files,
text: text || "Sent with attachments",
});
},
[sendMessage]
);
const handleCopyConversation = useCallback(() => {
const text = messages
.map((message) => {
const body = message.parts
.filter(isTextUIPart)
.map((part) => part.text)
.join("\n");
return `${message.role}: ${body}`;
})
.join("\n\n");
navigator.clipboard.writeText(text).catch(() => undefined);
}, [messages]);
const handleDeleteConversation = useCallback(() => {
stop();
setMessages([]);
}, [setMessages, stop]);
return (
<div
className="@container flex h-full min-h-0 flex-col overflow-hidden bg-background"
data-slot="full-screen-chat"
>
<ResizablePanelGroup
className="min-h-0 flex-1"
data-slot="full-screen-chat-layout"
orientation="horizontal"
>
<ResizablePanel
className="flex min-h-0 flex-col overflow-hidden"
defaultSize={panelOpen ? "68%" : "100%"}
minSize="45%"
>
<ShellHeader className="px-3" data-slot="full-screen-chat-header">
<ShellHeaderSidebarTrigger className="-ml-1" />
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<ShellHeaderIcon>
<BotIcon />
</ShellHeaderIcon>
<ShellHeaderTitle>Assistant</ShellHeaderTitle>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="size-7 shrink-0"
size="icon"
type="button"
variant="ghost"
/>
}
>
<MoreHorizontalIcon />
<span className="sr-only">More actions</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={handleCopyConversation}>
<ClipboardCopyIcon />
Copy conversation
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDeleteConversation}
variant="destructive"
>
<Trash2Icon />
Delete conversation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{panelOpen ? null : (
<ShellHeaderActions>
<Button
className="size-7 shrink-0"
onClick={togglePanel}
size="icon"
type="button"
variant="ghost"
>
<PanelRightIcon />
<span className="sr-only">Open side panel</span>
</Button>
</ShellHeaderActions>
)}
</ShellHeader>
<MessageScrollerProvider autoScroll>
<MessageScroller className="h-0 min-h-0 flex-1">
<MessageScrollerViewport>
<MessageScrollerContent className="mx-auto w-full max-w-3xl gap-6 p-4">
{messages.length === 0 ? (
<EmptyConversation />
) : (
messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<ChatMessageRow
isStreaming={streamingMessageId === message.id}
message={message}
onToolApproval={handleToolApproval}
/>
</MessageScrollerItem>
))
)}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
<GalleryChatInput
onStop={stop}
onSubmit={handleSubmit}
status={status}
/>
</ResizablePanel>
{panelOpen ? (
<>
<ResizableHandle className="bg-transparent" />
<ResizablePanel
className="ml-px flex min-h-0 flex-col overflow-hidden rounded-l-xl border-border border-l bg-accent/5 shadow"
defaultSize="32%"
maxSize="55%"
minSize="22%"
>
<ChatSidePanel
activeTab={activeTab}
activeTabId={activeTabId}
onClosePanel={closePanel}
onCloseTab={closeTab}
onOpenToolTab={openToolTab}
onSelectTab={setActiveTabId}
tabs={tabs}
/>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</div>
);
}
packages/registry/registry/blocks/chat-page/components/chat-input.tsx
"use client";
import type { ChatStatus, FileUIPart } from "ai";
import { FileIcon, PaperclipIcon, XIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import {
ChatInput,
ChatInputEditor,
type ChatInputHandle,
ChatInputMentionButton,
ChatInputSubmitButton,
} from "@/components/ui/chat-input";
import { InputGroupAddon, InputGroupButton } from "@/components/ui/input-group";
import { MOCK_MEMBERS, type MockMember } from "../lib/mock-members";
export interface GalleryPromptMessage {
text: string;
files: FileUIPart[];
members?: MockMember[];
}
type ChatInputFile = FileUIPart & { id: string };
function filePartsFromList(fileList: FileList | File[]): ChatInputFile[] {
return Array.from(fileList).map((file) => ({
id: crypto.randomUUID(),
type: "file" as const,
filename: file.name,
mediaType: file.type,
url: URL.createObjectURL(file),
}));
}
function ChatInputInner({
disabled,
onStop,
onSubmit,
placeholder,
status,
}: {
disabled: boolean;
onStop?: () => void;
onSubmit: (message: GalleryPromptMessage) => void | Promise<void>;
placeholder: string;
status: ChatStatus;
}) {
const [files, setFiles] = useState<ChatInputFile[]>([]);
const inputRef = useRef<ChatInputHandle>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const clearFiles = useCallback(() => {
setFiles((prev) => {
for (const file of prev) {
if (file.url) {
URL.revokeObjectURL(file.url);
}
}
return [];
});
}, []);
const addFiles = useCallback((list: FileList | File[]) => {
setFiles((prev) => [...prev, ...filePartsFromList(list)]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => {
const found = prev.find((file) => file.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((file) => file.id !== id);
});
}, []);
return (
<div className="w-full">
<input
className="hidden"
multiple
onChange={(event) => {
if (event.currentTarget.files?.length) {
addFiles(event.currentTarget.files);
}
event.currentTarget.value = "";
}}
ref={fileInputRef}
type="file"
/>
<ChatInput
className="rounded-2xl"
disabled={disabled}
onStop={onStop}
mentions={{
member: {
trigger: "@",
items: MOCK_MEMBERS,
},
}}
onSubmit={(parsed, { clear }) => {
const trimmed = parsed.text.trim();
if (!(trimmed || files.length > 0)) {
return;
}
const payload: GalleryPromptMessage = {
text: trimmed,
files: files.map(({ id: _id, ...file }) => file),
members: parsed.member,
};
clearFiles();
clear();
Promise.resolve(onSubmit(payload)).catch(() => undefined);
}}
ref={inputRef}
status={status}
>
{files.length > 0 ? (
<InputGroupAddon align="block-start" className="pb-0">
<AttachmentGroup>
{files.map((file) => {
const isImage = Boolean(
file.mediaType?.startsWith("image/") && file.url
);
return (
<Attachment key={file.id} size="xs" state="done">
<AttachmentMedia variant={isImage ? "image" : "icon"}>
{isImage ? (
<img
alt={file.filename ?? ""}
height={24}
src={file.url}
width={24}
/>
) : (
<FileIcon />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>
{file.filename ?? "Attachment"}
</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${file.filename ?? "attachment"}`}
onClick={() => removeFile(file.id)}
>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
);
})}
</AttachmentGroup>
</InputGroupAddon>
) : null}
<ChatInputEditor placeholder={placeholder} />
<InputGroupAddon align="block-end" className="pt-1">
<ChatInputMentionButton />
<InputGroupButton
aria-label="Add files"
onClick={() => fileInputRef.current?.click()}
size="icon-sm"
type="button"
variant="outline"
>
<PaperclipIcon />
</InputGroupButton>
<div className="ml-auto flex items-center gap-2">
<ChatInputSubmitButton />
</div>
</InputGroupAddon>
</ChatInput>
</div>
);
}
export function GalleryChatInput({
disabled = false,
onStop,
onSubmit,
placeholder = "Ask anything...",
status,
}: {
disabled?: boolean;
onStop?: () => void;
onSubmit: (message: GalleryPromptMessage) => void | Promise<void>;
placeholder?: string;
status: ChatStatus;
}) {
return (
<div className="relative bottom-0 z-10 w-full bg-background pt-2">
<div className="mx-auto w-full p-2 @[500px]:px-4 @[500px]:pb-4 md:max-w-3xl @[500px]:md:pb-6">
<ChatInputInner
disabled={disabled}
onStop={onStop}
onSubmit={onSubmit}
placeholder={placeholder}
status={status}
/>
</div>
</div>
);
}
packages/registry/registry/blocks/chat-page/components/chat-message-parts.tsx
"use client";
import {
type DynamicToolUIPart,
isTextUIPart,
isToolUIPart,
type ToolUIPart,
type UIMessagePart,
type UITools,
} from "ai";
import { CheckIcon, CircleIcon, CopyIcon } from "lucide-react";
import { Streamdown } from "streamdown";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker";
import {
Message,
MessageContent,
MessageFooter,
} from "@/components/ui/message";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ui/reasoning";
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ui/tool";
import {
splitWorkedParts,
Worked,
WorkedContent,
WorkedTrigger,
} from "@/components/ui/worked";
import { cn } from "@/lib/utils";
import type { AIDataPart } from "../lib/ai-types";
import type { GalleryChatMessage } from "../lib/mock-chat-messages";
function MarkdownBody({
children,
className,
}: {
children: string;
className?: string;
}) {
return (
<Streamdown
className={cn(
"size-full text-base [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
>
{children}
</Streamdown>
);
}
function PlanPart({
entries,
messageId,
partIndex,
}: {
entries: AIDataPart["plan"]["entries"];
messageId: string;
partIndex: number;
}) {
if (entries.length === 0) {
return null;
}
return (
<div
className="my-2 flex flex-col gap-1"
key={`${messageId}-plan-${partIndex}`}
>
{entries.map((entry, index) => {
const done = entry.status === "completed";
return (
<Marker
// biome-ignore lint/suspicious/noArrayIndexKey: plan entries have no stable id
key={`${messageId}-plan-${partIndex}-${index}`}
>
<MarkerIcon>{done ? <CheckIcon /> : <CircleIcon />}</MarkerIcon>
<MarkerContent className={cn(done && "line-through")}>
{entry.content}
</MarkerContent>
</Marker>
);
})}
</div>
);
}
function DefaultToolPart({
part,
messageId,
partIndex,
onToolApproval,
}: {
part: DynamicToolUIPart | ToolUIPart;
messageId: string;
partIndex: number;
onToolApproval?: (id: string, approved: boolean) => void;
}) {
const toolName =
"toolName" in part && typeof part.toolName === "string"
? part.toolName
: part.type.slice(5);
const approvalId =
part.state === "approval-requested" ? part.approval?.id : undefined;
const deniedReason =
part.state === "output-denied" ? part.approval?.reason : undefined;
return (
<Tool
defaultOpen={part.state === "approval-requested"}
key={`${messageId}-tool-${partIndex}`}
>
<ToolHeader
input={part.input}
state={part.state}
title={toolName}
type={`tool-${toolName}` as ToolUIPart["type"]}
/>
<ToolContent>
<ToolInput input={part.input} />
{part.state === "output-denied" ? (
<p className="text-destructive text-xs">
{deniedReason ? `Denied: ${deniedReason}` : "Denied"}
</p>
) : (
<ToolOutput errorText={part.errorText} output={part.output} />
)}
{approvalId && onToolApproval ? (
<div className="flex gap-2 py-1">
<Button
onClick={() => onToolApproval(approvalId, true)}
size="xs"
type="button"
>
Approve
</Button>
<Button
onClick={() => onToolApproval(approvalId, false)}
size="xs"
type="button"
variant="outline"
>
Deny
</Button>
</div>
) : null}
</ToolContent>
</Tool>
);
}
function GalleryMessagePart({
part,
messageId,
partIndex,
isLastPart,
isStreaming,
role,
onToolApproval,
}: {
part: UIMessagePart<AIDataPart, UITools>;
messageId: string;
partIndex: number;
isLastPart: boolean;
isStreaming: boolean;
role: GalleryChatMessage["role"];
onToolApproval?: (id: string, approved: boolean) => void;
}) {
if (part.type === "text") {
if (role === "user") {
return (
<Bubble align="end" variant="secondary">
<BubbleContent>
<MarkdownBody>{part.text}</MarkdownBody>
</BubbleContent>
</Bubble>
);
}
return (
<Bubble variant="ghost">
<BubbleContent className="w-full max-w-full">
<MarkdownBody>{part.text}</MarkdownBody>
</BubbleContent>
</Bubble>
);
}
if (part.type === "reasoning") {
return (
<Reasoning
className="my-2"
isStreaming={isStreaming && isLastPart}
key={`${messageId}-reasoning-${partIndex}`}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type === "dynamic-tool" || isToolUIPart(part)) {
return (
<DefaultToolPart
key={`${messageId}-tool-${partIndex}`}
messageId={messageId}
onToolApproval={onToolApproval}
part={part as DynamicToolUIPart | ToolUIPart}
partIndex={partIndex}
/>
);
}
if (part.type === "data-plan") {
return (
<PlanPart
entries={part.data.entries}
key={`${messageId}-plan-${partIndex}`}
messageId={messageId}
partIndex={partIndex}
/>
);
}
return null;
}
function workedDurationSeconds(
message: GalleryChatMessage
): number | undefined {
const responseTime = message.metadata?.responseTime;
if (typeof responseTime !== "number" || responseTime <= 0) {
return;
}
return Math.max(1, Math.round(responseTime / 1000));
}
export function ChatMessageRow({
message,
isStreaming = false,
onToolApproval,
}: {
message: GalleryChatMessage;
isStreaming?: boolean;
onToolApproval?: (id: string, approved: boolean) => void;
}) {
const textForCopy = message.parts
.filter(isTextUIPart)
.map((part) => part.text)
.join("\n\n");
const align = message.role === "user" ? "end" : "start";
const lastPartIndex = message.parts.length - 1;
const duration = workedDurationSeconds(message);
const partRow = (
part: GalleryChatMessage["parts"][number],
partIndex: number
) => (
<GalleryMessagePart
isLastPart={partIndex === lastPartIndex}
isStreaming={isStreaming}
key={`${message.id}-part-${partIndex}`}
messageId={message.id}
onToolApproval={onToolApproval}
part={part}
partIndex={partIndex}
role={message.role}
/>
);
return (
<Message align={align}>
<MessageContent>
{message.role === "assistant"
? splitWorkedParts(message.parts).map((segment) => {
if (segment.kind === "worked") {
const start = segment.items[0]?.index ?? 0;
return (
<Worked
duration={duration}
isStreaming={isStreaming}
key={`${message.id}-worked-${start}`}
>
<WorkedTrigger />
<WorkedContent>
{segment.items.map((item) =>
partRow(item.part, item.index)
)}
</WorkedContent>
</Worked>
);
}
return partRow(segment.item.part, segment.item.index);
})
: message.parts.map((part, partIndex) => partRow(part, partIndex))}
{message.role === "assistant" && textForCopy ? (
<MessageFooter>
<Button
aria-label="Copy"
onClick={() => navigator.clipboard.writeText(textForCopy)}
size="icon-xs"
title="Copy"
type="button"
variant="ghost"
>
<CopyIcon className="size-3.5" />
</Button>
</MessageFooter>
) : null}
</MessageContent>
</Message>
);
}
packages/registry/registry/blocks/chat-page/components/chat-side-panel.tsx
"use client";
import {
FileIcon,
FolderTreeIcon,
GlobeIcon,
PanelRightCloseIcon,
PlusIcon,
TerminalIcon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import type {
ChatSidePanelTab,
ChatSidePanelTool,
} from "../hooks/use-chat-side-panel";
import {
MOCK_WORKSPACE_FILE_CONTENT,
MOCK_WORKSPACE_TREE,
} from "../lib/mock-workspace-tree";
import { FileExplorerTree } from "./file-explorer-tree";
const TOOL_OPTIONS: {
tool: ChatSidePanelTool;
label: string;
icon: typeof FolderTreeIcon;
}[] = [
{
tool: "files",
label: "Files",
icon: FolderTreeIcon,
},
{
tool: "browser",
label: "Browser",
icon: GlobeIcon,
},
{
tool: "terminal",
label: "Terminal",
icon: TerminalIcon,
},
];
function tabLabel(tab: ChatSidePanelTab): string {
if (tab.type === "file") {
return tab.name;
}
return (
TOOL_OPTIONS.find((option) => option.tool === tab.type)?.label ?? tab.type
);
}
function tabIcon(tab: ChatSidePanelTab) {
if (tab.type === "file") {
return FileIcon;
}
return (
TOOL_OPTIONS.find((option) => option.tool === tab.type)?.icon ?? FileIcon
);
}
export function ChatSidePanel({
tabs,
activeTabId,
activeTab,
onClosePanel,
onOpenToolTab,
onCloseTab,
onSelectTab,
}: {
tabs: ChatSidePanelTab[];
activeTabId: string | null;
activeTab: ChatSidePanelTab | null;
onClosePanel: () => void;
onOpenToolTab: (tool: ChatSidePanelTool) => void;
onCloseTab: (tabId: string) => void;
onSelectTab: (tabId: string) => void;
}) {
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
return (
<div
className="flex h-full min-h-0 flex-col bg-muted/15"
data-slot="chat-side-panel"
>
<div
className="flex items-center gap-1 border-b bg-background px-2 py-1.5"
data-slot="chat-side-panel-toolbar"
>
{tabs.map((tab) => {
const Icon = tabIcon(tab);
const isActive = tab.id === activeTabId;
return (
<div
className={cn(
"group flex max-w-[180px] shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-xs",
isActive
? "border-border bg-muted text-foreground"
: "border-transparent text-muted-foreground hover:bg-muted/60"
)}
key={tab.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-1.5"
onClick={() => onSelectTab(tab.id)}
type="button"
>
<Icon className="size-3 shrink-0" />
<span className="truncate">{tabLabel(tab)}</span>
</button>
<button
className="rounded-sm p-0.5 opacity-60 hover:bg-background hover:opacity-100"
onClick={() => onCloseTab(tab.id)}
type="button"
>
<XIcon className="size-3" />
<span className="sr-only">Close tab</span>
</button>
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="size-7 shrink-0"
size="icon"
type="button"
variant="ghost"
/>
}
>
<PlusIcon className="size-3.5" />
<span className="sr-only">Open new tab</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
{TOOL_OPTIONS.map(({ tool, label, icon: Icon }) => (
<DropdownMenuItem key={tool} onClick={() => onOpenToolTab(tool)}>
<Icon />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
className="ml-auto size-7"
onClick={onClosePanel}
size="icon"
type="button"
variant="ghost"
>
<PanelRightCloseIcon className="size-3.5" />
<span className="sr-only">Close panel</span>
</Button>
</div>
<div
className="min-h-0 flex-1 overflow-hidden"
data-slot="chat-side-panel-content"
>
{activeTab ? (
<ChatSidePanelTabContent
activeFilePath={activeFilePath}
onOpenFile={(path) => setActiveFilePath(path)}
tab={activeTab}
/>
) : (
<ChatSidePanelEmptyState onOpenToolTab={onOpenToolTab} />
)}
</div>
</div>
);
}
function ChatSidePanelEmptyState({
onOpenToolTab,
}: {
onOpenToolTab: (tool: ChatSidePanelTool) => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-muted-foreground text-sm">
Open a tool to start exploring the assistant workspace.
</p>
<div className="flex flex-wrap justify-center gap-2">
{TOOL_OPTIONS.map(({ tool, label, icon: Icon }) => (
<Button
className="gap-1.5"
key={tool}
onClick={() => onOpenToolTab(tool)}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-3.5" />
{label}
</Button>
))}
</div>
</div>
);
}
function ChatSidePanelTabContent({
tab,
activeFilePath,
onOpenFile,
}: {
tab: ChatSidePanelTab;
activeFilePath: string | null;
onOpenFile: (path: string, name: string) => void;
}) {
if (tab.type === "files") {
const fileName = activeFilePath
? (activeFilePath.split("/").pop() ?? activeFilePath)
: null;
return (
<ResizablePanelGroup
data-slot="chat-side-panel-files-split"
orientation="horizontal"
>
<ResizablePanel className="min-h-0" defaultSize="65%" minSize="40%">
{fileName && activeFilePath ? (
<FileContent name={fileName} path={activeFilePath} />
) : (
<NoFileSelected />
)}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
className="min-h-0 overflow-auto bg-background"
defaultSize="35%"
maxSize="55%"
minSize="20%"
>
<FileExplorerTree
nodes={MOCK_WORKSPACE_TREE}
onOpenFile={onOpenFile}
/>
</ResizablePanel>
</ResizablePanelGroup>
);
}
if (tab.type === "browser") {
return (
<SidePanelPlaceholder
description="Embedded browser sessions will render here — navigate URLs, capture snapshots, and hand context back to the assistant."
title="Browser"
/>
);
}
if (tab.type === "terminal") {
return (
<SidePanelPlaceholder
description="Shell output and command history will appear here once a terminal session is attached to this chat."
title="Terminal"
/>
);
}
return null;
}
function FileContent({ path, name }: { path: string; name: string }) {
const content = MOCK_WORKSPACE_FILE_CONTENT[path];
const extension = name.split(".").pop()?.toLowerCase();
if (extension === "pdf" || extension === "docx") {
return (
<SidePanelPlaceholder
description={`Document preview for ${name} — PDF and Word viewers will open here.`}
title={name}
/>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b px-3 py-2 text-muted-foreground text-xs">
{path}
</div>
<pre className="min-h-0 flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed">
{content ?? `// Placeholder content for ${name}`}
</pre>
</div>
);
}
function NoFileSelected() {
return (
<div
className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center"
data-slot="no-file-selected"
>
<FileIcon className="size-6 text-muted-foreground" />
<p className="font-medium text-sm">No file selected</p>
<p className="max-w-xs text-muted-foreground text-xs">
Select a file from the tree to preview its contents.
</p>
</div>
);
}
function SidePanelPlaceholder({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
<p className="font-medium text-sm">{title}</p>
<p className="max-w-xs text-muted-foreground text-xs">{description}</p>
</div>
);
}
packages/registry/registry/blocks/chat-page/components/file-explorer-tree.tsx
"use client";
import { ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { WorkspaceFileNode } from "../lib/mock-workspace-tree";
export function FileExplorerTree({
nodes,
onOpenFile,
className,
}: {
nodes: WorkspaceFileNode[];
onOpenFile: (path: string, name: string) => void;
className?: string;
}) {
return (
<div
className={cn("flex flex-col gap-0.5 p-2 text-sm", className)}
data-slot="file-explorer-tree"
>
{nodes.map((node) => (
<FileExplorerTreeNode
key={node.path}
node={node}
onOpenFile={onOpenFile}
/>
))}
</div>
);
}
function FileExplorerTreeNode({
node,
depth = 0,
onOpenFile,
}: {
node: WorkspaceFileNode;
depth?: number;
onOpenFile: (path: string, name: string) => void;
}) {
const isFolder = Boolean(node.children?.length);
if (!isFolder) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-muted-foreground hover:bg-muted hover:text-foreground"
data-slot="file-explorer-tree-file"
onClick={() => onOpenFile(node.path, node.name)}
type="button"
>
<FileIcon className="size-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</button>
);
}
return (
<Collapsible defaultOpen={depth < 2}>
<CollapsibleTrigger
className="group flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left hover:bg-muted"
data-slot="file-explorer-tree-folder-trigger"
>
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<FolderIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{node.name}</span>
</CollapsibleTrigger>
<CollapsibleContent
className="ml-3.5 flex flex-col gap-0.5 border-border/60 border-l pl-1.5"
data-slot="file-explorer-tree-folder-content"
>
{node.children?.map((child) => (
<FileExplorerTreeNode
depth={depth + 1}
key={child.path}
node={child}
onOpenFile={onOpenFile}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
packages/registry/registry/blocks/chat-page/hooks/use-chat-side-panel.ts
"use client";
import { useCallback, useState } from "react";
export type ChatSidePanelTool = "files" | "browser" | "terminal";
export type ChatSidePanelTab =
| { id: string; type: ChatSidePanelTool }
| { id: string; type: "file"; path: string; name: string };
export function useChatSidePanel() {
const [panelOpen, setPanelOpen] = useState(false);
const [tabs, setTabs] = useState<ChatSidePanelTab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const openPanel = useCallback(() => {
setPanelOpen(true);
}, []);
const closePanel = useCallback(() => {
setPanelOpen(false);
setTabs([]);
setActiveTabId(null);
}, []);
const togglePanel = useCallback(() => {
if (panelOpen) {
closePanel();
} else {
openPanel();
}
}, [closePanel, openPanel, panelOpen]);
const openToolTab = useCallback(
(tool: ChatSidePanelTool) => {
setPanelOpen(true);
const existing = tabs.find((tab) => tab.type === tool);
if (existing) {
setActiveTabId(existing.id);
return;
}
const id = crypto.randomUUID();
setTabs((current) => [...current, { id, type: tool }]);
setActiveTabId(id);
},
[tabs]
);
const closeTab = useCallback(
(tabId: string) => {
const closedIndex = tabs.findIndex((tab) => tab.id === tabId);
const next = tabs.filter((tab) => tab.id !== tabId);
setTabs(next);
if (activeTabId === tabId) {
const fallback = next[closedIndex] ?? next[closedIndex - 1];
setActiveTabId(fallback?.id ?? null);
}
},
[activeTabId, tabs]
);
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null;
return {
panelOpen,
tabs,
activeTab,
activeTabId,
openPanel,
closePanel,
togglePanel,
openToolTab,
closeTab,
setActiveTabId,
};
}
packages/registry/registry/blocks/chat-page/lib/mock-workspace-tree.ts
export interface WorkspaceFileNode {
name: string;
path: string;
children?: WorkspaceFileNode[];
}
export const MOCK_WORKSPACE_TREE: WorkspaceFileNode[] = [
{
name: "workspace",
path: "/workspace",
children: [
{
name: "src",
path: "/workspace/src",
children: [
{ name: "index.ts", path: "/workspace/src/index.ts" },
{
name: "lib",
path: "/workspace/src/lib",
children: [
{ name: "money.ts", path: "/workspace/src/lib/money.ts" },
{
name: "money.test.ts",
path: "/workspace/src/lib/money.test.ts",
},
{ name: "utils.ts", path: "/workspace/src/lib/utils.ts" },
],
},
{
name: "routes",
path: "/workspace/src/routes",
children: [
{
name: "assistant.tsx",
path: "/workspace/src/routes/assistant.tsx",
},
],
},
],
},
{
name: "docs",
path: "/workspace/docs",
children: [
{ name: "readme.md", path: "/workspace/docs/readme.md" },
{ name: "onboarding.pdf", path: "/workspace/docs/onboarding.pdf" },
],
},
{
name: "data",
path: "/workspace/data",
children: [
{
name: "open-invoices.csv",
path: "/workspace/data/open-invoices.csv",
},
{ name: "customers.docx", path: "/workspace/data/customers.docx" },
],
},
],
},
];
export const MOCK_WORKSPACE_FILE_CONTENT: Record<string, string> = {
"/workspace/src/index.ts":
"export { formatMinor, parseMinor } from './lib/money';\nexport { startAssistant } from './routes/assistant';\n",
"/workspace/src/lib/money.ts": `export function formatMinor(amountMinor: number) {
const sign = amountMinor < 0 ? "-" : "";
const abs = Math.abs(Math.trunc(amountMinor));
const dollars = Math.floor(abs / 100);
const cents = abs % 100;
return \`\${sign}\${dollars}.\${cents.toString().padStart(2, "0")}\`;
}
export function parseMinor(value: string): number {
const trimmed = value.trim();
const match = /^(-?)(\\d+)(?:\\.(\\d{1,2}))?$/.exec(trimmed);
if (!match) {
throw new Error(\`Invalid money: \${value}\`);
}
const sign = match[1] === "-" ? -1 : 1;
const dollars = Number(match[2]);
const cents = Number((match[3] ?? "0").padEnd(2, "0"));
return sign * (dollars * 100 + cents);
}
`,
"/workspace/src/lib/money.test.ts": `import { formatMinor, parseMinor } from "./money";
test("formatMinor keeps cents on 9_140_000", () => {
expect(formatMinor(9_140_000)).toBe("91400.00");
});
test("parseMinor round-trip 91400.00", () => {
expect(parseMinor("91400.00")).toBe(9_140_000);
});
`,
"/workspace/src/lib/utils.ts":
"export function cn(...parts: string[]) {\n return parts.filter(Boolean).join(' ');\n}\n",
"/workspace/src/routes/assistant.tsx":
"export function AssistantRoute() {\n return <FullScreenChat />;\n}\n",
"/workspace/docs/readme.md":
"# Assistant workspace\n\nMock files for the gallery file explorer.\n",
"/workspace/data/open-invoices.csv":
"invoice_id,customer,balance_minor,due\nINV-1042,Northside Distributors,7500000,2026-06-24\nINV-1038,Bayview Supply,1240000,2026-06-26\n",
};
packages/registry/registry/blocks/chat-page/lib/mock-members.ts
export interface MockMember {
id: string;
name: string;
}
export const MOCK_MEMBERS: MockMember[] = [
{ id: "m-alice", name: "Alice Chen" },
{ id: "m-bruno", name: "Bruno Silva" },
{ id: "m-cara", name: "Cara Nguyen" },
{ id: "m-diego", name: "Diego Okonkwo" },
{ id: "m-elena", name: "Elena Rossi" },
];
packages/registry/registry/blocks/chat-page/lib/ai-types.ts
// biome-ignore-all lint/style/useConsistentTypeDefinitions: UIMessage data maps need type-alias index signatures
import type { LanguageModelUsage } from "ai";
export type AIMetadata = {
createdAt: string;
status: "pending" | "success" | "error";
modelId?: string;
usage?: LanguageModelUsage;
responseTime?: number;
summaryText?: string;
};
export type AIDataPart = {
plan: {
entries: Array<{
content: string;
status?: "completed" | "pending";
}>;
};
};