18 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 21-chat-foundation | 04 | execute | 2 |
|
|
true |
|
|
Purpose: Wire the chat UI skeleton into the app -- a toggle button in the Layout, a right-side drawer with open/close animation, and an auto-resizing input with keyboard shortcuts. This gives users a visible, interactive chat panel. Output: Functional chat drawer that opens/closes, with working text input.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/21-chat-foundation/21-RESEARCH.md @.planning/phases/21-chat-foundation/21-UI-SPEC.md @.planning/phases/21-chat-foundation/21-02-SUMMARY.md From ui/src/context/PanelContext.tsx: ```typescript const STORAGE_KEY = "paperclip:panel-visible"; interface PanelContextValue { panelContent: ReactNode | null; panelVisible: boolean; openPanel: (content: ReactNode) => void; closePanel: () => void; setPanelVisible: (visible: boolean) => void; togglePanelVisible: () => void; } export function PanelProvider({ children }: { children: ReactNode }): JSX.Element; export function usePanel(): PanelContextValue; ```From ui/src/components/Layout.tsx (line 416-435):
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main id="main-content" ... >
<Outlet />
</main>
<PropertiesPanel />
</div>
From ui/src/main.tsx (line 50-51):
<PanelProvider>
<DialogProvider>
From ui/src/components/ChatMarkdownMessage.tsx (created in Plan 02):
export function ChatMarkdownMessage({ content, className }: { content: string; className?: string }): JSX.Element;
Task 1: Create ChatPanelContext and ChatInput components
ui/src/context/ChatPanelContext.tsx, ui/src/components/ChatInput.tsx, ui/src/components/ChatMessage.tsx, ui/src/components/ChatInput.test.tsx
- ChatInput calls onSend when Enter is pressed without Shift
- ChatInput inserts newline when Shift+Enter is pressed
- ChatInput clears content when Escape is pressed
- ChatInput does not call onSend when input is empty
- ChatInput disables send button when isSubmitting is true
- ui/src/context/PanelContext.tsx (mirror this pattern for localStorage persistence)
- ui/src/context/ThemeContext.tsx (context + hook export pattern)
- ui/src/components/MarkdownBody.tsx (understand existing markdown rendering approach)
- ui/src/components/ChatInput.test.tsx (test stub from Plan 00 -- fill in test implementations)
First, update `ui/src/components/ChatInput.test.tsx` to replace `.todo` stubs with real test implementations. Use `@testing-library/react` (if available) or `renderToStaticMarkup` to verify:
- Enter key triggers onSend callback with the textarea content
- Shift+Enter does NOT trigger onSend
- Escape clears the textarea value
- Empty textarea does not trigger onSend on Enter
- Send button has disabled state when isSubmitting=true
Then create the components:
ChatPanelContext.tsx:
Create ui/src/context/ChatPanelContext.tsx mirroring the PanelContext pattern:
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
const STORAGE_KEY = "nexus:chat-panel-open";
interface ChatPanelContextValue {
chatOpen: boolean;
activeConversationId: string | null;
setChatOpen: (open: boolean) => void;
toggleChat: () => void;
setActiveConversationId: (id: string | null) => void;
}
const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
function readPreference(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === "true";
} catch {
return false;
}
}
function writePreference(open: boolean) {
try {
localStorage.setItem(STORAGE_KEY, String(open));
} catch { /* ignore */ }
}
export function ChatPanelProvider({ children }: { children: ReactNode }) {
const [chatOpen, setChatOpenState] = useState(readPreference);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const setChatOpen = useCallback((open: boolean) => {
setChatOpenState(open);
writePreference(open);
}, []);
const toggleChat = useCallback(() => {
setChatOpenState((prev) => {
const next = !prev;
writePreference(next);
return next;
});
}, []);
return (
<ChatPanelContext.Provider value={{ chatOpen, activeConversationId, setChatOpen, toggleChat, setActiveConversationId }}>
{children}
</ChatPanelContext.Provider>
);
}
export function useChatPanel() {
const ctx = useContext(ChatPanelContext);
if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider");
return ctx;
}
Key difference from PanelContext: default is false (chat starts closed), and uses nexus: prefix not paperclip:.
ChatInput.tsx:
Create ui/src/components/ChatInput.tsx:
- A
<form>wrapper withonSubmitthat calls the providedonSend(text)callback <textarea>with:placeholder="Message your agent..."- CSS:
field-sizing: contentfor auto-resize (Chrome 123+, Firefox 129+) - Fallback:
useEffectthat setsref.current.style.height = "auto"; ref.current.style.height = ref.current.scrollHeight + "px"on value change max-height: 160px(10rem) withoverflow-y: autowhen exceededclassName: use shadcn textarea base classes (flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ...) plusresize-none min-h-[40px] max-h-[160px]rows={1}initially
onKeyDownhandler:e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing->e.preventDefault(); submit()e.key === "Escape"-> clear textarea value, calle.preventDefault()Shift+Enter-> default behavior (inserts newline)
- Send button:
Button variant="ghost" size="icon"withSendicon from lucide-reactdisabledwhen textarea is empty (after trim) orisSubmittingis truearia-label="Send message"- When submitting: show
Loader2icon withanimate-spinclass
- Component props:
interface ChatInputProps { onSend: (content: string) => void; isSubmitting?: boolean; disabled?: boolean; }
ChatMessage.tsx:
Create ui/src/components/ChatMessage.tsx:
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
import { cn } from "../lib/utils";
interface ChatMessageProps {
role: "user" | "assistant" | "system";
content: string;
}
export function ChatMessage({ role, content }: ChatMessageProps) {
if (role === "user") {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm">
{content}
</div>
</div>
);
}
// assistant or system
return (
<div className="max-w-full">
<ChatMarkdownMessage content={content} />
</div>
);
}
User messages: right-aligned bubble with bg-secondary, plain text (no markdown).
Assistant messages: left-aligned, full width, rendered via ChatMarkdownMessage.
Run tests after implementation:
pnpm vitest run ui/src/components/ChatInput.test.tsx
cd /opt/nexus && pnpm vitest run ui/src/components/ChatInput.test.tsx 2>&1 | tail -5
- ui/src/context/ChatPanelContext.tsx contains `const STORAGE_KEY = "nexus:chat-panel-open"`
- ui/src/context/ChatPanelContext.tsx exports `ChatPanelProvider` and `useChatPanel`
- ChatPanelContext tracks `chatOpen`, `activeConversationId`, `setChatOpen`, `toggleChat`, `setActiveConversationId`
- ui/src/components/ChatInput.tsx contains `e.key === "Enter"` with `!e.shiftKey` check
- ui/src/components/ChatInput.tsx contains `e.key === "Escape"`
- ui/src/components/ChatInput.tsx contains `field-sizing` or `scrollHeight` for auto-resize
- ui/src/components/ChatInput.tsx contains `aria-label` with "Send message"
- ui/src/components/ChatInput.tsx contains `max-h-[160px]` or equivalent max-height
- ui/src/components/ChatMessage.tsx renders `ChatMarkdownMessage` for assistant role
- ui/src/components/ChatMessage.tsx renders `bg-secondary` bubble for user role
- ChatInput tests pass via vitest
ChatPanelContext provides open/close state with localStorage persistence. ChatInput auto-resizes and handles Enter/Shift+Enter/Escape keyboard shortcuts. ChatMessage renders user bubbles and assistant markdown. Tests pass.
Task 2: Create ChatPanel shell and wire into Layout + main.tsx
ui/src/components/ChatPanel.tsx, ui/src/components/Layout.tsx, ui/src/main.tsx
- ui/src/components/Layout.tsx (full file -- understand the flex row structure, PropertiesPanel placement, existing imports, and the mobile/desktop branching)
- ui/src/main.tsx (full file -- understand provider nesting order)
- ui/src/components/PropertiesPanel.tsx (understand how it reads panelVisible, its width, and its rendering pattern)
**ChatPanel.tsx:**
Create ui/src/components/ChatPanel.tsx -- the right-side drawer shell:
import { useChatPanel } from "../context/ChatPanelContext";
import { ChatInput } from "./ChatInput";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
export function ChatPanel() {
const { chatOpen, setChatOpen, activeConversationId } = useChatPanel();
return (
<aside
aria-label="Chat"
className="hidden md:flex overflow-hidden transition-[width] duration-100 ease-out flex-shrink-0 border-l border-border flex-col bg-background"
style={{ width: chatOpen ? 380 : 0 }}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-2 min-w-[380px]">
<span className="text-sm font-medium">Chat</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setChatOpen(false)}
aria-label="Close chat"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Two-column layout: conversation list (left) + thread (right) */}
<div className="flex flex-1 min-h-0 min-w-[380px]">
{/* Left column: conversation list -- placeholder for Plan 05 */}
<div className="w-[160px] flex-shrink-0 border-r border-border bg-card overflow-hidden">
<div className="p-3 text-center text-xs text-muted-foreground">
No conversations yet
</div>
</div>
{/* Right column: message thread + input */}
<div className="flex flex-1 flex-col min-w-0">
<ScrollArea className="flex-1 p-3">
{/* Messages placeholder -- wired in Plan 05 */}
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
</div>
</ScrollArea>
{/* Input area */}
<div className="border-t border-border px-3 py-2">
<ChatInput
onSend={(content) => {
// TODO: Wire to API in Plan 05
console.log("send:", content);
}}
/>
</div>
</div>
</div>
</aside>
);
}
Key specs per UI-SPEC:
- Width: 380px when open, 0 when closed
transition-[width] duration-100 ease-outmatches sidebarhidden md:flex-- desktop only- Two-column: left 160px for conversation list, right flex-1 for messages+input
min-w-[380px]on inner elements prevents content collapsing during width animation
Layout.tsx modifications:
-
Add imports at top:
import { MessageSquare } from "lucide-react"; import { ChatPanel } from "./ChatPanel"; import { useChatPanel } from "../context/ChatPanelContext"; -
Inside the
Layoutfunction, add:const { chatOpen, toggleChat, setChatOpen } = useChatPanel(); const { setPanelVisible } = usePanel(); -
Add a
useEffectthat closes PropertiesPanel when chat opens (per UI-SPEC: "When ChatPanel opens, PropertiesPanel closes"):useEffect(() => { if (chatOpen) { setPanelVisible(false); } }, [chatOpen, setPanelVisible]); -
Add a chat toggle button in the BreadcrumbBar area. Find where the theme toggle button and settings link are rendered (likely near the end of the BreadcrumbBar or in the Layout's top-right controls). Add a
MessageSquareicon button BEFORE the theme toggle:<Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="icon" className="hidden md:inline-flex h-7 w-7" onClick={toggleChat} aria-label={chatOpen ? "Close chat" : "Open chat"} > <MessageSquare className="h-4 w-4" /> </Button> </TooltipTrigger> <TooltipContent>{chatOpen ? "Close chat" : "Open chat"}</TooltipContent> </Tooltip> -
Insert
<ChatPanel />in the flex row BEFORE<PropertiesPanel />: Change:<main ...> <Outlet /> </main> <PropertiesPanel />To:
<main ...> <Outlet /> </main> <ChatPanel /> <PropertiesPanel />
main.tsx modifications:
Add ChatPanelProvider in the provider stack. Insert it as a sibling of PanelProvider -- AFTER PanelProvider (since ChatPanel needs to call setPanelVisible from PanelContext):
import { ChatPanelProvider } from "./context/ChatPanelContext";
In the render tree, wrap after PanelProvider:
<PanelProvider>
<ChatPanelProvider>
<DialogProvider>
...
</DialogProvider>
</ChatPanelProvider>
</PanelProvider>
cd /opt/nexus && grep -q "ChatPanel" ui/src/components/Layout.tsx && grep -q "MessageSquare" ui/src/components/Layout.tsx && grep -q "ChatPanelProvider" ui/src/main.tsx && grep -q "aria-label=\"Chat\"" ui/src/components/ChatPanel.tsx && grep -q "width: chatOpen ? 380 : 0" ui/src/components/ChatPanel.tsx && echo "OK"
- ui/src/components/ChatPanel.tsx contains `aria-label="Chat"` on the aside element
- ui/src/components/ChatPanel.tsx contains `style={{ width: chatOpen ? 380 : 0 }}`
- ui/src/components/ChatPanel.tsx contains `transition-[width] duration-100 ease-out`
- ui/src/components/ChatPanel.tsx contains `hidden md:flex`
- ui/src/components/Layout.tsx imports `ChatPanel` from "./ChatPanel"
- ui/src/components/Layout.tsx imports `MessageSquare` from "lucide-react"
- ui/src/components/Layout.tsx imports `useChatPanel` from "../context/ChatPanelContext"
- ui/src/components/Layout.tsx renders `` before ``
- ui/src/components/Layout.tsx contains `useEffect` that calls `setPanelVisible(false)` when `chatOpen` is true
- ui/src/components/Layout.tsx contains a button with `aria-label` containing "chat" (case-insensitive)
- ui/src/main.tsx imports `ChatPanelProvider`
- ui/src/main.tsx contains `` in the provider tree
ChatPanel renders as a 380px right-side drawer in Layout, toggled by a MessageSquare button. Opening chat closes PropertiesPanel. ChatPanelProvider is in the provider tree. The panel has a two-column skeleton ready for conversation list and message thread wiring.
- `cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
- `pnpm vitest run ui/src/components/ChatInput.test.tsx` passes
- Chat panel open state persists in localStorage under "nexus:chat-panel-open"
- Layout flex row: main + ChatPanel + PropertiesPanel
- ChatInput handles Enter/Shift+Enter/Escape
<success_criteria>
- Chat toggle button visible in Layout controls area
- Chat panel opens to 380px, closes to 0 with 100ms transition
- PropertiesPanel closes when chat opens
- Input auto-resizes, keyboard shortcuts work
- All theme CSS variables used (no hardcoded colors)
- ChatInput tests pass </success_criteria>