diff --git a/src/client/App.tsx b/src/client/App.tsx index e21c4cd..8a36a34 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -597,10 +597,12 @@ export function App() {

{workspaceMode === "chat" ? "Chat" : activeView.title}

- - - {openAiReady ? "OpenAI ready" : "Local board"} - + {workspaceMode === "board" && ( + + + {openAiReady ? "OpenAI ready" : "Local board"} + + )}

{workspaceMode === "chat" || view === "settings" @@ -697,7 +699,7 @@ export function App() { onLoadHistory={loadAssistantChatHistory} onAsk={askAssistant} onMessagesChange={handleChatMessagesChange} - className="min-h-[calc(100dvh-8rem)] lg:static lg:h-[calc(100dvh-8rem)] xl:h-[calc(100dvh-8.5rem)]" + className="h-[calc(100dvh-5.5rem)] min-h-[32rem] lg:static lg:h-[calc(100dvh-8rem)] xl:h-[calc(100dvh-8.5rem)]" /> diff --git a/src/client/components/AssistantChatPanel.test.tsx b/src/client/components/AssistantChatPanel.test.tsx index 7120a43..db97763 100644 --- a/src/client/components/AssistantChatPanel.test.tsx +++ b/src/client/components/AssistantChatPanel.test.tsx @@ -94,6 +94,10 @@ describe("AssistantChatPanel", () => { const textarea = container.querySelector("#assistant-chat-input"); expect(textarea).not.toBeNull(); + expect( + container.querySelector('[data-slot="card-title"]')?.textContent, + ).toBe("Planning"); + expect(container.textContent).not.toContain("ChatChat"); await act(async () => { setTextareaValue(textarea!, "Plan my day"); @@ -143,6 +147,11 @@ describe("AssistantChatPanel", () => { onLoadHistory: historyLoader(conversation, []), }); + const emptyState = container.querySelector('[data-slot="empty"]'); + expect(emptyState?.textContent).toContain("Plan today"); + expect(emptyState?.textContent).toContain("Break down drafts"); + expect(emptyState?.textContent).toContain("Review stuck work"); + await act(async () => { getButtonByText("Plan today").dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -151,6 +160,57 @@ describe("AssistantChatPanel", () => { expect(container.textContent).toContain("Response for"); }); + it("opens board context from the compact context action", async () => { + const conversation = conversationFixture(); + + await renderPanel({ + conversation, + onLoadHistory: historyLoader(conversation, []), + }); + + expect(document.body.textContent).not.toContain( + "Board counts available to the chat agent.", + ); + + await act(async () => { + container + .querySelector('button[aria-label="Open context"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(document.body.textContent).toContain("Board counts available to the chat agent."); + expect(document.body.querySelector('[data-slot="sheet-title"]')?.textContent).toBe( + "Current context", + ); + }); + + it("shows a retry path when conversation history fails to load", async () => { + const conversation = conversationFixture(); + const onLoadHistory = vi + .fn() + .mockRejectedValueOnce(new Error("History unavailable")) + .mockResolvedValueOnce({ + conversations: [conversation], + activeConversationId: conversation.id, + messages: [], + }); + + await renderPanel({ + conversation, + onLoadHistory, + }); + + expect(container.textContent).toContain("Could not load this conversation"); + expect(container.textContent).toContain("History unavailable"); + + await act(async () => { + getButtonByText("Retry").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onLoadHistory).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain("Start a chat"); + }); + it("renders proposed task actions and requires approval or dismissal", async () => { const conversation = conversationFixture(); const proposedActions = [ @@ -194,6 +254,7 @@ describe("AssistantChatPanel", () => { expect(container.textContent).toContain("Create launch task"); expect(container.textContent).toContain("Draft launch notes"); expect(container.textContent).toContain("The draft is ready"); + expect(container.querySelector("dl")).toBeNull(); await act(async () => { getButtonsByText("Approve")[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -222,6 +283,9 @@ describe("AssistantChatPanel", () => { const textarea = container.querySelector("#assistant-chat-input"); expect(textarea).not.toBeNull(); + expect(textarea!.disabled).toBe(true); + expect(container.textContent).toContain("OpenAI setup required"); + expect(getButtonByText("Open Settings")).not.toBeNull(); await act(async () => { setTextareaValue(textarea!, "Plan my day"); diff --git a/src/client/components/AssistantChatPanel.tsx b/src/client/components/AssistantChatPanel.tsx index dcd30df..8e82b9b 100644 --- a/src/client/components/AssistantChatPanel.tsx +++ b/src/client/components/AssistantChatPanel.tsx @@ -7,8 +7,9 @@ import { ListChecks, LoaderCircle, MemoryStick, - MessageSquareText, + PanelRightOpen, Plus, + RotateCcw, Send, Settings2, Sparkles, @@ -16,7 +17,7 @@ import { WandSparkles, X, } from "lucide-react"; -import { type FormEvent, useEffect, useRef, useState } from "react"; +import { type FormEvent, type RefObject, useEffect, useRef, useState } from "react"; import { COLUMN_LABELS, type FocusArea, type TaskStatus } from "../../shared/types"; import type { AssistantChatHistoryResponse, @@ -25,7 +26,7 @@ import type { AssistantChatResponse, AssistantProposedAction, } from "../../shared/types"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -39,6 +40,7 @@ import { } from "@/components/ui/card"; import { Empty, + EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, @@ -58,11 +60,22 @@ import { ItemContent, ItemDescription, ItemGroup, + ItemHeader, ItemMedia, ItemTitle, } from "@/components/ui/item"; +import { Kbd } from "@/components/ui/kbd"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { api } from "../api"; import { MarkdownMessage } from "./MarkdownMessage"; @@ -138,14 +151,26 @@ export function AssistantChatPanel({ const [draft, setDraft] = useState(""); const [loadingHistory, setLoadingHistory] = useState(true); const [sending, setSending] = useState(false); - const [error, setError] = useState(null); + const [historyError, setHistoryError] = useState(null); + const [chatError, setChatError] = useState(null); + const [historyRetryKey, setHistoryRetryKey] = useState(0); const [actionStates, setActionStates] = useState>({}); + const textareaRef = useRef(null); const bottomRef = useRef(null); - const canSend = draft.trim().length > 0 && openAiReady && !loadingHistory && !sending; + const composerDisabled = !openAiReady || loadingHistory || sending; + const canSend = draft.trim().length > 0 && !composerDisabled; + const chatTitle = conversationTitle || "New conversation"; + const chatStateDescription = formatConversationState({ + loading: loadingHistory, + messageCount: messages.length, + conversationId, + }); useEffect(() => { + setMessages([]); setDraft(""); - setError(null); + setHistoryError(null); + setChatError(null); setActionStates({}); }, [conversationId]); @@ -153,22 +178,32 @@ export function AssistantChatPanel({ if (typeof bottomRef.current?.scrollIntoView === "function") { bottomRef.current.scrollIntoView({ block: "end" }); } - }, [messages, sending, error]); + }, [messages, sending, historyError, chatError]); + + useEffect(() => { + if (!loadingHistory) { + focusComposer(textareaRef); + } + }, [conversationId, loadingHistory]); useEffect(() => { let cancelled = false; setLoadingHistory(true); + setHistoryError(null); onLoadHistory(conversationId) .then((history) => { if (!cancelled) { setMessages(history.messages); onMessagesChange?.(history.messages); - setError(null); + setHistoryError(null); } }) .catch((err) => { if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + setMessages([]); + onMessagesChange?.([]); + setHistoryError(message); } }) .finally(() => { @@ -179,7 +214,7 @@ export function AssistantChatPanel({ return () => { cancelled = true; }; - }, [conversationId, onLoadHistory, onMessagesChange]); + }, [conversationId, historyRetryKey, onLoadHistory, onMessagesChange]); async function submit(event: FormEvent) { event.preventDefault(); @@ -204,7 +239,7 @@ export function AssistantChatPanel({ onMessagesChange?.(nextMessages); setDraft(""); setSending(true); - setError(null); + setChatError(null); try { const response = await onAsk( @@ -219,9 +254,10 @@ export function AssistantChatPanel({ setMessages(responseMessages); onMessagesChange?.(responseMessages); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setChatError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); + focusComposer(textareaRef); } } @@ -236,7 +272,7 @@ export function AssistantChatPanel({ onResolveProposedAction?.(action.id); } catch (err) { setActionStates((current) => ({ ...current, [action.id]: "failed" })); - setError(err instanceof Error ? err.message : String(err)); + setChatError(err instanceof Error ? err.message : String(err)); } } @@ -248,30 +284,40 @@ export function AssistantChatPanel({ onResolveProposedAction?.(action.id); } + async function handleNewConversation() { + await onNewConversation?.(); + focusComposer(textareaRef); + } + + function retryHistory() { + setHistoryRetryKey((current) => current + 1); + focusComposer(textareaRef); + } + return (

- - - - - Chat - - - {conversationTitle || "New conversation"} - + + + {chatTitle} + {chatStateDescription} + {onNewConversation && ( + )} - - {loadingHistory ? ( - - - - - - Loading chat - Restoring saved messages. - - - ) : messages.length === 0 ? ( - - - - - - Start a chat - - Ask about priorities, task wording, or the next useful step. - - - - ) : ( - - {messages.map((message) => ( - - ))} - {sending && } -
- - )} + {chatError && ( + + + Chat request failed + {chatError} + + )} + + +
+ {loadingHistory ? ( + + ) : messages.length === 0 ? ( + void sendContent(prompt)} + /> + ) : ( + + {messages.map((message) => ( + + ))} + {sending && } +
+ + )} +
- +
- - Message - + + + Message + + setDraft(event.target.value)} - placeholder="Ask the agent..." - className="min-h-24" + placeholder={ + openAiReady ? "Ask the agent..." : "Connect OpenAI to start chatting" + } + className="min-h-16" + disabled={composerDisabled} + aria-label="Message" onKeyDown={(event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); @@ -368,9 +424,13 @@ export function AssistantChatPanel({ }} /> - + - ⌘ Enter + Send with + + + Enter + {sending ? ( @@ -388,124 +448,234 @@ export function AssistantChatPanel({ -
); } +function ContextSheet(props: { + counts: Record; + loading: boolean; + memoryAvailable: boolean; +}) { + return ( + + + + + + + Current context + Board counts available to the chat agent. + +
+ +
+
+
+ ); +} + function ContextCard(props: { counts: Record; loading: boolean; memoryAvailable: boolean; - openAiReady: boolean; }) { - const activeCount = props.counts.ready + props.counts.in_progress; return ( - Context - Board snapshot + Current context + Board snapshot for this conversation. - - - - - - - OpenAI - {props.openAiReady ? "Ready" : "Needs setup"} - - - - {props.openAiReady ? "Ready" : "Setup"} - - - - - - - - - Board - {activeCount} active tasks - - - {props.counts.draft} draft - - - - - - - - Memory - {props.memoryAvailable ? "Local" : "Unavailable"} - - - - {props.memoryAvailable ? "On" : "Off"} - - - - {props.loading && ( - - - - - - Restoring chat - - - )} - + ); } -function PromptStarters(props: { +function ContextSnapshot(props: { + counts: Record; + loading: boolean; + memoryAvailable: boolean; +}) { + const activeCount = props.counts.ready + props.counts.in_progress; + return ( + + + + + + + Ready now + + {props.counts.ready} ready · {props.counts.in_progress} in progress + + + + {activeCount} + + + + + + + + Drafts + Work that still needs shaping. + + + {props.counts.draft} + + + + + + + + Needs attention + Tasks that may need review. + + + 0 ? "secondary" : "outline"}> + {props.counts.needs_attention} + + + + + + + + + Done + Completed local work. + + + {props.counts.done} + + + + + + + + Memory + + {props.memoryAvailable ? "Local recall available." : "Not available."} + + + + + {props.memoryAvailable ? "On" : "Off"} + + + + {props.loading && ( + + + + + + Restoring chat + Loading saved messages. + + + )} + + ); +} + +function ChatEmptyState(props: { disabled: boolean; onPrompt: (prompt: string) => void; }) { return ( - - - Prompt Starters - Proactive next steps - - -
- {PROMPT_STARTERS.map((starter) => { - const Icon = starter.icon; - return ( - - ); - })} + + + + + + Start a chat + + Ask about priorities, task wording, or the next useful step. + + + + + + + ); +} + +function PromptStarterActions(props: { + disabled: boolean; + onPrompt: (prompt: string) => void; +}) { + return ( +
+ {PROMPT_STARTERS.map((starter) => { + const Icon = starter.icon; + return ( + + ); + })} +
+ ); +} + +function ChatLoadingSkeleton() { + return ( + + {Array.from({ length: 3 }, (_, index) => ( +
+ + + + + + + + {index !== 1 && } + +
- - + ))} +
); } @@ -524,15 +694,26 @@ function ChatMessageRow(props: { variant={isUser ? "default" : "muted"} size="sm" className={cn( - "max-w-[92%] items-start", + "w-fit max-w-[min(42rem,100%)] items-start", isUser && "border-transparent bg-primary text-primary-foreground", + isUser && "max-w-[min(34rem,100%)]", )} > {isUser ? : } - - {isUser ? "You" : "Agent"} + + + {isUser ? "You" : "Agent"} + + {formatMessageTime(props.message.createdAt)} + + {isUser ? (

{props.message.content}

) : ( @@ -574,10 +755,12 @@ function ProposedActionRow(props: { {actionIcon(props.action)} - - {props.action.title} - {actionLabel(props.action)} - + + + {props.action.title} + {actionLabel(props.action)} + + {props.action.rationale && {props.action.rationale}} @@ -619,26 +802,34 @@ function ActionFieldList(props: { return null; } return ( -
+ {rows.map((row) => ( -
-
{row.label}
-
{row.value}
-
+ + {row.label} + {row.value} + ))} -
+ ); } function PendingMessage() { return (
- + - Agent + + Agent + Now +

Thinking...

@@ -722,3 +913,32 @@ function formatPatchValue(key: string, value: unknown, focusAreas: FocusArea[]): } return String(value); } + +function formatConversationState(props: { + loading: boolean; + messageCount: number; + conversationId: string | null; +}): string { + if (props.loading) { + return "Restoring saved history"; + } + if (props.messageCount === 0) { + return props.conversationId ? "No messages yet" : "Unsaved local conversation"; + } + return `${props.messageCount} ${props.messageCount === 1 ? "message" : "messages"} · Saved local history`; +} + +function formatMessageTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Saved"; + } + return new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(date); +} + +function focusComposer(ref: RefObject) { + window.setTimeout(() => ref.current?.focus(), 0); +} diff --git a/src/client/components/Sidebar.test.tsx b/src/client/components/Sidebar.test.tsx index c9c3bd8..6c79ab2 100644 --- a/src/client/components/Sidebar.test.tsx +++ b/src/client/components/Sidebar.test.tsx @@ -43,6 +43,7 @@ describe("Sidebar", () => { const onViewChange = vi.fn(); const onWorkspaceModeChange = vi.fn(); const onChatConversationSelect = vi.fn(); + const onNewChatConversation = vi.fn(); await act(async () => { root.render( @@ -62,7 +63,7 @@ describe("Sidebar", () => { onWorkspaceModeChange={onWorkspaceModeChange} onFocusAreaChange={vi.fn()} onChatConversationSelect={onChatConversationSelect} - onNewChatConversation={vi.fn()} + onNewChatConversation={onNewChatConversation} /> , @@ -81,6 +82,7 @@ describe("Sidebar", () => { expect(container.textContent).toContain("This Mac"); expect(container.textContent).not.toContain("Focus areas"); expect(container.textContent).not.toContain("Client Ops"); + expect(container.querySelector('button[aria-label="New chat"]')).not.toBeNull(); expect( Array.from(container.querySelectorAll('button[data-sidebar="menu-button"]')) .some((button) => button.textContent?.trim() === "Board"), @@ -95,6 +97,14 @@ describe("Sidebar", () => { expect(onWorkspaceModeChange).toHaveBeenCalledWith("board"); expect(onViewChange).toHaveBeenCalledWith("board"); + await act(async () => { + container + .querySelector('button[aria-label="New chat"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onNewChatConversation).toHaveBeenCalledTimes(1); + await act(async () => { Array.from(container.querySelectorAll('button[data-sidebar="menu-button"]')) .find((button) => button.textContent?.includes("Launch note")) @@ -187,6 +197,60 @@ describe("Sidebar", () => { expect(onFocusAreaChange).toHaveBeenCalledWith(null); }); + + it("filters chat conversations without losing the active row", async () => { + const onChatConversationSelect = vi.fn(); + + await act(async () => { + root.render( + + + + + , + ); + }); + + const input = container.querySelector( + 'input[aria-label="Search conversations"]', + ); + expect(input).not.toBeNull(); + + await act(async () => { + setInputValue(input!, "launch"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(container.textContent).toContain("Launch note"); + expect(container.textContent).not.toContain("Plan my day"); + + const activeButton = Array.from( + container.querySelectorAll('button[data-sidebar="menu-button"]'), + ).find((button) => button.getAttribute("data-active") === "true"); + + expect(activeButton?.textContent).toContain("Launch note"); + + await act(async () => { + activeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onChatConversationSelect).toHaveBeenCalledWith("conversation-2"); + }); }); function counts(): Record { @@ -223,3 +287,38 @@ function chatConversations(): AssistantChatConversation[] { }, ]; } + +function manyChatConversations(): AssistantChatConversation[] { + return [ + ...chatConversations(), + { + id: "conversation-3", + title: "Daily planning", + createdAt: "2026-05-03T09:04:00.000Z", + updatedAt: "2026-05-03T09:05:00.000Z", + }, + { + id: "conversation-4", + title: "Roadmap cleanup", + createdAt: "2026-05-03T09:06:00.000Z", + updatedAt: "2026-05-03T09:07:00.000Z", + }, + { + id: "conversation-5", + title: "Follow-up list", + createdAt: "2026-05-03T09:08:00.000Z", + updatedAt: "2026-05-03T09:09:00.000Z", + }, + { + id: "conversation-6", + title: "Stuck work review", + createdAt: "2026-05-03T09:10:00.000Z", + updatedAt: "2026-05-03T09:11:00.000Z", + }, + ]; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); +} diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx index 09f2da8..c9859cb 100644 --- a/src/client/components/Sidebar.tsx +++ b/src/client/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { Plus, Settings, } from "lucide-react"; +import { useMemo, useState } from "react"; import type { AssistantChatConversation, FocusArea, @@ -17,14 +18,18 @@ import { SidebarContent, SidebarFooter, SidebarGroup, + SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, + SidebarInput, SidebarMenu, + SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarRail, + SidebarSeparator, useSidebar, } from "@/components/ui/sidebar"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; @@ -268,21 +273,44 @@ function ChatHistoryGroup(props: { onConversationSelect: (conversationId: string) => void; onNewConversation: () => void; }) { + const [query, setQuery] = useState(""); + const showSearch = props.conversations.length >= 5; + const normalizedQuery = query.trim().toLowerCase(); + const visibleConversations = useMemo(() => { + if (!showSearch || normalizedQuery.length === 0) { + return props.conversations; + } + return props.conversations.filter((conversation) => + conversation.title.toLowerCase().includes(normalizedQuery), + ); + }, [normalizedQuery, props.conversations, showSearch]); + return ( Conversations - + + + New chat + + + {showSearch && ( + <> + setQuery(event.target.value)} + placeholder="Search conversations" + aria-label="Search conversations" + className="group-data-[collapsible=icon]:hidden" + /> + + + )} - - - - New chat - - {props.loading ? ( Array.from({ length: 4 }, (_, index) => ( @@ -306,14 +334,32 @@ function ChatHistoryGroup(props: { + ) : visibleConversations.length === 0 ? ( + + + + + No matches + + Try another search. + + + + ) : ( - props.conversations.map((conversation) => { + visibleConversations.map((conversation) => { + const isActive = conversation.id === props.activeConversationId; return ( props.onConversationSelect(conversation.id)} > @@ -325,6 +371,9 @@ function ChatHistoryGroup(props: { + + {isActive ? "Open" : formatConversationBadge(conversation.updatedAt)} + ); }) @@ -347,3 +396,14 @@ function formatConversationTime(value: string): string { minute: "2-digit", }).format(date); } + +function formatConversationBadge(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Saved"; + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(date); +}