--- phase: 22-add-from-catalog-thread-integration plan: 02 type: execute wave: 2 depends_on: ["22-01"] files_modified: - src/client/components/AddToThreadModal.tsx - src/client/routes/__root.tsx autonomous: false requirements: [CATFLOW-05, CATFLOW-06] must_haves: truths: - "Clicking Add on a catalog search card in thread mode opens the AddToThreadModal with a thread picker" - "User can select an existing active thread and the catalog item is added as a candidate" - "User can choose New Thread which shows thread name + category fields and creates thread + candidate in one step" - "After creating a new thread, subsequent adds in same session default to that thread" - "Clicking Add to Thread on the global item detail page opens the same thread picker modal" artifacts: - path: "src/client/components/AddToThreadModal.tsx" provides: "Thread picker modal with new thread creation flow" min_lines: 120 - path: "src/client/routes/__root.tsx" provides: "AddToThreadModal rendered at root level" contains: "AddToThreadModal" key_links: - from: "src/client/components/AddToThreadModal.tsx" to: "/api/threads" via: "useCreateThread for new thread creation" pattern: "useCreateThread" - from: "src/client/components/AddToThreadModal.tsx" to: "/api/threads/:threadId/candidates" via: "apiPost for candidate creation after thread selection/creation" pattern: "apiPost.*candidates" - from: "src/client/components/AddToThreadModal.tsx" to: "src/client/stores/uiStore.ts" via: "setCatalogSessionThreadId to remember thread selection" pattern: "setCatalogSessionThreadId" --- Build the AddToThreadModal: thread picker with existing active threads, "New Thread..." option for combined thread+candidate creation, and session thread memory. Wire to root layout. Verify CATFLOW-06 regression (thread resolution with catalog-linked candidates) via existing service tests. Purpose: CATFLOW-05 -- users can add catalog items as thread candidates from search. CATFLOW-06 -- resolution already works, confirmed by existing tests. Output: Working add-to-thread flow, complete Phase 22 catalog integration. @$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/22-add-from-catalog-thread-integration/22-CONTEXT.md @.planning/phases/22-add-from-catalog-thread-integration/22-RESEARCH.md @.planning/phases/22-add-from-catalog-thread-integration/22-01-SUMMARY.md From src/client/stores/uiStore.ts (after Plan 01): ```typescript addToThreadModal: { open: boolean; globalItemId: number | null; globalItemName: string | null }; openAddToThread: (globalItemId: number, globalItemName: string) => void; closeAddToThread: () => void; catalogSessionThreadId: number | null; setCatalogSessionThreadId: (id: number | null) => void; ``` From src/client/hooks/useThreads.ts: ```typescript export function useThreads(includeResolved = false) // Returns ThreadListItem[] with: id, name, status, categoryId, categoryName, candidateCount export function useCreateThread() // mutationFn: (data: { name: string; categoryId: number }) => apiPost("/api/threads", data) // onSuccess invalidates ["threads"] ``` From src/client/hooks/useCandidates.ts: ```typescript export function useCreateCandidate(threadId: number) // mutationFn: (data: CreateCandidate & { imageFilename?: string }) => apiPost(...) // NOTE: threadId is a hook parameter, not in mutation payload // For dynamic threadId (new thread flow), use apiPost directly instead ``` From src/shared/schemas.ts: ```typescript export const createCandidateSchema = z.object({ name: z.string().min(1, "Name is required"), weightGrams: z.number().nonnegative().optional(), priceCents: z.number().int().nonnegative().optional(), categoryId: z.number().int().positive(), notes: z.string().optional(), productUrl: z.string().url().optional().or(z.literal("")), imageFilename: z.string().optional(), imageSourceUrl: z.string().url().optional().or(z.literal("")), status: candidateStatusSchema.optional(), pros: z.string().optional(), cons: z.string().optional(), globalItemId: z.number().int().positive().optional(), }); ``` From src/client/lib/api.ts: ```typescript export async function apiPost(url: string, body: unknown): Promise ``` From src/client/hooks/useGlobalItems.ts: ```typescript export function useGlobalItem(id: number | null) // Returns globalItem with: id, brand, model, category, weightGrams, priceCents, imageUrl, description, ownerCount // NOTE: Already has `enabled: id != null` guard built in -- safe to call with null globalItemId ``` Task 1: Build AddToThreadModal with thread picker and new thread flow src/client/components/AddToThreadModal.tsx src/client/routes/__root.tsx src/client/stores/uiStore.ts src/client/components/CreateThreadModal.tsx src/client/components/AddToCollectionModal.tsx src/client/hooks/useThreads.ts src/client/hooks/useCandidates.ts src/client/hooks/useGlobalItems.ts src/client/hooks/useCategories.ts src/shared/schemas.ts src/client/lib/api.ts src/client/routes/__root.tsx **Create `src/client/components/AddToThreadModal.tsx`** This modal has two modes: "pick" (select existing thread) and "create" (new thread + candidate). **Component structure:** ```typescript import { useState, useEffect } from "react"; import { toast } from "sonner"; import { useCategories } from "../hooks/useCategories"; import { useGlobalItem } from "../hooks/useGlobalItems"; import { useCreateThread, useThreads } from "../hooks/useThreads"; import { apiPost } from "../lib/api"; import { useUIStore } from "../stores/uiStore"; import { useQueryClient } from "@tanstack/react-query"; ``` **State management:** - Read from UIStore: `addToThreadModal` (open, globalItemId, globalItemName), `closeAddToThread`, `catalogSessionThreadId`, `setCatalogSessionThreadId` - Local state: `mode` ("pick" | "create"), `selectedThreadId` (number | null), `newThreadName` (string), `newThreadCategoryId` (number | null), `isSubmitting` (boolean), `error` (string | null) - Fetch: `useThreads()` for thread list, `useCategories()` for new thread category dropdown, `useGlobalItem(globalItemId)` for the global item data needed to create the candidate. NOTE: `useGlobalItem` already has `enabled: id != null` guard built in, so passing `globalItemId` (which may be null when modal is closed) is safe -- no additional enabled guard needed. - `useCreateThread()` for thread creation, `useQueryClient()` for manual invalidation **Initialization logic (per D-12, D-19):** - When modal opens (`open` becomes true), if `catalogSessionThreadId` is set, pre-select it in the thread picker (`selectedThreadId = catalogSessionThreadId`) - Filter threads to active only: `threads?.filter((t) => t.status === "active") ?? []` - If no active threads exist, auto-switch to "create" mode (per D-09 empty state) **"Pick" mode UI (per D-06, D-07):** - Header: "Add to Thread" - Subheading: show `globalItemName` in `

` - Thread dropdown `