diff --git a/.changeset/pinned-chats.md b/.changeset/pinned-chats.md new file mode 100644 index 00000000..f9b32c6a --- /dev/null +++ b/.changeset/pinned-chats.md @@ -0,0 +1,5 @@ +--- +"@codex-relay/mobile": patch +--- + +Persist pinned chats locally so they remain available across app restarts. diff --git a/apps/mobile/src/components/chat/ThreadDrawerContent.tsx b/apps/mobile/src/components/chat/ThreadDrawerContent.tsx index 43a220b7..9e524793 100644 --- a/apps/mobile/src/components/chat/ThreadDrawerContent.tsx +++ b/apps/mobile/src/components/chat/ThreadDrawerContent.tsx @@ -67,17 +67,8 @@ import { setHasPairedSession, setThreadMessagesLoading, } from "@/state/chat-store"; - -type DrawerRow = - | { - id: string; - kind: "project"; - projectKey: string; - title: string; - workspacePath?: string; - } - | { id: string; kind: "thread"; projectKey: string; thread: ThreadSummary } - | { id: string; kind: "more"; hiddenCount: number; projectKey: string }; +import { pinnedThreadStore$, togglePinnedThread, unpinThread } from "@/state/pinned-thread-store"; +import { buildDrawerRows, type DrawerRow } from "./thread-drawer-rows"; type WorkspaceBrowser = { directories: { name: string; path: string }[]; @@ -122,7 +113,6 @@ type ThreadDrawerContentProps = Parameters< type ThreadDrawerNavigation = ThreadDrawerContentProps["navigation"]; -const collapsedProjectThreadCount = 5; const drawerListDrawDistance = 96; const drawerRowEstimatedSize = 40; const drawerListIdleTimeoutMs = 180; @@ -204,7 +194,8 @@ export function ThreadDrawerContent(props: ThreadDrawerContentProps) { setActiveThread(context.previousActiveThreadId); } }, - onSuccess: async () => { + onSuccess: async (_response, threadId) => { + unpinThread(threadId); await queryClient.invalidateQueries({ queryKey: serverStateKeys.threads() }); }, }); @@ -213,6 +204,7 @@ export function ThreadDrawerContent(props: ThreadDrawerContentProps) { renameThreadServerState(queryClient, threadId, { title }), }); const activeThreadId = useSelector(() => chatStore$.activeThreadId.get()); + const pinnedThreadIds = useSelector(() => pinnedThreadStore$.threadIds.get()); const statusQuery = useQuery({ queryKey: serverStateKeys.status(), queryFn: serverStateQueryFns.status, @@ -270,11 +262,15 @@ export function ThreadDrawerContent(props: ThreadDrawerContentProps) { visibleThreads, expandedProjects, activeThreadId, + pinnedThreadIds, Boolean(normalizedSearchQuery), ), - [activeThreadId, expandedProjects, normalizedSearchQuery, visibleThreads], + [activeThreadId, expandedProjects, normalizedSearchQuery, pinnedThreadIds, visibleThreads], ); const workspaceRows = useMemo(() => workspaceBrowserRows(workspaceBrowser), [workspaceBrowser]); + const threadWithActionsIsPinned = Boolean( + threadWithActions && pinnedThreadIds.includes(threadWithActions.id), + ); const canSaveRenamedThread = Boolean( canMutateAppServerThreads && threadToRename && @@ -298,6 +294,16 @@ export function ThreadDrawerContent(props: ThreadDrawerContentProps) { setThreadToRename(undefined); setRenameDraft(""); }, []); + const handleTogglePinnedThread = useCallback( + (thread: ThreadSummary) => { + togglePinnedThread(thread.id); + hapticSelection(); + if (threadWithActions?.id === thread.id) { + closeThreadActions(); + } + }, + [closeThreadActions, threadWithActions?.id], + ); const returnToThreadActions = useCallback(() => { setThreadToRename(undefined); setRenameDraft(""); @@ -406,9 +412,11 @@ export function ThreadDrawerContent(props: ThreadDrawerContentProps) { item={item} onArchiveThread={confirmArchiveThread} onCreateThread={createNewThread} - onRenameThread={openThreadActions} + onOpenThreadActions={openThreadActions} onSelectThread={selectThread} + onTogglePinnedThread={handleTogglePinnedThread} onToggleProject={toggleProject} + pinned={item.kind === "thread" && pinnedThreadIds.includes(item.thread.id)} selected={item.kind === "thread" && item.thread.id === activeThreadId} workspacePath={workspacePath} /> @@ -419,8 +427,10 @@ export function ThreadDrawerContent(props: ThreadDrawerContentProps) { canMutateAppServerThreads, confirmArchiveThread, createNewThread, + handleTogglePinnedThread, isCreatingThread, openThreadActions, + pinnedThreadIds, selectThread, toggleProject, workspacePath, @@ -539,14 +549,24 @@ export function ThreadDrawerContent(props: ThreadDrawerContentProps) { - ) : ( - - )} + ) : threadWithActions ? ( + <> + handleTogglePinnedThread(threadWithActions)} + title={threadWithActionsIsPinned ? "Unpin chat" : "Pin chat"} + /> + {canMutateAppServerThreads ? ( + + ) : null} + + ) : null} {props.showResizeHandle ? ( @@ -875,9 +895,11 @@ type DrawerRowItemProps = { item: DrawerRow; onArchiveThread: (thread: ThreadSummary) => void; onCreateThread: (workspacePath: string | undefined) => Promise; - onRenameThread: (thread: ThreadSummary) => void; + onOpenThreadActions: (thread: ThreadSummary) => void; onSelectThread: (threadId: string) => void; + onTogglePinnedThread: (thread: ThreadSummary) => void; onToggleProject: (projectKey: string) => void; + pinned: boolean; selected: boolean; workspacePath: string | undefined; }; @@ -889,14 +911,27 @@ const DrawerRowItem = memo(function DrawerRowItem({ item, onArchiveThread, onCreateThread, - onRenameThread, + onOpenThreadActions, onSelectThread, + onTogglePinnedThread, onToggleProject, + pinned, selected, workspacePath, }: DrawerRowItemProps) { const theme = useTheme(); + if (item.kind === "pinned") { + return ( + + + + + Pinned + + ); + } + if (item.kind === "project") { return ( @@ -942,27 +977,27 @@ const DrawerRowItem = memo(function DrawerRowItem({ } const running = item.thread.state === "running"; + const relativeTime = formatRelativeTime(item.thread.lastActivityAt ?? item.thread.updatedAt); return ( { - if (event.nativeEvent.actionName === "rename") { - onRenameThread(item.thread); - } - } - : undefined - } - onLongPress={canRenameThread ? () => onRenameThread(item.thread) : undefined} + onAccessibilityAction={(event) => { + if (event.nativeEvent.actionName === "toggle-pin") { + onTogglePinnedThread(item.thread); + } else if (event.nativeEvent.actionName === "rename" && canRenameThread) { + onOpenThreadActions(item.thread); + } + }} + onLongPress={() => onOpenThreadActions(item.thread)} onPress={() => void onSelectThread(item.thread.id)} style={styles.threadOpenButton} > @@ -977,8 +1012,12 @@ const DrawerRowItem = memo(function DrawerRowItem({ {item.thread.title} - - {formatRelativeTime(item.thread.lastActivityAt ?? item.thread.updatedAt)} + + {item.workspaceTitle ? `${item.workspaceTitle} · ${relativeTime}` : relativeTime} @@ -1007,9 +1046,11 @@ function areDrawerRowItemsEqual(previous: DrawerRowItemProps, next: DrawerRowIte previous.item.id !== next.item.id || previous.onArchiveThread !== next.onArchiveThread || previous.onCreateThread !== next.onCreateThread || - previous.onRenameThread !== next.onRenameThread || + previous.onOpenThreadActions !== next.onOpenThreadActions || previous.onSelectThread !== next.onSelectThread || + previous.onTogglePinnedThread !== next.onTogglePinnedThread || previous.onToggleProject !== next.onToggleProject || + previous.pinned !== next.pinned || previous.selected !== next.selected || previous.workspacePath !== next.workspacePath ) { @@ -1018,11 +1059,12 @@ function areDrawerRowItemsEqual(previous: DrawerRowItemProps, next: DrawerRowIte if (previous.item.kind === "thread" && next.item.kind === "thread") { return ( - previous.item.thread === next.item.thread || - (previous.item.thread.title === next.item.thread.title && - previous.item.thread.state === next.item.thread.state && - previous.item.thread.lastActivityAt === next.item.thread.lastActivityAt && - previous.item.thread.updatedAt === next.item.thread.updatedAt) + previous.item.workspaceTitle === next.item.workspaceTitle && + (previous.item.thread === next.item.thread || + (previous.item.thread.title === next.item.thread.title && + previous.item.thread.state === next.item.thread.state && + previous.item.thread.lastActivityAt === next.item.thread.lastActivityAt && + previous.item.thread.updatedAt === next.item.thread.updatedAt)) ); } @@ -1403,70 +1445,6 @@ function VersionNoticeRow({ label, value }: { label: string; value: string }) { ); } -function buildDrawerRows( - threads: ThreadSummary[], - expandedProjects: Record, - activeThreadId: string | undefined, - forceExpanded = false, -): DrawerRow[] { - const groups = new Map< - string, - { title: string; threads: ThreadSummary[]; workspacePath?: string } - >(); - - for (const thread of threads) { - const title = workspaceName(thread.cwd) ?? "codex-relay"; - const key = thread.cwd ?? title; - const group = groups.get(key); - if (group) { - group.threads.push(thread); - } else { - groups.set(key, { title, threads: [thread], workspacePath: thread.cwd }); - } - } - - return [...groups.entries()].flatMap(([projectKey, group]) => { - const isExpanded = forceExpanded || (expandedProjects[projectKey] ?? false); - const activeThread = activeThreadId - ? group.threads.find((thread) => thread.id === activeThreadId) - : undefined; - const collapsedThreads = group.threads.slice(0, collapsedProjectThreadCount); - const visibleThreads = - isExpanded || !activeThread || collapsedThreads.includes(activeThread) - ? isExpanded - ? group.threads - : collapsedThreads - : [...collapsedThreads.slice(0, collapsedProjectThreadCount - 1), activeThread]; - const hiddenCount = group.threads.length - visibleThreads.length; - const projectRows: DrawerRow[] = [ - { - id: `project:${projectKey}`, - kind: "project", - projectKey, - title: group.title, - workspacePath: group.workspacePath, - }, - ...visibleThreads.map((thread) => ({ - id: `thread:${thread.id}`, - kind: "thread" as const, - projectKey, - thread, - })), - ]; - - if (hiddenCount > 0) { - projectRows.push({ - id: `more:${projectKey}`, - kind: "more", - hiddenCount, - projectKey, - }); - } - - return projectRows; - }); -} - function workspaceBrowserRows(browser: WorkspaceBrowser | undefined): WorkspaceBrowserRow[] { if (!browser) { return []; diff --git a/apps/mobile/src/components/chat/thread-drawer-rows.ts b/apps/mobile/src/components/chat/thread-drawer-rows.ts new file mode 100644 index 00000000..9031c051 --- /dev/null +++ b/apps/mobile/src/components/chat/thread-drawer-rows.ts @@ -0,0 +1,144 @@ +import type { ThreadSummary } from "codex-relay/api-schema"; + +import { workspaceName } from "../../lib/workspace-name"; + +const collapsedProjectThreadCount = 5; + +export type DrawerRow = + | { id: "pinned"; kind: "pinned" } + | { + id: string; + kind: "project"; + projectKey: string; + title: string; + workspacePath?: string; + } + | { + id: string; + kind: "thread"; + projectKey: string; + thread: ThreadSummary; + workspaceTitle?: string; + } + | { id: string; kind: "more"; hiddenCount: number; projectKey: string }; + +type ThreadGroup = { + title: string; + threads: ThreadSummary[]; + workspacePath?: string; +}; + +export function buildDrawerRows( + threads: ThreadSummary[], + expandedProjects: Record, + activeThreadId: string | undefined, + pinnedThreadIds: string[] = [], + forceExpanded = false, +): DrawerRow[] { + const uniqueThreads = threadsWithUniqueIds(threads); + const threadsById = new Map(uniqueThreads.map((thread) => [thread.id, thread])); + const pinnedThreads = forceExpanded ? [] : pinnedThreadsForIds(pinnedThreadIds, threadsById); + const pinnedThreadIdsSet = new Set(pinnedThreads.map((thread) => thread.id)); + const groups = new Map(); + + for (const thread of uniqueThreads) { + const title = workspaceName(thread.cwd) ?? "codex-relay"; + const projectKey = thread.cwd ?? title; + const group = groups.get(projectKey); + if (group) { + group.threads.push(thread); + } else { + groups.set(projectKey, { title, threads: [thread], workspacePath: thread.cwd }); + } + } + + const rows: DrawerRow[] = []; + if (pinnedThreads.length > 0) { + rows.push({ id: "pinned", kind: "pinned" }); + rows.push( + ...pinnedThreads.map((thread) => + threadRow(thread, projectKeyForThread(thread), workspaceName(thread.cwd) ?? "codex-relay"), + ), + ); + } + + for (const [projectKey, group] of groups) { + const unpinnedThreads = forceExpanded + ? group.threads + : group.threads.filter((thread) => !pinnedThreadIdsSet.has(thread.id)); + const isExpanded = forceExpanded || (expandedProjects[projectKey] ?? false); + const activeThread = activeThreadId + ? unpinnedThreads.find((thread) => thread.id === activeThreadId) + : undefined; + const collapsedThreads = unpinnedThreads.slice(0, collapsedProjectThreadCount); + const visibleThreads = + isExpanded || !activeThread || collapsedThreads.includes(activeThread) + ? isExpanded + ? unpinnedThreads + : collapsedThreads + : [...collapsedThreads.slice(0, collapsedProjectThreadCount - 1), activeThread]; + const hiddenCount = unpinnedThreads.length - visibleThreads.length; + + rows.push({ + id: `project:${projectKey}`, + kind: "project", + projectKey, + title: group.title, + workspacePath: group.workspacePath, + }); + rows.push(...visibleThreads.map((thread) => threadRow(thread, projectKey))); + + if (hiddenCount > 0) { + rows.push({ + id: `more:${projectKey}`, + kind: "more", + hiddenCount, + projectKey, + }); + } + } + + return rows; +} + +function threadsWithUniqueIds(threads: ThreadSummary[]) { + const threadIds = new Set(); + return threads.filter((thread) => { + if (threadIds.has(thread.id)) { + return false; + } + threadIds.add(thread.id); + return true; + }); +} + +function pinnedThreadsForIds(pinnedThreadIds: string[], threadsById: Map) { + const pinnedIds = new Set(); + const pinnedThreads: ThreadSummary[] = []; + for (const threadId of pinnedThreadIds) { + if (pinnedIds.has(threadId)) { + continue; + } + pinnedIds.add(threadId); + const thread = threadsById.get(threadId); + if (thread) { + pinnedThreads.push(thread); + } + } + return pinnedThreads; +} + +function projectKeyForThread(thread: ThreadSummary) { + const title = workspaceName(thread.cwd) ?? "codex-relay"; + return thread.cwd ?? title; +} + +function threadRow(thread: ThreadSummary, projectKey: string, workspaceTitle?: string): DrawerRow { + return { + id: `thread:${thread.id}`, + kind: "thread", + projectKey, + thread, + ...(workspaceTitle ? { workspaceTitle } : {}), + }; +} diff --git a/apps/mobile/src/components/ui/icon.tsx b/apps/mobile/src/components/ui/icon.tsx index e3ba02ac..bd471839 100644 --- a/apps/mobile/src/components/ui/icon.tsx +++ b/apps/mobile/src/components/ui/icon.tsx @@ -31,6 +31,7 @@ import { PanelRightOpen, PanelLeftClose, PanelLeftOpen, + Pin, Plus, RefreshCw, RotateCcw, @@ -80,6 +81,7 @@ export type AppIconName = | "permissionsAuto" | "permissionsDefault" | "permissionsFull" + | "pin" | "preview" | "previewHide" | "pullRequest" @@ -136,6 +138,7 @@ const iconComponents: Record = { permissionsAuto: Zap, permissionsDefault: Hand, permissionsFull: ShieldCheck, + pin: Pin, preview: PanelRightOpen, previewHide: PanelRightClose, pullRequest: GitPullRequest, diff --git a/apps/mobile/src/state/pinned-thread-store.ts b/apps/mobile/src/state/pinned-thread-store.ts new file mode 100644 index 00000000..10048600 --- /dev/null +++ b/apps/mobile/src/state/pinned-thread-store.ts @@ -0,0 +1,50 @@ +import { observable } from "@legendapp/state"; + +import { persistLocalObservable } from "./persistence"; + +type PinnedThreadState = { + threadIds: string[]; +}; + +export const pinnedThreadStore$ = observable({ + threadIds: [], +}); + +persistLocalObservable(pinnedThreadStore$, "pinned-threads"); + +export function getPinnedThreadIds(): readonly string[] { + return [...pinnedThreadStore$.threadIds.peek()]; +} + +export function pinThread(threadId: string) { + pinnedThreadStore$.threadIds.set((current) => { + if (current.includes(threadId)) { + return current; + } + + return [threadId, ...current]; + }); +} + +export function unpinThread(threadId: string) { + pinnedThreadStore$.threadIds.set((current) => { + if (!current.includes(threadId)) { + return current; + } + + return current.filter((candidate) => candidate !== threadId); + }); +} + +export function togglePinnedThread(threadId: string) { + if (pinnedThreadStore$.threadIds.peek().includes(threadId)) { + unpinThread(threadId); + return; + } + + pinThread(threadId); +} + +export function resetPinnedThreadState() { + pinnedThreadStore$.set({ threadIds: [] }); +} diff --git a/docs/superpowers/plans/2026-08-05-pinned-chats.md b/docs/superpowers/plans/2026-08-05-pinned-chats.md new file mode 100644 index 00000000..40bc15b8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-pinned-chats.md @@ -0,0 +1,809 @@ +# Pinned Chats Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add locally persisted pinned chats to the mobile conversation drawer, with a dedicated `Pinned` section and pin controls in the existing chat actions sheet. + +**Architecture:** Store an ordered device-local list of thread IDs in a dedicated Legend State observable persisted through the existing MMKV adapter. Move drawer row construction into a pure tested module, then connect the persisted state to `ThreadDrawerContent` without changing Relay APIs or shared schemas. + +**Tech Stack:** TypeScript, React Native, Legend State, MMKV, TanStack Query, Vitest, Lucide icons + +--- + +### Task 1: Persist pinned thread IDs locally + +**Files:** + +- Create: `apps/mobile/src/state/pinned-thread-store.ts` +- Create: `packages/codex-relay/test/mobile-pinned-thread-store.test.ts` + +- [ ] **Step 1: Write the failing store tests** + +```ts +import { beforeEach, describe, expect, it } from "vitest"; + +import { + getPinnedThreadIds, + pinThread, + resetPinnedThreadState, + unpinThread, +} from "../../../apps/mobile/src/state/pinned-thread-store.js"; + +describe("mobile pinned thread store", () => { + beforeEach(() => { + resetPinnedThreadState(); + }); + + it("keeps the most recently pinned thread first", () => { + pinThread("thread-a"); + pinThread("thread-b"); + + expect(getPinnedThreadIds()).toEqual(["thread-b", "thread-a"]); + }); + + it("does not duplicate an existing pin", () => { + pinThread("thread-a"); + pinThread("thread-a"); + + expect(getPinnedThreadIds()).toEqual(["thread-a"]); + }); + + it("removes only the selected pin", () => { + pinThread("thread-a"); + pinThread("thread-b"); + + unpinThread("thread-b"); + + expect(getPinnedThreadIds()).toEqual(["thread-a"]); + }); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-pinned-thread-store.test.ts +``` + +Expected: FAIL because `pinned-thread-store.ts` does not exist. + +- [ ] **Step 3: Implement the minimal persisted store** + +```ts +import { observable } from "@legendapp/state"; + +import { persistLocalObservable } from "./persistence"; + +type PinnedThreadState = { + threadIds: string[]; +}; + +export const pinnedThreadStore$ = observable(createDefaultPinnedThreadState()); + +persistLocalObservable(pinnedThreadStore$, "pinned-threads"); + +export function getPinnedThreadIds() { + return pinnedThreadStore$.threadIds.peek(); +} + +export function pinThread(threadId: string) { + pinnedThreadStore$.threadIds.set((current) => + current.includes(threadId) ? current : [threadId, ...current], + ); +} + +export function unpinThread(threadId: string) { + pinnedThreadStore$.threadIds.set((current) => { + const next = current.filter((candidate) => candidate !== threadId); + return next.length === current.length ? current : next; + }); +} + +export function resetPinnedThreadState() { + pinnedThreadStore$.set(createDefaultPinnedThreadState()); +} + +function createDefaultPinnedThreadState(): PinnedThreadState { + return { threadIds: [] }; +} +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-pinned-thread-store.test.ts +``` + +Expected: 3 tests pass with no warnings. + +- [ ] **Step 5: Commit the store** + +```bash +git add apps/mobile/src/state/pinned-thread-store.ts packages/codex-relay/test/mobile-pinned-thread-store.test.ts +git commit -m "feat: persist pinned chats locally" +``` + +### Task 2: Build pinned drawer rows without duplicates + +**Files:** + +- Create: `apps/mobile/src/components/chat/thread-drawer-rows.ts` +- Create: `packages/codex-relay/test/mobile-thread-drawer-rows.test.ts` +- Modify: `apps/mobile/src/components/chat/ThreadDrawerContent.tsx:76-88,267-276,1406-1470` + +- [ ] **Step 1: Write the failing row-builder tests** + +```ts +import { describe, expect, it } from "vitest"; +import type { ThreadSummary } from "../src/api-schema.js"; + +import { buildDrawerRows } from "../../../apps/mobile/src/components/chat/thread-drawer-rows.js"; + +describe("mobile thread drawer rows", () => { + it("shows pinned chats once in pinned order above workspace groups", () => { + const threads = [thread("thread-a"), thread("thread-b"), thread("thread-c")]; + + const rows = buildDrawerRows(threads, {}, undefined, ["thread-b", "thread-a"]); + + expect(rows.map((row) => row.id)).toEqual([ + "pinned", + "thread:thread-b", + "thread:thread-a", + "project:/workspace/alpha", + "thread:thread-c", + ]); + }); + + it("keeps a workspace header when all of its chats are pinned", () => { + const rows = buildDrawerRows([thread("thread-a"), thread("thread-b")], {}, undefined, [ + "thread-b", + "thread-a", + ]); + + expect(rows.at(-1)).toMatchObject({ + id: "project:/workspace/alpha", + kind: "project", + }); + }); + + it("omits unavailable pinned IDs without deleting available pins", () => { + const rows = buildDrawerRows([thread("thread-a")], {}, undefined, ["missing", "thread-a"]); + + expect(rows.map((row) => row.id)).toEqual([ + "pinned", + "thread:thread-a", + "project:/workspace/alpha", + ]); + }); + + it("counts hidden unpinned chats independently from pinned chats", () => { + const threads = Array.from({ length: 7 }, (_, index) => thread(`thread-${index + 1}`)); + + const rows = buildDrawerRows(threads, {}, undefined, ["thread-1"]); + + expect(rows.filter((row) => row.kind === "thread")).toHaveLength(6); + expect(rows.find((row) => row.kind === "more")).toMatchObject({ hiddenCount: 1 }); + }); + + it("uses normal expanded workspace results while searching", () => { + const rows = buildDrawerRows( + [thread("thread-a"), thread("thread-b")], + {}, + undefined, + ["thread-a"], + true, + ); + + expect(rows.map((row) => row.id)).toEqual([ + "project:/workspace/alpha", + "thread:thread-a", + "thread:thread-b", + ]); + }); + + it("keeps an active collapsed chat visible", () => { + const threads = Array.from({ length: 7 }, (_, index) => thread(`thread-${index + 1}`)); + + const rows = buildDrawerRows(threads, {}, "thread-7"); + const visibleThreadIds = rows + .filter((row) => row.kind === "thread") + .map((row) => row.thread.id); + + expect(visibleThreadIds).toEqual(["thread-1", "thread-2", "thread-3", "thread-4", "thread-7"]); + expect(rows.find((row) => row.kind === "more")).toMatchObject({ hiddenCount: 2 }); + }); +}); + +function thread(id: string): ThreadSummary { + return { + id, + title: id, + createdAt: "2026-08-05T00:00:00.000Z", + updatedAt: "2026-08-05T00:00:00.000Z", + state: "completed", + cwd: "/workspace/alpha", + messageCount: 0, + }; +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-thread-drawer-rows.test.ts +``` + +Expected: FAIL because `thread-drawer-rows.ts` does not exist. + +- [ ] **Step 3: Implement the pure row builder** + +```ts +import type { ThreadSummary } from "codex-relay/api-schema"; + +import { workspaceName } from "@/lib/workspace-name"; + +const collapsedProjectThreadCount = 5; + +export type DrawerRow = + | { id: "pinned"; kind: "pinned" } + | { + id: string; + kind: "project"; + projectKey: string; + title: string; + workspacePath?: string; + } + | { id: string; kind: "thread"; projectKey: string; thread: ThreadSummary } + | { id: string; kind: "more"; hiddenCount: number; projectKey: string }; + +export function buildDrawerRows( + threads: ThreadSummary[], + expandedProjects: Record, + activeThreadId: string | undefined, + pinnedThreadIds: string[] = [], + forceExpanded = false, +): DrawerRow[] { + const groups = new Map< + string, + { title: string; threads: ThreadSummary[]; workspacePath?: string } + >(); + const threadsById = new Map(threads.map((thread) => [thread.id, thread])); + const pinnedThreads = forceExpanded + ? [] + : pinnedThreadIds + .map((threadId) => threadsById.get(threadId)) + .filter((thread): thread is ThreadSummary => Boolean(thread)); + const displayedPinnedIds = new Set(pinnedThreads.map((thread) => thread.id)); + + for (const thread of threads) { + const project = projectDetails(thread); + const group = groups.get(project.key); + if (group) { + group.threads.push(thread); + } else { + groups.set(project.key, { + title: project.title, + threads: [thread], + workspacePath: project.workspacePath, + }); + } + } + + const rows: DrawerRow[] = []; + if (pinnedThreads.length > 0) { + rows.push( + { id: "pinned", kind: "pinned" }, + ...pinnedThreads.map((thread) => ({ + id: `thread:${thread.id}`, + kind: "thread" as const, + projectKey: projectDetails(thread).key, + thread, + })), + ); + } + + for (const [projectKey, group] of groups) { + const projectThreads = forceExpanded + ? group.threads + : group.threads.filter((thread) => !displayedPinnedIds.has(thread.id)); + const isExpanded = forceExpanded || (expandedProjects[projectKey] ?? false); + const activeThread = activeThreadId + ? projectThreads.find((thread) => thread.id === activeThreadId) + : undefined; + const collapsedThreads = projectThreads.slice(0, collapsedProjectThreadCount); + const visibleThreads = + isExpanded || !activeThread || collapsedThreads.includes(activeThread) + ? isExpanded + ? projectThreads + : collapsedThreads + : [...collapsedThreads.slice(0, collapsedProjectThreadCount - 1), activeThread]; + const hiddenCount = projectThreads.length - visibleThreads.length; + + rows.push( + { + id: `project:${projectKey}`, + kind: "project", + projectKey, + title: group.title, + workspacePath: group.workspacePath, + }, + ...visibleThreads.map((thread) => ({ + id: `thread:${thread.id}`, + kind: "thread" as const, + projectKey, + thread, + })), + ); + + if (hiddenCount > 0) { + rows.push({ + id: `more:${projectKey}`, + kind: "more", + hiddenCount, + projectKey, + }); + } + } + + return rows; +} + +function projectDetails(thread: ThreadSummary) { + const title = workspaceName(thread.cwd) ?? "codex-relay"; + return { + key: thread.cwd ?? title, + title, + workspacePath: thread.cwd, + }; +} +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-thread-drawer-rows.test.ts +``` + +Expected: 6 tests pass. + +- [ ] **Step 5: Replace the inline row builder with the tested module** + +Add the import and keep the existing `workspaceName` import because it is also used by the workspace picker: + +```ts +import { buildDrawerRows, type DrawerRow } from "./thread-drawer-rows"; +``` + +Delete the local `DrawerRow` union, `collapsedProjectThreadCount`, and `buildDrawerRows` function from `ThreadDrawerContent.tsx`. Update the call so search remains the fifth `forceExpanded` argument until persisted state is connected in Task 3: + +```ts +const rows = useMemo( + () => + buildDrawerRows( + visibleThreads, + expandedProjects, + activeThreadId, + [], + Boolean(normalizedSearchQuery), + ), + [activeThreadId, expandedProjects, normalizedSearchQuery, visibleThreads], +); +``` + +- [ ] **Step 6: Verify the refactor** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-thread-drawer-rows.test.ts +corepack pnpm -r typecheck +``` + +Expected: focused tests and all workspace typechecks pass. + +- [ ] **Step 7: Commit the row builder** + +```bash +git add apps/mobile/src/components/chat/ThreadDrawerContent.tsx apps/mobile/src/components/chat/thread-drawer-rows.ts packages/codex-relay/test/mobile-thread-drawer-rows.test.ts +git commit -m "feat: group pinned chats in the drawer" +``` + +### Task 3: Add pin actions and archive cleanup to the drawer + +**Files:** + +- Modify: `apps/mobile/src/state/pinned-thread-store.ts` +- Modify: `packages/codex-relay/test/mobile-pinned-thread-store.test.ts` +- Modify: `apps/mobile/src/components/ui/icon.tsx:1-51,54-106,110-165` +- Modify: `apps/mobile/src/components/chat/ThreadDrawerContent.tsx:170-325,400-428,495-550,871-1050` + +- [ ] **Step 1: Add a failing toggle test** + +Add `togglePinnedThread` to the test import, then add: + +```ts +it("toggles a thread pin", () => { + togglePinnedThread("thread-a"); + expect(getPinnedThreadIds()).toEqual(["thread-a"]); + + togglePinnedThread("thread-a"); + expect(getPinnedThreadIds()).toEqual([]); +}); +``` + +- [ ] **Step 2: Run the focused store test and verify RED** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-pinned-thread-store.test.ts +``` + +Expected: FAIL because `togglePinnedThread` is not exported. + +- [ ] **Step 3: Implement the tested toggle** + +```ts +export function togglePinnedThread(threadId: string) { + if (getPinnedThreadIds().includes(threadId)) { + unpinThread(threadId); + return; + } + pinThread(threadId); +} +``` + +- [ ] **Step 4: Run the focused store test and verify GREEN** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-pinned-thread-store.test.ts +``` + +Expected: 4 tests pass. + +- [ ] **Step 5: Add the shared pin icon** + +Import `Pin` from `lucide-react-native`, add `"pin"` to `AppIconName`, and register it: + +```ts +pin: Pin, +``` + +- [ ] **Step 6: Subscribe the drawer to pinned state and pass it to the row builder** + +Add: + +```ts +import { pinnedThreadStore$, togglePinnedThread, unpinThread } from "@/state/pinned-thread-store"; +``` + +Read state beside `activeThreadId`: + +```ts +const pinnedThreadIds = useSelector(() => pinnedThreadStore$.threadIds.get()); +``` + +Build rows with the persisted IDs: + +```ts +const rows = useMemo( + () => + buildDrawerRows( + visibleThreads, + expandedProjects, + activeThreadId, + pinnedThreadIds, + Boolean(normalizedSearchQuery), + ), + [activeThreadId, expandedProjects, normalizedSearchQuery, pinnedThreadIds, visibleThreads], +); +``` + +Update the archive mutation so only a successful archive removes the pin: + +```ts +onSuccess: async (_response, threadId) => { + unpinThread(threadId); + await queryClient.invalidateQueries({ queryKey: serverStateKeys.threads() }); +}, +``` + +- [ ] **Step 7: Add pin and unpin behavior to the action sheet** + +After `closeThreadActions`, add: + +```ts +const handleTogglePinnedThread = useCallback( + (thread: ThreadSummary) => { + togglePinnedThread(thread.id); + hapticSelection(); + if (threadWithActions?.id === thread.id) { + closeThreadActions(); + } + }, + [closeThreadActions, threadWithActions?.id], +); +const threadWithActionsIsPinned = Boolean( + threadWithActions && pinnedThreadIds.includes(threadWithActions.id), +); +``` + +Replace the non-rename branch of the action sheet with: + +```tsx +threadWithActions ? ( + <> + handleTogglePinnedThread(threadWithActions)} + title={threadWithActionsIsPinned ? "Unpin chat" : "Pin chat"} + /> + {canMutateAppServerThreads ? ( + + ) : null} + +) : null; +``` + +- [ ] **Step 8: Render the pinned header and make chat actions available for every thread** + +Add a pinned header branch before the project branch: + +```tsx +if (item.kind === "pinned") { + return ( + + + + + Pinned + + ); +} +``` + +Rename the row callback prop from `onRenameThread` to `onOpenThreadActions`, add `pinned` and `onTogglePinnedThread`, then use: + +```tsx +accessibilityActions={[ + { label: pinned ? "Unpin chat" : "Pin chat", name: "toggle-pin" }, + ...(canRenameThread ? [{ label: "Rename chat", name: "rename" }] : []), +]} +accessibilityHint="Long press for chat actions" +onAccessibilityAction={(event) => { + if (event.nativeEvent.actionName === "toggle-pin") { + onTogglePinnedThread(item.thread); + } else if (event.nativeEvent.actionName === "rename") { + onOpenThreadActions(item.thread); + } +}} +onLongPress={() => onOpenThreadActions(item.thread)} +``` + +Pass the new props from `renderDrawerRow`: + +```tsx +onOpenThreadActions={openThreadActions} +onTogglePinnedThread={handleTogglePinnedThread} +pinned={item.kind === "thread" && pinnedThreadIds.includes(item.thread.id)} +``` + +Include the new callbacks and `pinned` value in `areDrawerRowItemsEqual`, and include `handleTogglePinnedThread` plus `pinnedThreadIds` in the `renderDrawerRow` dependency list. + +- [ ] **Step 9: Format and verify the integrated feature** + +Run: + +```bash +corepack pnpm exec oxfmt apps/mobile/src/state/pinned-thread-store.ts apps/mobile/src/components/ui/icon.tsx apps/mobile/src/components/chat/thread-drawer-rows.ts apps/mobile/src/components/chat/ThreadDrawerContent.tsx packages/codex-relay/test/mobile-pinned-thread-store.test.ts packages/codex-relay/test/mobile-thread-drawer-rows.test.ts --write +corepack pnpm --filter codex-relay exec vitest run test/mobile-pinned-thread-store.test.ts test/mobile-thread-drawer-rows.test.ts +corepack pnpm -r typecheck +``` + +Expected: 10 focused tests pass and all workspace typechecks pass. + +- [ ] **Step 10: Commit the UI integration** + +```bash +git add apps/mobile/src/state/pinned-thread-store.ts apps/mobile/src/components/ui/icon.tsx apps/mobile/src/components/chat/thread-drawer-rows.ts apps/mobile/src/components/chat/ThreadDrawerContent.tsx packages/codex-relay/test/mobile-pinned-thread-store.test.ts packages/codex-relay/test/mobile-thread-drawer-rows.test.ts +git commit -m "feat: add pinned chat actions" +``` + +### Task 4: Complete repository and contributor validation + +**Files:** + +- Verify only, no planned source changes + +- [ ] **Step 1: Build the package required by mobile-importing server tests** + +```bash +corepack pnpm --filter codex-relay build +``` + +Expected: `dist/api-schema.js` and the remaining package artifacts build successfully. + +- [ ] **Step 2: Run the complete server test suite** + +```bash +corepack pnpm --filter codex-relay test +``` + +Expected: all non-live tests pass, with only the repository's expected live-test skips. + +- [ ] **Step 3: Run CI-equivalent static checks** + +```bash +corepack pnpm exec oxlint apps packages --import-plugin --react-plugin --jsx-a11y-plugin --vitest-plugin +corepack pnpm format:check +corepack pnpm -r typecheck +node --test scripts/mobile-release-version.test.mjs +corepack pnpm --filter @codex-relay/mobile exec vitest run src/lib/mobile-release-version.test.ts +``` + +Expected: lint, formatting, all workspace typechecks, and release-policy tests pass. + +- [ ] **Step 4: Attempt manual mobile verification** + +Verify on an available simulator or device: + +1. Long-press an unpinned chat and choose `Pin chat`. +2. Confirm it moves to `Pinned` and disappears from its workspace group. +3. Restart the app and confirm the pin remains. +4. Unpin the chat and confirm it returns to its workspace group. +5. Pin a chat, archive it successfully, and confirm the pin disappears. +6. Search for a pinned chat and confirm it appears once in normal search results. + +If no simulator or paired Relay is available, record the missing manual checks in the final handoff and future PR validation section. + +- [ ] **Step 5: Review the final diff and history** + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline origin/main..HEAD +``` + +Expected: no whitespace errors, no uncommitted source changes, and Conventional Commit messages only. The eventual PR should be in English, link issue #58, list validation commands, and include a screenshot or recording when manual UI verification is available. Include a patch changeset for `@codex-relay/mobile`; no `codex-relay` package changeset is required. + +### Task 5: Show workspace metadata on pinned rows + +**Files:** + +- Modify: `apps/mobile/src/components/chat/thread-drawer-rows.ts` +- Modify: `apps/mobile/src/components/chat/ThreadDrawerContent.tsx` +- Test: `packages/codex-relay/test/mobile-thread-drawer-rows.test.ts` + +- [ ] **Step 1: Write a failing row-builder test** + +Add a test proving pinned rows receive the leaf workspace label while normal search rows do not: + +```ts +it("adds workspace labels only to pinned section rows", () => { + const thread = threadSummary("thread-a", "/work/very-long-project-folder-name"); + + const pinnedRows = buildDrawerRows([thread], {}, undefined, [thread.id]); + const searchRows = buildDrawerRows([thread], {}, undefined, [thread.id], true); + + expect(pinnedRows.find((row) => row.kind === "thread")).toEqual({ + ...threadRow(thread), + workspaceTitle: "very-long-project-folder-name", + }); + expect(searchRows.find((row) => row.kind === "thread")).toEqual(threadRow(thread)); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-thread-drawer-rows.test.ts +``` + +Expected: FAIL because pinned thread rows do not yet contain `workspaceTitle`. + +- [ ] **Step 3: Add pinned workspace metadata to the row model** + +Extend the thread row variant: + +```ts +| { + id: string; + kind: "thread"; + projectKey: string; + thread: ThreadSummary; + workspaceTitle?: string; + } +``` + +Create pinned rows with the workspace label while leaving normal rows unchanged: + +```ts +rows.push( + ...pinnedThreads.map((thread) => + threadRow(thread, projectKeyForThread(thread), workspaceName(thread.cwd) ?? "codex-relay"), + ), +); +``` + +Update `threadRow` to accept and conditionally include the optional label: + +```ts +function threadRow(thread: ThreadSummary, projectKey: string, workspaceTitle?: string): DrawerRow { + return { + id: `thread:${thread.id}`, + kind: "thread", + projectKey, + thread, + ...(workspaceTitle ? { workspaceTitle } : {}), + }; +} +``` + +- [ ] **Step 4: Render compact pinned metadata** + +Calculate the relative time once and render pinned metadata with middle ellipsis: + +```tsx +const relativeTime = formatRelativeTime(item.thread.lastActivityAt ?? item.thread.updatedAt); + +const metadata = ( + + {item.workspaceTitle ? `${item.workspaceTitle} · ${relativeTime}` : relativeTime} + +); +``` + +Include `workspaceTitle` in the thread-row memo comparison: + +```ts +previous.item.workspaceTitle === next.item.workspaceTitle && +``` + +- [ ] **Step 5: Verify GREEN and static checks** + +Run: + +```bash +corepack pnpm --filter codex-relay exec vitest run test/mobile-thread-drawer-rows.test.ts +corepack pnpm exec oxfmt apps/mobile/src/components/chat/thread-drawer-rows.ts apps/mobile/src/components/chat/ThreadDrawerContent.tsx packages/codex-relay/test/mobile-thread-drawer-rows.test.ts --write +corepack pnpm -r typecheck +``` + +Expected: the focused tests and all workspace typechecks pass, and formatting produces no remaining diff. + +- [ ] **Step 6: Verify on Android and commit** + +Open the drawer in the paired Android emulator and confirm: + +1. A pinned row shows ` · ` below its title. +2. A long workspace label stays on one line with middle ellipsis. +3. A normal workspace row still shows only relative time. + +Save a fresh screenshot, then commit: + +```bash +git add apps/mobile/src/components/chat/thread-drawer-rows.ts apps/mobile/src/components/chat/ThreadDrawerContent.tsx packages/codex-relay/test/mobile-thread-drawer-rows.test.ts +git commit -m "feat: label pinned chats by workspace" +``` diff --git a/docs/superpowers/specs/2026-08-05-pinned-chats-design.md b/docs/superpowers/specs/2026-08-05-pinned-chats-design.md new file mode 100644 index 00000000..fb0de862 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-pinned-chats-design.md @@ -0,0 +1,56 @@ +# Pinned Chats Design + +## Goal + +Let mobile users keep important chats immediately accessible in a dedicated `Pinned` section at the top of the conversation drawer. + +## Scope + +- Pin and unpin a chat from the existing long-press `Chat actions` sheet. +- Show currently available pinned chats once in a `Pinned` section above workspace groups. +- Persist pin state locally on the phone across app and Relay restarts. +- Remove a pin after its chat is archived successfully. +- Keep search behavior focused on matching chats instead of showing a separate pinned section. + +Server APIs, shared thread schemas, cross-device synchronization, drag reordering, collapsing the `Pinned` section, and desktop changes are out of scope. + +## User Experience + +Long-pressing any chat opens `Chat actions`, even when server-side rename is unavailable. The sheet shows `Pin chat` for an unpinned chat or `Unpin chat` for a pinned chat, followed by `Rename chat` when rename is supported. + +Pinned chats appear in most-recently-pinned order under a `Pinned` header at the top of the drawer. Each pinned row keeps the chat title primary and shows ` · ` as single-line secondary text so chats with similar titles remain distinguishable. The workspace label uses the existing leaf-folder helper and falls back to `codex-relay` when the thread has no working directory. Long labels use middle ellipsis so the start of the folder and the relative time remain visible without increasing row height. Normal workspace rows keep their existing time-only secondary text. + +Pinned chats are removed from their normal workspace groups to avoid duplicates. Workspace headers remain visible even when every chat in that workspace is pinned. During search, all matching chats appear in their normal workspace groups, use the normal time-only metadata, and the separate `Pinned` section is hidden. + +Pinning and unpinning update the drawer immediately and close the action sheet. Archiving removes the pin only after the archive request succeeds. + +## Architecture + +Add a small client-state module backed by the existing `persistLocalObservable` MMKV adapter. It stores one ordered array of thread IDs for the device. Thread IDs are treated as globally unique, so pins continue to work if the same Relay is reached through a different URL. + +Extract drawer row construction from `ThreadDrawerContent.tsx` into a pure helper module. The helper receives threads, expanded projects, the active thread, search state, and pinned IDs. It returns a discriminated row list containing the pinned header, thread rows, workspace headers, and `Show more` rows. + +`ThreadDrawerContent` subscribes to the persisted pin store, passes pin state into the row builder, exposes pin actions in the existing bottom sheet, and unpins after a successful archive. The shared icon map gains a `pin` icon for the action and section header. + +## Data Flow + +1. The drawer reads thread data from the existing TanStack Query source. +2. It reads ordered pinned IDs from the local observable store. +3. The pure row builder resolves available pinned IDs to current thread objects, emits the pinned section, then emits workspace sections without those threads. +4. Pin and unpin actions update the observable store, which persists the new array through MMKV and rerenders the drawer. +5. Missing thread IDs are retained in storage but omitted from the UI, allowing temporarily unavailable chats to reappear later. + +## Error Handling + +The existing persistence adapter falls back to default state if stored JSON cannot be read. Pin operations are local and synchronous, so they do not depend on network availability. A failed archive keeps the pin because unpinning occurs only after the archive mutation succeeds. + +## Testing + +- Store tests cover pin order, idempotent pinning, unpinning, reset isolation, and restoration after a module reload. +- Pure row-builder tests cover the pinned section, pinned workspace labels, no duplicates, workspace-header preservation, unavailable pinned IDs, collapsed groups, and search behavior. +- Run the repository test suite, typecheck, lint, and formatting checks. +- Manually verify long-press actions, immediate drawer updates, archive behavior, and persistence after an app restart. Record any unavailable simulator or device validation in the PR. + +## Contribution Requirements + +Use English for maintainer-facing artifacts, Conventional Commits, and link the eventual PR to issue #58. Add a patch changeset for `@codex-relay/mobile`; no `codex-relay` package changeset is required. diff --git a/packages/codex-relay/test/mobile-pinned-thread-store.test.ts b/packages/codex-relay/test/mobile-pinned-thread-store.test.ts new file mode 100644 index 00000000..6628d96b --- /dev/null +++ b/packages/codex-relay/test/mobile-pinned-thread-store.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getPinnedThreadIds, + pinThread, + resetPinnedThreadState, + togglePinnedThread, + unpinThread, +} from "../../../apps/mobile/src/state/pinned-thread-store.js"; + +describe("mobile pinned thread store", () => { + beforeEach(() => { + resetPinnedThreadState(); + }); + + it("adds pinned threads to the front in pin order", () => { + pinThread("thread-a"); + pinThread("thread-b"); + + expect(getPinnedThreadIds()).toEqual(["thread-b", "thread-a"]); + }); + + it("does not duplicate an already pinned thread", () => { + pinThread("thread-a"); + pinThread("thread-a"); + + expect(getPinnedThreadIds()).toEqual(["thread-a"]); + }); + + it("does not expose a mutable pinned thread array", () => { + pinThread("thread-a"); + const threadIds = getPinnedThreadIds(); + (threadIds as string[]).push("thread-b"); + + expect(getPinnedThreadIds()).toEqual(["thread-a"]); + }); + + it("removes only the selected pinned thread", () => { + pinThread("thread-a"); + pinThread("thread-b"); + unpinThread("thread-a"); + + expect(getPinnedThreadIds()).toEqual(["thread-b"]); + }); + + it("toggles a thread between pinned and unpinned", () => { + togglePinnedThread("thread-a"); + expect(getPinnedThreadIds()).toEqual(["thread-a"]); + + togglePinnedThread("thread-a"); + expect(getPinnedThreadIds()).toEqual([]); + }); + + it("restores pinned threads after the store module reloads", async () => { + pinThread("thread-a"); + vi.resetModules(); + + const reloadedStore = await import("../../../apps/mobile/src/state/pinned-thread-store.js"); + + expect(reloadedStore.getPinnedThreadIds()).toEqual(["thread-a"]); + reloadedStore.resetPinnedThreadState(); + }); +}); diff --git a/packages/codex-relay/test/mobile-thread-drawer-rows.test.ts b/packages/codex-relay/test/mobile-thread-drawer-rows.test.ts new file mode 100644 index 00000000..81202a42 --- /dev/null +++ b/packages/codex-relay/test/mobile-thread-drawer-rows.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import type { ThreadSummary } from "../src/api-schema.js"; + +import { buildDrawerRows } from "../../../apps/mobile/src/components/chat/thread-drawer-rows.js"; + +describe("mobile thread drawer rows", () => { + it("renders pinned threads once in pinned order above their project", () => { + const threads = [ + threadSummary("thread-a", "/work/project"), + threadSummary("thread-b", "/work/project"), + threadSummary("thread-c", "/work/project"), + ]; + + expect(buildDrawerRows(threads, {}, undefined, ["thread-b", "thread-a"])).toEqual([ + { id: "pinned", kind: "pinned" }, + pinnedThreadRow(threads[1], "project"), + pinnedThreadRow(threads[0], "project"), + projectRow("/work/project"), + threadRow(threads[2]), + ]); + }); + + it("keeps a project header when every chat in the project is pinned", () => { + const threads = [ + threadSummary("thread-a", "/work/project"), + threadSummary("thread-b", "/work/project"), + ]; + + expect(buildDrawerRows(threads, {}, undefined, ["thread-a", "thread-b"])).toEqual([ + { id: "pinned", kind: "pinned" }, + pinnedThreadRow(threads[0], "project"), + pinnedThreadRow(threads[1], "project"), + projectRow("/work/project"), + ]); + }); + + it("omits missing pinned ids while rendering available pinned threads", () => { + const threads = [ + threadSummary("thread-a", "/work/project"), + threadSummary("thread-b", "/work/project"), + ]; + + expect(buildDrawerRows(threads, {}, undefined, ["thread-missing", "thread-b"])).toEqual([ + { id: "pinned", kind: "pinned" }, + pinnedThreadRow(threads[1], "project"), + projectRow("/work/project"), + threadRow(threads[0]), + ]); + }); + + it("keeps the collapsed project limit for unpinned chats", () => { + const threads = Array.from({ length: 7 }, (_, index) => + threadSummary(`thread-${index + 1}`, "/work/project"), + ); + + const rows = buildDrawerRows(threads, {}, undefined, ["thread-1"]); + + expect(rows.filter((row) => row.kind === "thread").map((row) => row.thread.id)).toEqual([ + "thread-1", + "thread-2", + "thread-3", + "thread-4", + "thread-5", + "thread-6", + ]); + expect(rows.find((row) => row.kind === "more")).toEqual({ + id: "more:/work/project", + kind: "more", + hiddenCount: 1, + projectKey: "/work/project", + }); + }); + + it("renders search results once in normal workspace order without a pinned header", () => { + const threads = [ + threadSummary("thread-a", "/work/alpha"), + threadSummary("thread-b", "/work/alpha"), + threadSummary("thread-c", "/work/beta"), + ]; + + expect(buildDrawerRows(threads, {}, undefined, ["thread-b"], true)).toEqual([ + projectRow("/work/alpha"), + threadRow(threads[0]), + threadRow(threads[1]), + projectRow("/work/beta"), + threadRow(threads[2]), + ]); + }); + + it("adds workspace labels only to pinned section rows", () => { + const thread = threadSummary("thread-a", "/work/very-long-project-folder-name"); + + const pinnedRows = buildDrawerRows([thread], {}, undefined, [thread.id]); + const searchRows = buildDrawerRows([thread], {}, undefined, [thread.id], true); + + expect(pinnedRows.find((row) => row.kind === "thread")).toEqual( + pinnedThreadRow(thread, "very-long-project-folder-name"), + ); + expect(searchRows.find((row) => row.kind === "thread")).toEqual(threadRow(thread)); + }); + + it("keeps an active seventh chat visible in a collapsed project", () => { + const threads = Array.from({ length: 7 }, (_, index) => + threadSummary(`thread-${index + 1}`, "/work/project"), + ); + + const rows = buildDrawerRows(threads, {}, "thread-7"); + + expect(rows.filter((row) => row.kind === "thread").map((row) => row.thread.id)).toEqual([ + "thread-1", + "thread-2", + "thread-3", + "thread-4", + "thread-7", + ]); + expect(rows.find((row) => row.kind === "more")).toEqual({ + id: "more:/work/project", + kind: "more", + hiddenCount: 2, + projectKey: "/work/project", + }); + }); + + it("keeps only the first duplicate thread id in normal mode", () => { + const firstThread = threadSummary("thread-duplicate", "/work/first"); + const duplicateThread = threadSummary("thread-duplicate", "/work/second"); + + expect(buildDrawerRows([firstThread, duplicateThread], {}, undefined)).toEqual([ + projectRow("/work/first"), + threadRow(firstThread), + ]); + }); + + it("keeps only the first duplicate thread id in force-expanded mode", () => { + const firstThread = threadSummary("thread-duplicate", "/work/first"); + const duplicateThread = threadSummary("thread-duplicate", "/work/second"); + + expect(buildDrawerRows([firstThread, duplicateThread], {}, undefined, [], true)).toEqual([ + projectRow("/work/first"), + threadRow(firstThread), + ]); + }); + + it("renders repeated pinned ids only once", () => { + const thread = threadSummary("thread-a", "/work/project"); + + expect(buildDrawerRows([thread], {}, undefined, ["thread-a", "thread-a"])).toEqual([ + { id: "pinned", kind: "pinned" }, + pinnedThreadRow(thread, "project"), + projectRow("/work/project"), + ]); + }); + + it("shows every chat in an expanded project without a more row", () => { + const threads = Array.from({ length: 7 }, (_, index) => + threadSummary(`thread-${index + 1}`, "/work/project"), + ); + + const rows = buildDrawerRows(threads, { "/work/project": true }, undefined); + + expect(rows.filter((row) => row.kind === "thread").map((row) => row.thread.id)).toEqual( + threads.map((thread) => thread.id), + ); + expect(rows.find((row) => row.kind === "more")).toBeUndefined(); + }); + + it("uses the codex-relay fallback project for a pinned thread without a cwd", () => { + const thread = threadSummary("thread-a"); + + expect(buildDrawerRows([thread], {}, undefined, ["thread-a"])).toEqual([ + { id: "pinned", kind: "pinned" }, + pinnedThreadRow(thread, "codex-relay"), + { + id: "project:codex-relay", + kind: "project", + projectKey: "codex-relay", + title: "codex-relay", + workspacePath: undefined, + }, + ]); + }); +}); + +function threadSummary(id: string, cwd?: string): ThreadSummary { + const now = "2026-08-05T00:00:00.000Z"; + return { + id, + title: id, + createdAt: now, + updatedAt: now, + state: "completed", + cwd, + messageCount: 0, + }; +} + +function projectRow(projectKey: string) { + return { + id: `project:${projectKey}`, + kind: "project" as const, + projectKey, + title: projectKey.split("/").at(-1), + workspacePath: projectKey, + }; +} + +function threadRow(thread: ThreadSummary) { + return { + id: `thread:${thread.id}`, + kind: "thread" as const, + projectKey: thread.cwd ?? "codex-relay", + thread, + }; +} + +function pinnedThreadRow(thread: ThreadSummary, workspaceTitle: string) { + return { + ...threadRow(thread), + workspaceTitle, + }; +}