Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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