shadimage
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";
    }>;
  };
};

More from Simple AI

Browse all components in this library →