nexus/.planning/phases/21-chat-foundation/21-06-PLAN.md

16 KiB

phase plan type wave depends_on files_modified autonomous gap_closure requirements must_haves
21-chat-foundation 06 execute 1
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
true true
HIST-02
INPUT-07
truths artifacts key_links
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
path provides contains
server/src/services/chat.ts listConversations with search and agentId filter params ilike
path provides contains
server/src/routes/chat.ts GET route reads search and agentId query params req.query
path provides contains
ui/src/components/ChatConversationList.tsx Search input above conversation list search
path provides contains
ui/src/hooks/useKeyboardShortcuts.ts Cmd+K handler onSearch
from to via pattern
ui/src/components/ChatConversationList.tsx ui/src/hooks/useChatConversations.ts search param passed to hook which passes to chatApi useChatConversations.*search
from to via pattern
ui/src/hooks/useKeyboardShortcuts.ts ui/src/components/Layout.tsx onSearch callback focuses chat search input onSearch
from to via pattern
ui/src/api/chat.ts server/src/routes/chat.ts search query param in URL 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.

<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/STATE.md @.planning/phases/21-chat-foundation/21-VERIFICATION.md

From packages/shared/src/types/chat.ts:

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:

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:

interface ShortcutHandlers {
  onNewIssue?: () => void;
  onToggleSidebar?: () => void;
  onTogglePanel?: () => void;
}
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) { ... }

From ui/src/api/chat.ts:

export const chatApi = {
  listConversations(companyId: string, opts?: { cursor?: string; limit?: number }) { ... },
  // ... other methods
};

From ui/src/hooks/useChatConversations.ts:

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):

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<ChatConversationListHandle>(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)

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/21-chat-foundation/21-06-SUMMARY.md`