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 && (
-
);
}
+function ContextSheet(props: {
+ counts: Record;
+ loading: boolean;
+ memoryAvailable: boolean;
+}) {
+ return (
+
+
+
+
+ Context
+
+
+
+
+ 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 (
-
props.onPrompt(starter.prompt)}
- key={starter.label}
- >
-
- {starter.label}
-
- );
- })}
+
+
+
+
+
+ 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 (
+ props.onPrompt(starter.prompt)}
+ key={starter.label}
+ >
+
+ {starter.label}
+
+ );
+ })}
+
+ );
+}
+
+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);
+}