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
3 changes: 3 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[test]
# Exclude the e2e directory which uses @playwright/test (not bun:test)
root = "src"
25 changes: 24 additions & 1 deletion chat-ui/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTheme } from "@/hooks/use-theme";
import { useIsMobile } from "@/hooks/use-mobile";
import { CommandPalette } from "./command-palette";
import { DeleteSessionDialog } from "./delete-session-dialog";
import { KeyboardHelpSheet } from "./keyboard-help-sheet";
import { SidebarPanel } from "./sidebar-panel";

export function AppShell({ children }: { children: React.ReactNode }) {
Expand All @@ -21,6 +22,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
id: string;
title: string | null;
} | null>(null);
const [helpOpen, setHelpOpen] = useState(false);

const handleNewSession = useCallback(async () => {
const id = await createSession();
Expand Down Expand Up @@ -59,13 +61,31 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}
}, [deleteTarget, deleteSession, sessionId, navigate]);

const handleShowKeyboardHelp = useCallback(() => {
setHelpOpen(true);
}, []);

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

return (
<div className="flex h-dvh overflow-hidden bg-background">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground"
>
Skip to main content
</a>
<a
href="#chat-composer"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-14 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground"
>
Skip to composer
</a>

{sidebarOpen && (
<div className="w-64 shrink-0 border-r border-border">
<SidebarPanel
Expand Down Expand Up @@ -106,15 +126,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<span className="text-sm font-medium text-foreground">Phantom</span>
</header>

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

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

<KeyboardHelpSheet open={helpOpen} onOpenChange={setHelpOpen} />

<DeleteSessionDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
Expand Down
25 changes: 25 additions & 0 deletions chat-ui/src/components/attachment-strip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Thumbnail strip shown above the textarea when files are attached.
// Renders a horizontal scrollable row of attachment tiles.

import type { PendingAttachment } from "@/hooks/use-attachments";
import { AttachmentTile } from "./attachment-tile";

export function AttachmentStrip({
files,
onRemove,
}: {
files: PendingAttachment[];
onRemove: (id: string) => void;
}) {
if (files.length === 0) return null;

return (
<div className="flex gap-2 overflow-x-auto px-2 pb-2" role="list" aria-label="Attached files">
{files.map((file) => (
<div key={file.id} role="listitem">
<AttachmentTile attachment={file} onRemove={onRemove} />
</div>
))}
</div>
);
}
60 changes: 60 additions & 0 deletions chat-ui/src/components/attachment-tile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Individual attachment thumbnail tile.
// Shows image preview, document icon, or file icon with filename.

import { FileText, File as FileIcon, X, Loader2 } from "lucide-react";
import type { PendingAttachment } from "@/hooks/use-attachments";

const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);

export function AttachmentTile({
attachment,
onRemove,
}: {
attachment: PendingAttachment;
onRemove: (id: string) => void;
}) {
const isImage = IMAGE_MIMES.has(attachment.file.type);
const isPdf = attachment.file.type === "application/pdf";
const isUploading = attachment.status === "uploading";

return (
<div className="group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted">
{isImage && attachment.previewUrl ? (
<img
src={attachment.previewUrl}
alt={attachment.file.name}
className="h-full w-full object-cover"
/>
) : isPdf ? (
<div className="flex flex-col items-center gap-0.5">
<FileText className="h-5 w-5 text-muted-foreground" />
<span className="max-w-[56px] truncate text-[9px] text-muted-foreground">
{attachment.file.name}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-0.5">
<FileIcon className="h-5 w-5 text-muted-foreground" />
<span className="max-w-[56px] truncate text-[9px] text-muted-foreground">
{attachment.file.name}
</span>
</div>
)}

{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
)}

<button
type="button"
onClick={() => onRemove(attachment.id)}
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${attachment.file.name}`}
>
<X className="h-2.5 w-2.5" />
</button>
</div>
);
}
12 changes: 8 additions & 4 deletions chat-ui/src/components/chat-input-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Paperclip } from "lucide-react";
import { Button } from "@/ui/button";

export function ChatInputToolbar() {
export function ChatInputToolbar({
onPaperclipClick,
}: {
onPaperclipClick?: () => void;
}) {
return (
<div className="flex items-center gap-1 px-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label="Attach file"
disabled
title="File attachments coming soon"
onClick={onPaperclipClick}
title="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
Expand Down
127 changes: 90 additions & 37 deletions chat-ui/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import { ArrowUp, Square } from "lucide-react";
import { useCallback, useRef, useState, type KeyboardEvent } from "react";
import { Button } from "@/ui/button";
import type { PendingAttachment } from "@/hooks/use-attachments";
import { AttachmentStrip } from "./attachment-strip";
import { ChatInputToolbar } from "./chat-input-toolbar";

export function ChatInput({
onSend,
onStop,
isStreaming,
disabled,
attachments,
onAddFiles,
onRemoveFile,
}: {
onSend: (text: string) => void;
onStop: () => void;
isStreaming: boolean;
disabled?: boolean;
attachments?: PendingAttachment[];
onAddFiles?: (files: File[]) => void;
onRemoveFile?: (id: string) => void;
}) {
const [text, setText] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);

const handleSend = useCallback(() => {
const trimmed = text.trim();
Expand All @@ -29,6 +39,7 @@ export function ChatInput({

const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (composingRef.current) return;
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
Expand All @@ -44,48 +55,90 @@ export function ChatInput({
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}, []);

const handlePaperclipClick = useCallback(() => {
fileInputRef.current?.click();
}, []);

const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && onAddFiles) {
onAddFiles(Array.from(files));
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[onAddFiles],
);

const hasAttachments = (attachments?.length ?? 0) > 0;

return (
<div className="border-t border-border bg-background px-4 py-3">
<div className="mx-auto max-w-3xl">
<div className="flex items-end gap-2 rounded-xl border border-border bg-card p-2">
<ChatInputToolbar />
<textarea
ref={textareaRef}
value={text}
onChange={(e) => {
setText(e.target.value);
handleInput();
}}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
rows={1}
disabled={disabled}
className="max-h-[200px] min-h-[36px] flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
aria-label="Message input"
/>
{isStreaming ? (
<Button
variant="ghost"
size="icon"
onClick={onStop}
className="h-8 w-8 shrink-0 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90"
aria-label="Stop generation"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={handleSend}
disabled={!text.trim() || disabled}
className="h-8 w-8 shrink-0 rounded-lg bg-primary text-primary-content hover:bg-primary/90 disabled:opacity-50"
aria-label="Send message"
>
<ArrowUp className="h-4 w-4" />
</Button>
<div className="flex flex-col rounded-xl border border-border bg-card">
{hasAttachments && attachments && onRemoveFile && (
<div className="pt-2">
<AttachmentStrip files={attachments} onRemove={onRemoveFile} />
</div>
)}
<div className="flex items-end gap-2 p-2">
<ChatInputToolbar onPaperclipClick={handlePaperclipClick} />
<textarea
ref={textareaRef}
value={text}
onChange={(e) => {
setText(e.target.value);
handleInput();
}}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
}}
placeholder="Send a message..."
rows={1}
disabled={disabled}
enterKeyHint="send"
className="max-h-[200px] min-h-[36px] flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
aria-label="Message input"
id="chat-composer"
/>
{isStreaming ? (
<Button
variant="ghost"
size="icon"
onClick={onStop}
className="h-8 w-8 shrink-0 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90"
aria-label="Stop generation"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={handleSend}
disabled={!text.trim() || disabled}
className="h-8 w-8 shrink-0 rounded-lg bg-primary text-primary-content hover:bg-primary/90 disabled:opacity-50"
aria-label="Send message"
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,text/*,.js,.ts,.tsx,.jsx,.py,.go,.rs,.rb,.java,.kt,.swift,.c,.cpp,.h,.hpp,.sh,.bash,.zsh,.toml,.ini,.sql,.json,.md,.csv,.html,.xml,.yaml,.yml"
/>
</div>
</div>
);
Expand Down
Loading
Loading