Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ COPY public/ public/
COPY skills-builtin/ skills-builtin/
COPY tsconfig.json biome.json ./

# --- Chat UI Build Stage ---
FROM oven/bun:1 AS chat-ui-builder
WORKDIR /app/chat-ui
COPY chat-ui/package.json chat-ui/bun.lock* ./
RUN bun install --frozen-lockfile
COPY chat-ui/ ./
RUN bun run build

# --- Runtime Stage ---
FROM oven/bun:1-slim
WORKDIR /app
Expand Down Expand Up @@ -73,6 +81,7 @@ COPY --from=builder /app/src ./src
COPY --from=builder /app/config ./config
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/public ./public
COPY --from=chat-ui-builder /app/chat-ui/dist ./public/chat
COPY --from=builder /app/skills-builtin ./skills-builtin
COPY --from=builder /app/package.json ./
COPY --from=builder /app/tsconfig.json ./
Expand Down
3 changes: 3 additions & 0 deletions chat-ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.vite
726 changes: 726 additions & 0 deletions chat-ui/bun.lock

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions chat-ui/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
28 changes: 28 additions & 0 deletions chat-ui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Phantom</title>
<link rel="icon" href="/chat/favicon.svg" type="image/svg+xml" />
<link
rel="preload"
href="/chat/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/chat/fonts/jetbrains-mono-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions chat-ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "phantom-chat-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^1.8.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.5.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.0"
},
"devDependencies": {
"vite": "^6.3.0",
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.7.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@types/node": "^22.15.0"
}
}
6 changes: 6 additions & 0 deletions chat-ui/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added chat-ui/public/fonts/instrument-serif-latin.woff2
Binary file not shown.
Binary file added chat-ui/public/fonts/inter-var-latin.woff2
Binary file not shown.
Binary file not shown.
17 changes: 17 additions & 0 deletions chat-ui/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "Phantom",
"short_name": "Phantom",
"description": "Phantom AI co-worker",
"start_url": "/chat/",
"scope": "/chat/",
"display": "standalone",
"background_color": "#faf9f5",
"theme_color": "#4850c4",
"icons": [
{
"src": "/chat/favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
46 changes: 46 additions & 0 deletions chat-ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Suspense } from "react";
import {
createBrowserRouter,
Outlet,
RouterProvider,
} from "react-router-dom";
import { AppShell } from "@/components/app-shell";
import { ChatRoute } from "@/routes/chat-route";
import { NewChatRoute } from "@/routes/new-chat-route";
import { NotFoundRoute } from "@/routes/not-found-route";
import { SessionRoute } from "@/routes/session-route";

function Layout() {
return (
<AppShell>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
}
>
<Outlet />
</Suspense>
</AppShell>
);
}

const router = createBrowserRouter(
[
{
element: <Layout />,
children: [
{ index: true, element: <ChatRoute /> },
{ path: "s/:sessionId", element: <SessionRoute /> },
{ path: "new", element: <NewChatRoute /> },
{ path: "*", element: <NotFoundRoute /> },
],
},
],
{ basename: "/chat" },
);

export function App() {
return <RouterProvider router={router} />;
}
128 changes: 128 additions & 0 deletions chat-ui/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useCallback, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useKeyboard } from "@/hooks/use-keyboard";
import { useSessions } from "@/hooks/use-sessions";
import { useTheme } from "@/hooks/use-theme";
import { useIsMobile } from "@/hooks/use-mobile";
import { CommandPalette } from "./command-palette";
import { DeleteSessionDialog } from "./delete-session-dialog";
import { SidebarPanel } from "./sidebar-panel";

export function AppShell({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const { sessionId } = useParams<{ sessionId: string }>();
const { sessions, isLoading, createSession, deleteSession, updateSession } =
useSessions();
const { toggleTheme } = useTheme();
const isMobile = useIsMobile();

const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
title: string | null;
} | null>(null);

const handleNewSession = useCallback(async () => {
const id = await createSession();
navigate(`/s/${id}`);
}, [createSession, navigate]);

const handleSessionClick = useCallback(
(id: string) => {
navigate(`/s/${id}`);
if (isMobile) setSidebarOpen(false);
},
[navigate, isMobile],
);

const handleRename = useCallback(
(id: string, title: string) => {
updateSession(id, { title });
},
[updateSession],
);

const handleDeleteRequest = useCallback(
(id: string) => {
const session = sessions.find((s) => s.id === id);
setDeleteTarget({ id, title: session?.title ?? null });
},
[sessions],
);

const handleDeleteConfirm = useCallback(() => {
if (!deleteTarget) return;
deleteSession(deleteTarget.id);
setDeleteTarget(null);
if (deleteTarget.id === sessionId) {
navigate("/");
}
}, [deleteTarget, deleteSession, sessionId, navigate]);

useKeyboard({
newSession: handleNewSession,
toggleTheme,
});

return (
<div className="flex h-dvh overflow-hidden bg-background">
{sidebarOpen && (
<div className="w-64 shrink-0 border-r border-border">
<SidebarPanel
sessions={sessions}
isLoading={isLoading}
activeSessionId={sessionId ?? null}
onSessionClick={handleSessionClick}
onNewSession={handleNewSession}
onRename={handleRename}
onDelete={handleDeleteRequest}
/>
</div>
)}

<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-12 items-center border-b border-border px-4">
<button
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="mr-3 text-muted-foreground hover:text-foreground"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
className="h-4 w-4"
>
<path
d="M2 4h12M2 8h12M2 12h12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
<span className="text-sm font-medium text-foreground">Phantom</span>
</header>

<main className="flex min-h-0 flex-1 flex-col">{children}</main>
</div>

<CommandPalette
sessions={sessions}
onNewSession={handleNewSession}
onSessionClick={handleSessionClick}
/>

<DeleteSessionDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={handleDeleteConfirm}
sessionTitle={deleteTarget?.title ?? null}
/>
</div>
);
}
59 changes: 59 additions & 0 deletions chat-ui/src/components/assistant-message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ChatMessage, ThinkingBlockState, ToolCallState } from "@/lib/chat-types";
import { Markdown } from "./markdown";
import { ThinkingBlock } from "./thinking-block";
import { ToolCallCard } from "./tool-call-card";

export function AssistantMessage({
message,
toolCalls,
thinkingBlocks,
}: {
message: ChatMessage;
toolCalls: ToolCallState[];
thinkingBlocks: ThinkingBlockState[];
}) {
const textContent =
message.content.find((b) => b.type === "text")?.text ?? "";

const isStreaming = message.status === "streaming";

return (
<div className="flex justify-start">
<div className="max-w-[85%]">
{thinkingBlocks.map((block, i) => (
<ThinkingBlock key={`thinking-${i}`} block={block} />
))}

{toolCalls.map((tool) => (
<ToolCallCard key={tool.id} tool={tool} />
))}

{textContent && <Markdown content={textContent} />}

{isStreaming && !textContent && toolCalls.length === 0 && (
<div className="flex items-center gap-1.5 py-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<div className="h-2 w-2 animate-pulse rounded-full bg-primary [animation-delay:150ms]" />
<div className="h-2 w-2 animate-pulse rounded-full bg-primary [animation-delay:300ms]" />
</div>
)}

{message.costUsd != null && message.status === "committed" && (
<div className="mt-1 text-xs text-muted-foreground">
{message.inputTokens != null && message.outputTokens != null && (
<span>
{message.inputTokens.toLocaleString()} in /{" "}
{message.outputTokens.toLocaleString()} out
</span>
)}
{message.costUsd > 0 && (
<span className="ml-2">
${message.costUsd.toFixed(4)}
</span>
)}
</div>
)}
</div>
</div>
);
}
19 changes: 19 additions & 0 deletions chat-ui/src/components/chat-input-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Paperclip } from "lucide-react";
import { Button } from "@/ui/button";

export function ChatInputToolbar() {
return (
<div className="flex items-center gap-1 px-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
aria-label="Attach file"
disabled
title="File attachments coming soon"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
);
}
Loading
Loading