diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 43c994c..b3529c9 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + @AGENTS.md + +> The import above is load-bearing: this is a **customized Next.js 16** with breaking changes vs. +> public docs. Before writing any Next.js code (routing, metadata, `next/image`, `next/font`, +> route handlers, config), read the relevant guide under `node_modules/next/dist/docs/01-app/`. + +## What this is + +`temetro` — an open-source AI chat that lets clinicians retrieve patient information in natural +language. The product surface is currently **UI-only**: the chat appends a mock assistant reply +(`components/chat/chat-panel.tsx`); there is no backend, model call, or patient data yet. + +## Commands + +```bash +npm run dev # Next dev server (Turbopack) on http://localhost:3000 +npm run build # production build (runs a full TypeScript typecheck — see caveat below) +npm run start # serve the production build +npm run lint # eslint (eslint-config-next: core-web-vitals + typescript) +``` + +There is **no test runner** configured (no `test` script, no vitest/jest/playwright). Don't invent +test commands; verify changes by running the dev server. + +**Version control:** this folder is its own git repo. Commit after every change +(`git -C . add -A && git commit`), with the `Co-Authored-By: Claude Opus 4.8 ` +trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit policy. + +## Architecture + +- **Stack:** Next.js 16.2.6 (App Router) · React 19 · TypeScript · Tailwind CSS **v4**. Path alias + `@/*` → repo root (`tsconfig.json`). `cn()` (clsx + tailwind-merge) lives in `lib/utils.ts`. +- **`app/`** — App Router. `app/page.tsx` is the product chat page (sidebar + chat panel); + `app/layout.tsx` **forces dark mode** by putting `dark` on `` and loads the fonts. +- **`components/ui/`** — shadcn components, but built on **Base UI (`@base-ui/react`), not Radix.** + APIs differ: composition uses the `render` prop and `useRender`/`mergeProps`, not `asChild`. + Match existing files when adding/editing primitives. Config in `components.json` (style + `base-luma`, baseColor `neutral`). +- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`, + `Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`, + `ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state + is managed with local React state. +- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` / + `SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav/teams config. +- **`components/chat/`** — the product chat UI. `chat-panel.tsx` owns message state + empty/active + layouts; `chat-input.tsx` is a bespoke (non–ai-elements) input matching a specific design. + +## Theming + +Tailwind v4 with `@theme inline` and **oklch CSS variables** in `app/globals.css` (a +Linear-inspired dark palette; `--primary` is indigo `#5e6ad2`). The app is dark-only. The radius +scale (`rounded-2xl` … `rounded-4xl`) is derived from `--radius`, so those utilities are larger than +stock Tailwind. + +## Gotchas + +- `npm run build` currently **fails its typecheck** on a pre-existing error in + `components/ai-elements/attachments.tsx` (`openDelay` prop). `npm run dev` (Turbopack) is + unaffected because it only compiles the imported route and doesn't typecheck the whole tree. +- **`lucide-react@1.17` dropped brand glyphs** (e.g. `Github`, `Discord`) — import them and you get + a build error. Use inline SVGs instead. +- A sibling **`../landing-page/`** directory is a copy of this app with a marketing landing page + (`components/landing/`); it is a separate project, not part of this git repo. +- Multiple lockfiles in the tree produce a harmless Turbopack "inferred workspace root" warning. diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx new file mode 100644 index 0000000..8d21d77 --- /dev/null +++ b/frontend/app/(app)/layout.tsx @@ -0,0 +1,17 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+ + {children} +
+
+ ); +} diff --git a/frontend/app/(app)/page.tsx b/frontend/app/(app)/page.tsx new file mode 100644 index 0000000..3169314 --- /dev/null +++ b/frontend/app/(app)/page.tsx @@ -0,0 +1,10 @@ +import { SidebarInset } from "@/components/ui/sidebar"; +import { ChatPanel } from "@/components/chat/chat-panel"; + +export default function Home() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx new file mode 100644 index 0000000..6c392a4 --- /dev/null +++ b/frontend/app/(app)/settings/page.tsx @@ -0,0 +1,10 @@ +import { SidebarInset } from "@/components/ui/sidebar"; +import { SettingsView } from "@/components/settings/settings-view"; + +export default function SettingsPage() { + return ( + + + + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a2dc41e..f15e306 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,26 +1,131 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); + --font-sans: var(--font-sans); --font-mono: var(--font-geist-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.488 0.243 264.376); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --radius: 0.875rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); +} + +/* Linear-inspired dark palette: near-black canvas, #5e6ad2 indigo accent, hairline borders. */ +.dark { + --background: oklch(0.146 0.005 277); + --foreground: oklch(0.98 0.002 277); + --card: oklch(0.193 0.005 277); + --card-foreground: oklch(0.98 0.002 277); + --popover: oklch(0.193 0.005 277); + --popover-foreground: oklch(0.98 0.002 277); + --primary: oklch(0.556 0.155 277); + --primary-foreground: oklch(0.985 0.001 277); + --secondary: oklch(0.225 0.006 277); + --secondary-foreground: oklch(0.98 0.002 277); + --muted: oklch(0.225 0.006 277); + --muted-foreground: oklch(0.652 0.012 268); + --accent: oklch(0.245 0.008 277); + --accent-foreground: oklch(0.98 0.002 277); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 8%); + --input: oklch(1 0 0 / 10%); + --ring: oklch(0.556 0.155 277); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.165 0.005 277); + --sidebar-foreground: oklch(0.98 0.002 277); + --sidebar-primary: oklch(0.556 0.155 277); + --sidebar-primary-foreground: oklch(0.985 0.001 277); + --sidebar-accent: oklch(0.245 0.008 277); + --sidebar-accent-foreground: oklch(0.98 0.002 277); + --sidebar-border: oklch(1 0 0 / 8%); + --sidebar-ring: oklch(0.556 0.155 277); +} + +@layer base { + * { + @apply border-border outline-ring/50; } -} - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 976eb90..8f8a108 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Geist, Geist_Mono, Inter } from "next/font/google"; import "./globals.css"; +import { cn } from "@/lib/utils"; + +const inter = Inter({subsets:['latin'],variable:'--font-sans'}); const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +16,9 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "temetro — AI assistant for clinicians", + description: + "Retrieve patient information by simply asking. The open-source AI assistant for clinicians.", }; export default function RootLayout({ @@ -25,7 +29,7 @@ export default function RootLayout({ return ( {children} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx deleted file mode 100644 index 3f36f7c..0000000 --- a/frontend/app/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); -} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..a968803 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-luma", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@blocks-so": "https://blocks.so/r/{name}.json" + } +} diff --git a/frontend/components/ai-elements/agent.tsx b/frontend/components/ai-elements/agent.tsx new file mode 100644 index 0000000..86dbcc0 --- /dev/null +++ b/frontend/components/ai-elements/agent.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Tool } from "ai"; +import { BotIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { memo } from "react"; + +import { CodeBlock } from "./code-block"; + +export type AgentProps = ComponentProps<"div">; + +export const Agent = memo(({ className, ...props }: AgentProps) => ( +
+)); + +export type AgentHeaderProps = ComponentProps<"div"> & { + name: string; + model?: string; +}; + +export const AgentHeader = memo( + ({ className, name, model, ...props }: AgentHeaderProps) => ( +
+
+ + {name} + {model && ( + + {model} + + )} +
+
+ ) +); + +export type AgentContentProps = ComponentProps<"div">; + +export const AgentContent = memo( + ({ className, ...props }: AgentContentProps) => ( +
+ ) +); + +export type AgentInstructionsProps = ComponentProps<"div"> & { + children: string; +}; + +export const AgentInstructions = memo( + ({ className, children, ...props }: AgentInstructionsProps) => ( +
+ + Instructions + +
+

{children}

+
+
+ ) +); + +export type AgentToolsProps = ComponentProps; + +export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => ( +
+ Tools + +
+)); + +export type AgentToolProps = ComponentProps & { + tool: Tool; +}; + +export const AgentTool = memo( + ({ className, tool, value, ...props }: AgentToolProps) => { + const schema = + "jsonSchema" in tool && tool.jsonSchema + ? tool.jsonSchema + : tool.inputSchema; + + return ( + + + {tool.description ?? "No description"} + + +
+ +
+
+
+ ); + } +); + +export type AgentOutputProps = ComponentProps<"div"> & { + schema: string; +}; + +export const AgentOutput = memo( + ({ className, schema, ...props }: AgentOutputProps) => ( +
+ + Output Schema + +
+ +
+
+ ) +); + +Agent.displayName = "Agent"; +AgentHeader.displayName = "AgentHeader"; +AgentContent.displayName = "AgentContent"; +AgentInstructions.displayName = "AgentInstructions"; +AgentTools.displayName = "AgentTools"; +AgentTool.displayName = "AgentTool"; +AgentOutput.displayName = "AgentOutput"; diff --git a/frontend/components/ai-elements/artifact.tsx b/frontend/components/ai-elements/artifact.tsx new file mode 100644 index 0000000..0597d4a --- /dev/null +++ b/frontend/components/ai-elements/artifact.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/frontend/components/ai-elements/attachments.tsx b/frontend/components/ai-elements/attachments.tsx new file mode 100644 index 0000000..d4e4e34 --- /dev/null +++ b/frontend/components/ai-elements/attachments.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, SourceDocumentUIPart } from "ai"; +import { + FileTextIcon, + GlobeIcon, + ImageIcon, + Music2Icon, + PaperclipIcon, + VideoIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export type AttachmentData = + | (FileUIPart & { id: string }) + | (SourceDocumentUIPart & { id: string }); + +export type AttachmentMediaCategory = + | "image" + | "video" + | "audio" + | "document" + | "source" + | "unknown"; + +export type AttachmentVariant = "grid" | "inline" | "list"; + +const mediaCategoryIcons: Record = { + audio: Music2Icon, + document: FileTextIcon, + image: ImageIcon, + source: GlobeIcon, + unknown: PaperclipIcon, + video: VideoIcon, +}; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export const getMediaCategory = ( + data: AttachmentData +): AttachmentMediaCategory => { + if (data.type === "source-document") { + return "source"; + } + + const mediaType = data.mediaType ?? ""; + + if (mediaType.startsWith("image/")) { + return "image"; + } + if (mediaType.startsWith("video/")) { + return "video"; + } + if (mediaType.startsWith("audio/")) { + return "audio"; + } + if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { + return "document"; + } + + return "unknown"; +}; + +export const getAttachmentLabel = (data: AttachmentData): string => { + if (data.type === "source-document") { + return data.title || data.filename || "Source"; + } + + const category = getMediaCategory(data); + return data.filename || (category === "image" ? "Image" : "Attachment"); +}; + +const renderAttachmentImage = ( + url: string, + filename: string | undefined, + isGrid: boolean +) => + isGrid ? ( + {filename + ) : ( + {filename + ); + +// ============================================================================ +// Contexts +// ============================================================================ + +interface AttachmentsContextValue { + variant: AttachmentVariant; +} + +const AttachmentsContext = createContext(null); + +interface AttachmentContextValue { + data: AttachmentData; + mediaCategory: AttachmentMediaCategory; + onRemove?: () => void; + variant: AttachmentVariant; +} + +const AttachmentContext = createContext(null); + +// ============================================================================ +// Hooks +// ============================================================================ + +export const useAttachmentsContext = () => + useContext(AttachmentsContext) ?? { variant: "grid" as const }; + +export const useAttachmentContext = () => { + const ctx = useContext(AttachmentContext); + if (!ctx) { + throw new Error("Attachment components must be used within "); + } + return ctx; +}; + +// ============================================================================ +// Attachments - Container +// ============================================================================ + +export type AttachmentsProps = HTMLAttributes & { + variant?: AttachmentVariant; +}; + +export const Attachments = ({ + variant = "grid", + className, + children, + ...props +}: AttachmentsProps) => { + const contextValue = useMemo(() => ({ variant }), [variant]); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// Attachment - Item +// ============================================================================ + +export type AttachmentProps = HTMLAttributes & { + data: AttachmentData; + onRemove?: () => void; +}; + +export const Attachment = ({ + data, + onRemove, + className, + children, + ...props +}: AttachmentProps) => { + const { variant } = useAttachmentsContext(); + const mediaCategory = getMediaCategory(data); + + const contextValue = useMemo( + () => ({ data, mediaCategory, onRemove, variant }), + [data, mediaCategory, onRemove, variant] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// AttachmentPreview - Media preview +// ============================================================================ + +export type AttachmentPreviewProps = HTMLAttributes & { + fallbackIcon?: ReactNode; +}; + +export const AttachmentPreview = ({ + fallbackIcon, + className, + ...props +}: AttachmentPreviewProps) => { + const { data, mediaCategory, variant } = useAttachmentContext(); + + const iconSize = variant === "inline" ? "size-3" : "size-4"; + + const renderIcon = (Icon: typeof ImageIcon) => ( + + ); + + const renderContent = () => { + if (mediaCategory === "image" && data.type === "file" && data.url) { + return renderAttachmentImage(data.url, data.filename, variant === "grid"); + } + + if (mediaCategory === "video" && data.type === "file" && data.url) { + return