diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2e60d85d..c114759c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -10,7 +10,7 @@ ## Phases -- [x] **Phase 21: Chat Foundation** — Persistent conversation storage, sidebar, CRUD, markdown rendering, theme integration, keyboard shortcuts (completed 2026-04-01) +- [ ] **Phase 21: Chat Foundation** — Persistent conversation storage, sidebar, CRUD, markdown rendering, theme integration, keyboard shortcuts - [ ] **Phase 22: Agent Streaming** — Real-time streaming via SSE/WebSocket, agent selector, agent identity on messages, stop/edit/regenerate, slash commands and @mentions - [ ] **Phase 23: Brainstormer Flow** — Brainstormer agent persona, structured questioning flow, spec generation, PM handoff, task creation from chat, agent status updates in chat - [ ] **Phase 24: Search, History & Branching** — Full-text search across all conversations, export, conversation branching, message bookmarks @@ -31,7 +31,7 @@ 3. Agent messages render with full markdown: code blocks with syntax highlighting and a copy button, tables, lists, headings, links, and inline images 4. Conversations and all messages are stored in libSQL and survive a server restart 5. The chat interface applies Catppuccin Mocha, Tokyo Night, and Catppuccin Latte themes correctly; code block highlighting matches the active theme -**Plans:** 6/6 plans complete +**Plans:** 7 plans (6 complete + 1 gap closure) Plans: - [x] 21-00-PLAN.md — Wave 0 test stubs (chat-service, chat-routes, ChatMarkdownMessage, ChatInput) @@ -40,6 +40,7 @@ Plans: - [x] 21-03-PLAN.md — Server chat service and REST API routes (CRUD + pagination) - [x] 21-04-PLAN.md — ChatPanel shell, ChatPanelContext, ChatInput, Layout integration - [x] 21-05-PLAN.md — Full UI wiring: API client, conversation list, message thread, infinite scroll +- [ ] 21-06-PLAN.md — Gap closure: conversation search/filter (HIST-02) + Cmd+K shortcut (INPUT-07) **UI hint**: yes @@ -189,7 +190,7 @@ All 65 v1 requirements are mapped to exactly one phase. No orphans. | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| -| 21. Chat Foundation | v1.3 | 6/6 | Complete | 2026-04-01 | +| 21. Chat Foundation | v1.3 | 6/7 | Gap closure | - | | 22. Agent Streaming | v1.3 | 0/? | Not started | - | | 23. Brainstormer Flow | v1.3 | 0/? | Not started | - | | 24. Search, History & Branching | v1.3 | 0/? | Not started | - | diff --git a/.planning/phases/21-chat-foundation/21-06-PLAN.md b/.planning/phases/21-chat-foundation/21-06-PLAN.md new file mode 100644 index 00000000..536145a5 --- /dev/null +++ b/.planning/phases/21-chat-foundation/21-06-PLAN.md @@ -0,0 +1,328 @@ +--- +phase: 21-chat-foundation +plan: 06 +type: execute +wave: 1 +depends_on: [] +files_modified: + - server/src/services/chat.ts + - server/src/routes/chat.ts + - ui/src/api/chat.ts + - ui/src/hooks/useChatConversations.ts + - ui/src/components/ChatConversationList.tsx + - ui/src/hooks/useKeyboardShortcuts.ts + - ui/src/components/Layout.tsx +autonomous: true +gap_closure: true +requirements: + - HIST-02 + - INPUT-07 + +must_haves: + truths: + - "Typing in the search input filters the conversation list to only matching titles" + - "Cmd+K (Mac) or Ctrl+K (non-Mac) focuses the conversation search input when the chat panel is open" + - "Passing an agentId query parameter to GET /companies/:companyId/conversations returns only conversations for that agent" + - "Clearing the search input restores the full conversation list" + artifacts: + - path: "server/src/services/chat.ts" + provides: "listConversations with search and agentId filter params" + contains: "ilike" + - path: "server/src/routes/chat.ts" + provides: "GET route reads search and agentId query params" + contains: "req.query" + - path: "ui/src/components/ChatConversationList.tsx" + provides: "Search input above conversation list" + contains: "search" + - path: "ui/src/hooks/useKeyboardShortcuts.ts" + provides: "Cmd+K handler" + contains: "onSearch" + key_links: + - from: "ui/src/components/ChatConversationList.tsx" + to: "ui/src/hooks/useChatConversations.ts" + via: "search param passed to hook which passes to chatApi" + pattern: "useChatConversations.*search" + - from: "ui/src/hooks/useKeyboardShortcuts.ts" + to: "ui/src/components/Layout.tsx" + via: "onSearch callback focuses chat search input" + pattern: "onSearch" + - from: "ui/src/api/chat.ts" + to: "server/src/routes/chat.ts" + via: "search query param in URL" + pattern: "search" +--- + + +Close two verification gaps from Phase 21: conversation search/filtering (HIST-02) and Cmd+K keyboard shortcut (INPUT-07). + +Purpose: Complete the two remaining PARTIAL requirements that prevent Phase 21 from fully passing verification. +Output: Searchable conversation list with server-side filtering, Cmd+K shortcut to focus search. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/21-chat-foundation/21-VERIFICATION.md + + + + +From packages/shared/src/types/chat.ts: +```typescript +export interface ChatConversationListItem { + id: string; + companyId: string; + title: string | null; + agentId: string | null; + pinnedAt: string | null; + archivedAt: string | null; + updatedAt: string; + lastMessagePreview: string | null; +} + +export interface ChatConversationListResponse { + items: ChatConversationListItem[]; + hasMore: boolean; +} +``` + +From server/src/services/chat.ts: +```typescript +export function chatService(db: Db) { + return { + async listConversations( + companyId: string, + opts: { cursor?: string; limit?: number; includeArchived?: boolean }, + ) { ... }, + // ... other methods + }; +} +``` + +From ui/src/hooks/useKeyboardShortcuts.ts: +```typescript +interface ShortcutHandlers { + onNewIssue?: () => void; + onToggleSidebar?: () => void; + onTogglePanel?: () => void; +} +export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) { ... } +``` + +From ui/src/api/chat.ts: +```typescript +export const chatApi = { + listConversations(companyId: string, opts?: { cursor?: string; limit?: number }) { ... }, + // ... other methods +}; +``` + +From ui/src/hooks/useChatConversations.ts: +```typescript +export function useChatConversations(companyId: string | null) { + // useInfiniteQuery with queryKey: ["chat", "conversations", companyId] + // queryFn calls chatApi.listConversations(companyId!, { cursor: pageParam }) +} +``` + +From ui/src/components/Layout.tsx (lines 159-163): +```typescript +useKeyboardShortcuts({ + onNewIssue: () => openNewIssue(), + onToggleSidebar: toggleSidebar, + onTogglePanel: togglePanel, +}); +``` + + + + + + + Task 1: Add search and agentId filter to server service, route, and API client + server/src/services/chat.ts, server/src/routes/chat.ts, ui/src/api/chat.ts, ui/src/hooks/useChatConversations.ts + + - server/src/services/chat.ts (full file — understand listConversations signature and condition-building pattern) + - server/src/routes/chat.ts (full file — understand how query params are extracted) + - ui/src/api/chat.ts (full file — understand how query params are serialized) + - ui/src/hooks/useChatConversations.ts (full file — understand queryKey and queryFn) + - packages/shared/src/types/chat.ts (ChatConversationListResponse type) + + + **server/src/services/chat.ts** — Extend `listConversations` opts type and query: + 1. Add `import { ilike } from "drizzle-orm"` to the existing import (add `ilike` alongside `and, desc, eq, isNull, lt`) + 2. Extend the opts parameter type: `opts: { cursor?: string; limit?: number; includeArchived?: boolean; search?: string; agentId?: string }` + 3. In the conditions array, after the existing conditions, add: + - If `opts.search` is truthy: `conditions.push(ilike(chatConversations.title, \`%${opts.search}%\`))` + - If `opts.agentId` is truthy: `conditions.push(eq(chatConversations.agentId, opts.agentId))` + + **server/src/routes/chat.ts** — Pass search and agentId from query params: + 1. In the GET `/companies/:companyId/conversations` handler, destructure `search` and `agentId` from `req.query` alongside the existing `cursor, limit, includeArchived` + 2. Pass them to `svc.listConversations`: `search: search as string | undefined, agentId: agentId as string | undefined` + + **ui/src/api/chat.ts** — Add search and agentId to the API client: + 1. Extend `listConversations` opts type: `opts?: { cursor?: string; limit?: number; search?: string; agentId?: string }` + 2. In the URLSearchParams builder, add: `if (opts?.search) params.set("search", opts.search)` and `if (opts?.agentId) params.set("agentId", opts.agentId)` + + **ui/src/hooks/useChatConversations.ts** — Accept search param and pass through: + 1. Change signature: `export function useChatConversations(companyId: string | null, opts?: { search?: string })` + 2. Add `opts?.search` to the queryKey: `queryKey: ["chat", "conversations", companyId, opts?.search ?? ""]` + 3. In the queryFn, pass search: `chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined, search: opts?.search || undefined })` + + + cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit 2>&1 | grep -i chat; pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | grep -i chat; echo "TypeScript check done" + + + - `server/src/services/chat.ts` listConversations accepts `search?: string` and `agentId?: string` in opts + - When `search` is provided, the query includes `ilike(chatConversations.title, '%search%')` + - When `agentId` is provided, the query includes `eq(chatConversations.agentId, agentId)` + - `server/src/routes/chat.ts` extracts `search` and `agentId` from `req.query` and passes to service + - `ui/src/api/chat.ts` serializes `search` and `agentId` as URL query params + - `ui/src/hooks/useChatConversations.ts` accepts `opts?: { search?: string }` and includes search in queryKey + - TypeScript compilation passes for both server and ui packages (no new errors in chat files) + + Server-side search and agent filtering works end-to-end from API client through route to database query. The hook re-fetches when search changes due to updated queryKey. + + + + Task 2: Add search input to ChatConversationList and Cmd+K shortcut + ui/src/components/ChatConversationList.tsx, ui/src/hooks/useKeyboardShortcuts.ts, ui/src/components/Layout.tsx + + - ui/src/components/ChatConversationList.tsx (full file — understand structure, imports, existing state) + - ui/src/hooks/useKeyboardShortcuts.ts (full file — understand ShortcutHandlers interface and handler pattern) + - ui/src/components/Layout.tsx (lines 1-30 for imports, lines 155-165 for useKeyboardShortcuts call, and lines 440-470 for ChatPanel render area) + - ui/src/context/ChatPanelContext.tsx (full file — understand useChatPanel exports) + + + **ui/src/components/ChatConversationList.tsx** — Add search input with debounced filtering: + 1. Add `import { Search, X } from "lucide-react"` (add Search and X to existing lucide imports; Plus is already imported) + 2. Add `import { Input } from "@/components/ui/input"` (shadcn input component) + 3. Add a `useRef(null)` for the search input ref — name it `searchInputRef` + 4. Add `const [searchTerm, setSearchTerm] = useState("")` state + 5. Add a debounced search value with a simple useEffect + setTimeout pattern: + ``` + const [debouncedSearch, setDebouncedSearch] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchTerm), 300); + return () => clearTimeout(timer); + }, [searchTerm]); + ``` + 6. Pass debouncedSearch to the hook: change `useChatConversations(companyId)` to `useChatConversations(companyId, { search: debouncedSearch || undefined })` + 7. Add a search input between the "New conversation" button div and the ScrollArea: + ```tsx +
+
+ + setSearchTerm(e.target.value)} + placeholder="Search conversations..." + className="h-7 pl-7 pr-7 text-xs" + /> + {searchTerm && ( + + )} +
+
+ ``` + 8. Export the searchInputRef via an imperative handle: Add `useImperativeHandle` from React. Change the component to use `forwardRef`. Define the handle interface: + ```typescript + export interface ChatConversationListHandle { + focusSearch: () => void; + } + ``` + In `useImperativeHandle(ref, () => ({ focusSearch: () => searchInputRef.current?.focus() }))`. + + **ui/src/hooks/useKeyboardShortcuts.ts** — Add onSearch handler for Cmd+K: + 1. Add `onSearch?: () => void` to the `ShortcutHandlers` interface + 2. Add a new handler block BEFORE the existing shortcut checks (Cmd+K uses metaKey/ctrlKey, so it won't conflict with the input-guard since Cmd+K is a global shortcut that should work even from inputs): + ```typescript + // Cmd+K / Ctrl+K → Search (global, works even from inputs) + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onSearch?.(); + return; + } + ``` + Place this check BEFORE the `if (target.tagName === "INPUT" ...)` early return, so Cmd+K fires even when focused in an input/textarea. + 3. Add `onSearch` to the useEffect dependency array + + **ui/src/components/Layout.tsx** — Wire Cmd+K to focus chat search: + 1. Add `import { useRef } from "react"` (add useRef to the existing React import) + 2. Add `import type { ChatConversationListHandle } from "./ChatConversationList"` + 3. Create a ref: `const chatSearchRef = useRef(null)` + 4. Add `onSearch` to the `useKeyboardShortcuts` call: + ```typescript + useKeyboardShortcuts({ + onNewIssue: () => openNewIssue(), + onToggleSidebar: toggleSidebar, + onTogglePanel: togglePanel, + onSearch: () => { + if (!chatOpen) setChatOpen(true); + // Use requestAnimationFrame to ensure panel is visible before focusing + requestAnimationFrame(() => chatSearchRef.current?.focusSearch()); + }, + }); + ``` + 5. Pass the ref down: The ChatConversationList is rendered inside ChatPanel, which is rendered inside Layout. The simplest approach is to expose a `searchRef` prop on ChatPanel and pass it through. + + ALTERNATIVE (simpler): Instead of threading refs, add a dedicated `useEffect` in `ChatConversationList` that listens for a custom event: + - In ChatConversationList, add: `useEffect(() => { const handler = () => searchInputRef.current?.focus(); window.addEventListener("nexus:focus-chat-search", handler); return () => window.removeEventListener("nexus:focus-chat-search", handler); }, []);` + - In Layout's onSearch callback: `if (!chatOpen) setChatOpen(true); requestAnimationFrame(() => window.dispatchEvent(new Event("nexus:focus-chat-search")));` + - This avoids drilling refs through ChatPanel. Use this approach. + + With the custom event approach, you do NOT need chatSearchRef, ChatConversationListHandle, or forwardRef. Simplify: + - ChatConversationList: do NOT use forwardRef or useImperativeHandle. Just add the event listener useEffect with searchInputRef. + - Layout: just dispatch the event in onSearch. + - useKeyboardShortcuts: add onSearch to interface and handler as described above. +
+ + cd /opt/nexus && pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | tail -5; echo "---"; pnpm vitest run ui/src/components/ChatInput.test.tsx 2>&1 | tail -5 + + + - ChatConversationList renders an `` with placeholder "Search conversations..." + - The search input has a Search icon on the left and a clear (X) button when non-empty + - Typing in the search input debounces at 300ms then passes the search term to useChatConversations + - useKeyboardShortcuts has an `onSearch` handler that fires on Cmd+K (metaKey+k) or Ctrl+K (ctrlKey+k) + - The Cmd+K handler fires even when focus is in an input or textarea (it is checked before the input-guard early return) + - Layout.tsx wires onSearch to open the chat panel (if closed) and dispatch "nexus:focus-chat-search" event + - ChatConversationList listens for "nexus:focus-chat-search" and focuses the search input + - TypeScript compilation passes with no new errors + - Existing ChatInput tests still pass + + Users can type in the search input to filter conversations by title. Cmd+K (Mac) or Ctrl+K (other) opens the chat panel if needed and focuses the search input. Clearing the search restores the full list. +
+ +
+ + +1. TypeScript: `pnpm --filter @paperclipai/server exec -- tsc --noEmit` and `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` — no new errors in chat files +2. Existing tests: `pnpm vitest run server/src/__tests__/chat-service.test.ts server/src/__tests__/chat-routes.test.ts ui/src/components/ChatMarkdownMessage.test.tsx ui/src/components/ChatInput.test.tsx` — all pass +3. Search input visible in ChatConversationList (grep for `Search conversations` in ChatConversationList.tsx) +4. Cmd+K handler present (grep for `metaKey.*ctrlKey.*k` in useKeyboardShortcuts.ts) +5. Server route passes search param (grep for `search` in server/src/routes/chat.ts GET handler) + + + +- HIST-02 gap closed: conversation list is searchable via a search input with server-side ilike filtering on title; agentId filter parameter accepted by service and route +- INPUT-07 gap closed: Cmd+K / Ctrl+K keyboard shortcut opens chat panel and focuses the search input +- All existing tests continue to pass +- TypeScript compilation clean + + + +After completion, create `.planning/phases/21-chat-foundation/21-06-SUMMARY.md` +