From a43a8b03cfc798f291d46c0270e018e7a40a7805 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:30:01 +0200 Subject: [PATCH] refactor(assistant): one source of truth for the conversation list (#1214) * refactor(assistant): one source of truth for the conversation list PR5 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7): unified history. The two surfaces that list conversations had drifted apart in BEHAVIOUR, not just chrome. The in-sheet list rolled a failed rename back and said so; the /chat sidebar fired pin, archive and rename blind, with no res.ok check, no rollback and no message. A failed archive there removed a conversation from the list while it still existed on the server, and a failed rename displayed a title the server never saved, both until the next reload, with an unhandled promise rejection on a network error. State, search, grouping and all three mutations now live in one hook that both surfaces consume, so they cannot diverge again: every write is optimistic, reverts to the value captured before the write on failure, and reports it. The sheet gains pin and archive, which it never had. Archive resolves whether the row is really gone, so the sidebar only navigates away from a conversation that was actually archived. Chrome deliberately stays per-surface: a 320px sidebar that collapses to a rail and a sheet panel are different shapes, and merging the markup belongs with the shell work in PR6, where both containers change anyway. Verified: 9554 unit tests pass (5 new pinning the rollback semantics, including reverting to the original pin value rather than toggling and restoring a null title), lint and tsc clean on the touched files, guards pass. Co-Authored-By: Claude Opus 5 * fix(assistant): test the real mutation code, and make rollback mutation-aware Review follow-ups on the unified conversation list. Both findings were right. The tests duplicated the state transforms and called fetch directly, so they never executed the hook: deleting the rollback entirely would have left them green. That is test theater. The transforms and the write coordinator now live in conversation-mutations.ts, React-free, and the tests exercise those. Checked by deleting the rollback and confirming three tests fail. Rollback was not mutation-aware. A failed archive restored a render-time snapshot of the whole list, discarding any pin, rename or archive made while the request was in flight; and a failing earlier write could roll back over a newer value for the same row (a double-click on pin). Writes now claim a per-row revision and only undo while they are still the latest for that row, and a failed archive re-inserts the single row into the list AS IT STANDS, at its server-sort position, rather than replacing the list. Also drops a ref read during render that the React lint rules reject. Verified: 9560 unit tests pass (11 covering the real coordinator, including the overlapping-write case and the concurrent-edit-survives-archive-failure case), lint clean, tsc clean, guards pass. Co-Authored-By: Claude Opus 5 * test(assistant): unstub globals so the fetch stub cannot outlive the file vi.restoreAllMocks does not undo vi.stubGlobal, and the config sets no unstubGlobals, so the stubbed fetch survived past the suite that set it. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- components/agent/AgentSessionList.tsx | 134 +++++----- components/agent/ChatSidebar.tsx | 91 ++----- .../conversation-list-mutations.test.ts | 246 ++++++++++++++++++ components/agent/conversation-mutations.ts | 129 +++++++++ components/agent/use-conversation-list.ts | 171 ++++++++++++ 5 files changed, 627 insertions(+), 144 deletions(-) create mode 100644 components/agent/__tests__/conversation-list-mutations.test.ts create mode 100644 components/agent/conversation-mutations.ts create mode 100644 components/agent/use-conversation-list.ts diff --git a/components/agent/AgentSessionList.tsx b/components/agent/AgentSessionList.tsx index 501822f4..588f0a7e 100644 --- a/components/agent/AgentSessionList.tsx +++ b/components/agent/AgentSessionList.tsx @@ -1,16 +1,15 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' -import { Search, X, Loader2, MessageSquare, Pencil } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Search, X, Loader2, MessageSquare, Pencil, Pin, PinOff, Archive } from 'lucide-react' import { cn } from '@/lib/utils' -import { useToast } from '@/components/ui/use-toast' import { type ConversationRow, BUCKET_LABELS, relativeTime, intentLabel, - groupConversations, } from './conversation-display' +import { useConversationList } from './use-conversation-list' interface Props { // Highlight the row for the conversation currently open in the sheet. @@ -26,15 +25,26 @@ interface Props { // inline in the sheet and the user keeps chatting without leaving the page. // Rows are renameable inline (PATCH /api/agent/conversations/[id]). export default function AgentSessionList({ activeConversationId, onSelect }: Props) { - const [conversations, setConversations] = useState([]) + // Same hook the /chat sidebar uses, so pin/archive/rename behave identically + // on both surfaces (optimistic, rolled back on failure, with a message). + // Pin and archive are new here: the sheet could only rename before. + const { + conversations, + setConversations, + query, + setQuery, + grouped, + togglePin, + archive, + editingId, + editValue, + setEditValue, + startEdit, + cancelEdit, + commitEdit, + } = useConversationList([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [query, setQuery] = useState('') - const [editingId, setEditingId] = useState(null) - const [editValue, setEditValue] = useState('') - // Set by Esc so the blur that fires when the input unmounts doesn't save. - const cancelRef = useRef(false) - const { toast } = useToast() useEffect(() => { let cancelled = false @@ -55,62 +65,11 @@ export default function AgentSessionList({ activeConversationId, onSelect }: Pro return () => { cancelled = true } + // setConversations is the hook's stable useState setter; listing it would + // not change when this runs, and this must fire exactly once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - if (!q) return conversations - return conversations.filter( - (c) => - (c.title ?? '').toLowerCase().includes(q) || - (c.last_message_preview ?? '').toLowerCase().includes(q) || - (c.context_ref ?? '').toLowerCase().includes(q) || - c.intent_id.toLowerCase().includes(q), - ) - }, [conversations, query]) - - const grouped = useMemo(() => groupConversations(filtered), [filtered]) - - function startEdit(c: ConversationRow) { - setEditingId(c.id) - setEditValue(c.title ?? '') - cancelRef.current = false - } - function cancelEdit() { - cancelRef.current = true - setEditingId(null) - } - async function commitEdit(id: string) { - if (cancelRef.current) { - cancelRef.current = false - return - } - setEditingId(null) - const title = editValue.trim() - const current = conversations.find((c) => c.id === id) - if (!title || title === current?.title) return - // Capture the pre-rename title so we can roll back if the PATCH fails. - const previousTitle = current?.title ?? null - setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c))) - try { - const res = await fetch(`/api/agent/conversations/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), - }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - } catch { - // Revert the optimistic rename so the list stays in sync with the server. - setConversations((prev) => - prev.map((c) => (c.id === id ? { ...c, title: previousTitle } : c)), - ) - toast({ - variant: 'destructive', - title: 'Kunde inte byta namn på konversationen.', - }) - } - } - return (
@@ -207,15 +166,42 @@ export default function AgentSessionList({ activeConversationId, onSelect }: Pro

- +
+ + + +
)} diff --git a/components/agent/ChatSidebar.tsx b/components/agent/ChatSidebar.tsx index 5309b13e..bdf7c58c 100644 --- a/components/agent/ChatSidebar.tsx +++ b/components/agent/ChatSidebar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useRef, useState, useTransition } from 'react' +import { useEffect, useState, useTransition } from 'react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { Pin, PinOff, Archive, Pencil, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react' @@ -12,8 +12,8 @@ import { BUCKET_LABELS, relativeTime, intentLabel, - groupConversations, } from './conversation-display' +import { useConversationList } from './use-conversation-list' interface Props { initialConversations: ConversationRow[] @@ -24,8 +24,23 @@ export default function ChatSidebar({ initialConversations }: Props) { const pathname = usePathname() const { openAgentSheet, identity } = useAgentSheet() const agentName = identity.displayName?.trim() || null - const [conversations, setConversations] = useState(initialConversations) - const [query, setQuery] = useState('') + // State + all three mutations live in the shared hook so this surface and the + // in-sheet list cannot drift apart again. Pin/archive/rename here used to be + // fire-and-forget with no rollback. + const { + conversations, + query, + setQuery, + grouped, + togglePin, + archive: archiveConversation, + editingId, + editValue, + setEditValue, + startEdit, + cancelEdit, + commitEdit, + } = useConversationList(initialConversations) const [, startTransition] = useTransition() // Collapsed by default; persisted across reloads so power users keep // their preference. Hidden behind a thin rail when collapsed so the @@ -46,74 +61,10 @@ export default function ChatSidebar({ initialConversations }: Props) { const activeId = pathname?.startsWith('/chat/') ? pathname.split('/')[2] : null const isConversationOpen = !!activeId - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - if (!q) return conversations - return conversations.filter((c) => { - return ( - (c.title ?? '').toLowerCase().includes(q) || - (c.last_message_preview ?? '').toLowerCase().includes(q) || - (c.context_ref ?? '').toLowerCase().includes(q) || - c.intent_id.toLowerCase().includes(q) - ) - }) - }, [conversations, query]) - - // Group filtered into ordered buckets, preserving the sort order already - // applied server-side (pinned first, then last_message_at desc). - const grouped = useMemo(() => groupConversations(filtered), [filtered]) - - async function togglePin(id: string, current: boolean) { - setConversations((prev) => - prev.map((c) => (c.id === id ? { ...c, pinned: !current } : c)), - ) - await fetch(`/api/agent/conversations/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pinned: !current }), - }) - } async function archive(id: string) { - setConversations((prev) => prev.filter((c) => c.id !== id)) - await fetch(`/api/agent/conversations/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ archived: true }), - }) - if (activeId === id) startTransition(() => router.push('/chat')) - } - - // Inline rename of a conversation's title (PATCH /api/agent/conversations/[id]). - const [editingId, setEditingId] = useState(null) - const [editValue, setEditValue] = useState('') - // Set by Esc so the blur that fires when the input unmounts doesn't save. - const cancelRef = useRef(false) - - function startEdit(c: ConversationRow) { - setEditingId(c.id) - setEditValue(c.title ?? '') - cancelRef.current = false - } - function cancelEdit() { - cancelRef.current = true - setEditingId(null) - } - async function commitEdit(id: string) { - if (cancelRef.current) { - cancelRef.current = false - return - } - setEditingId(null) - const title = editValue.trim() - const current = conversations.find((c) => c.id === id) - if (!title || title === current?.title) return - setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c))) - await fetch(`/api/agent/conversations/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }), - }) + const removed = await archiveConversation(id) + if (removed && activeId === id) startTransition(() => router.push('/chat')) } // Collapsed rail (desktop only). Mobile keeps the existing behavior where diff --git a/components/agent/__tests__/conversation-list-mutations.test.ts b/components/agent/__tests__/conversation-list-mutations.test.ts new file mode 100644 index 00000000..6903f0d7 --- /dev/null +++ b/components/agent/__tests__/conversation-list-mutations.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { ConversationRow } from '../conversation-display' +import { + createRevisionGuard, + patchConversation, + removeRow, + restoreRow, + runOptimisticPatch, + setPinned, + setTitle, +} from '../conversation-mutations' + +/** + * These exercise the REAL transforms and the real write coordinator, not copies + * of them: deleting the rollback or the response check makes this suite fail. + * + * What is being protected: the /chat sidebar used to fire pin, archive and + * rename blind, so a failed archive removed a row that still existed on the + * server and a failed rename showed a title the server never saved, both until + * the next reload. + */ + +const row = (over: Partial = {}): ConversationRow => ({ + id: 'c1', + intent_id: 'general.help', + context_ref: null, + title: 'Juli mot juni', + pinned: false, + archived: false, + last_message_at: '2026-07-26T10:00:00Z', + last_message_preview: 'Juli gick 12 procent bättre', + created_at: '2026-07-26T09:00:00Z', + ...over, +}) + +/** Minimal stand-in for React's setState updater contract. */ +function fakeState(initial: ConversationRow[]) { + let current = initial + return { + set: (updater: (prev: ConversationRow[]) => ConversationRow[]) => { + current = updater(current) + }, + get list() { + return current + }, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.restoreAllMocks() + // restoreAllMocks does not undo vi.stubGlobal, so the stubbed fetch would + // otherwise outlive this file. + vi.unstubAllGlobals() +}) + +describe('restoreRow', () => { + it('puts an archived row back into the CURRENT list, keeping later changes', () => { + const a = row({ id: 'a', last_message_at: '2026-07-26T12:00:00Z' }) + const b = row({ id: 'b', last_message_at: '2026-07-26T11:00:00Z' }) + // While the archive of `a` was in flight, the user renamed `b`. + const currentAfterOtherEdit = [{ ...b, title: 'Nytt namn' }] + + const restored = restoreRow(currentAfterOtherEdit, a) + + expect(restored.map((c) => c.id)).toEqual(['a', 'b']) + // The concurrent rename must survive: restoring a stale snapshot of the + // whole list would have thrown it away. + expect(restored.find((c) => c.id === 'b')!.title).toBe('Nytt namn') + }) + + it('re-inserts by the server ordering: pinned first, then most recent', () => { + const pinned = row({ id: 'p', pinned: true }) + const newer = row({ id: 'n', last_message_at: '2026-07-26T12:00:00Z' }) + const older = row({ id: 'o', last_message_at: '2026-07-20T12:00:00Z' }) + + expect(restoreRow([newer, older], pinned).map((c) => c.id)).toEqual(['p', 'n', 'o']) + expect( + restoreRow([pinned, older], row({ id: 'mid', last_message_at: '2026-07-25T12:00:00Z' })).map( + (c) => c.id, + ), + ).toEqual(['p', 'mid', 'o']) + }) + + it('is a no-op when the row is already present', () => { + const list = [row({ id: 'a' })] + expect(restoreRow(list, row({ id: 'a' }))).toBe(list) + }) +}) + +describe('runOptimisticPatch', () => { + it('applies immediately and keeps the change when the server accepts it', async () => { + const state = fakeState([row({ id: 'c1' })]) + const ok = await runOptimisticPatch({ + id: 'c1', + body: { pinned: true }, + apply: (l) => setPinned(l, 'c1', true), + revert: (l) => setPinned(l, 'c1', false), + setList: state.set, + guard: createRevisionGuard(), + onError: () => {}, + patch: async () => true, + }) + + expect(ok).toBe(true) + expect(state.list[0]!.pinned).toBe(true) + }) + + it('reverts and reports when the server refuses', async () => { + const state = fakeState([row({ id: 'c1', title: 'Original' })]) + const onError = vi.fn() + + const ok = await runOptimisticPatch({ + id: 'c1', + body: { title: 'Nytt' }, + apply: (l) => setTitle(l, 'c1', 'Nytt'), + revert: (l) => setTitle(l, 'c1', 'Original'), + setList: state.set, + guard: createRevisionGuard(), + onError, + patch: async () => false, + }) + + expect(ok).toBe(false) + expect(state.list[0]!.title).toBe('Original') + expect(onError).toHaveBeenCalledOnce() + }) + + it('does not roll back over a newer write to the same row', async () => { + // Double-click on pin: the first request fails AFTER the second has already + // set the newer value. Reverting here would clobber it. + const state = fakeState([row({ id: 'c1', pinned: false })]) + const guard = createRevisionGuard() + const onError = vi.fn() + + let releaseFirst: (v: boolean) => void = () => {} + const first = runOptimisticPatch({ + id: 'c1', + body: { pinned: true }, + apply: (l) => setPinned(l, 'c1', true), + revert: (l) => setPinned(l, 'c1', false), + setList: state.set, + guard, + onError, + patch: () => new Promise((resolve) => (releaseFirst = resolve)), + }) + + // Second write lands and succeeds while the first is still in flight. + await runOptimisticPatch({ + id: 'c1', + body: { pinned: false }, + apply: (l) => setPinned(l, 'c1', false), + revert: (l) => setPinned(l, 'c1', true), + setList: state.set, + guard, + onError, + patch: async () => true, + }) + + releaseFirst(false) + await first + + // The newer write owns the row: its value stands and no error is shown for + // a change the user already superseded. + expect(state.list[0]!.pinned).toBe(false) + expect(onError).not.toHaveBeenCalled() + }) + + it('still rolls back a stale write to a DIFFERENT row', async () => { + const state = fakeState([row({ id: 'a', pinned: false }), row({ id: 'b', pinned: false })]) + const guard = createRevisionGuard() + const onError = vi.fn() + + await runOptimisticPatch({ + id: 'b', + body: { pinned: true }, + apply: (l) => setPinned(l, 'b', true), + revert: (l) => setPinned(l, 'b', false), + setList: state.set, + guard, + onError, + patch: async () => true, + }) + await runOptimisticPatch({ + id: 'a', + body: { pinned: true }, + apply: (l) => setPinned(l, 'a', true), + revert: (l) => setPinned(l, 'a', false), + setList: state.set, + guard, + onError, + patch: async () => false, + }) + + expect(state.list.find((c) => c.id === 'a')!.pinned).toBe(false) + expect(state.list.find((c) => c.id === 'b')!.pinned).toBe(true) + expect(onError).toHaveBeenCalledOnce() + }) + + it('restores an archived row into the list as it stands when the request fails', async () => { + const archived = row({ id: 'a', last_message_at: '2026-07-26T12:00:00Z' }) + const other = row({ id: 'b', last_message_at: '2026-07-26T11:00:00Z' }) + const state = fakeState([archived, other]) + + await runOptimisticPatch({ + id: 'a', + body: { archived: true }, + apply: (l) => removeRow(l, 'a'), + revert: (l) => restoreRow(l, archived), + setList: state.set, + guard: createRevisionGuard(), + onError: () => {}, + patch: async () => false, + }) + + expect(state.list.map((c) => c.id)).toEqual(['a', 'b']) + }) +}) + +describe('patchConversation', () => { + it('reports failure for a non-2xx instead of assuming success', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 })) + expect(await patchConversation('c1', { pinned: true })).toBe(false) + }) + + it('reports failure when the request throws (offline)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + // Must not reject: an unhandled rejection was the old behaviour. + expect(await patchConversation('c1', { pinned: true })).toBe(false) + }) + + it('sends the PATCH to the conversation endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + await patchConversation('c1', { archived: true }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/agent/conversations/c1', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ archived: true }) }), + ) + }) +}) diff --git a/components/agent/conversation-mutations.ts b/components/agent/conversation-mutations.ts new file mode 100644 index 00000000..598bed95 --- /dev/null +++ b/components/agent/conversation-mutations.ts @@ -0,0 +1,129 @@ +import type { ConversationRow } from './conversation-display' + +/** + * The list transforms and the optimistic-write coordinator behind + * useConversationList. Pure and React-free so they can be tested directly: + * the hook is a thin binding over these, which means a test that deletes the + * rollback here fails, rather than passing against a copy of the logic. + */ + +export function setPinned( + list: ConversationRow[], + id: string, + pinned: boolean, +): ConversationRow[] { + return list.map((c) => (c.id === id ? { ...c, pinned } : c)) +} + +export function setTitle( + list: ConversationRow[], + id: string, + title: string | null, +): ConversationRow[] { + return list.map((c) => (c.id === id ? { ...c, title } : c)) +} + +export function removeRow(list: ConversationRow[], id: string): ConversationRow[] { + return list.filter((c) => c.id !== id) +} + +/** + * Put a row back after a failed archive, into whatever the list looks like NOW. + * + * Restoring a render-time snapshot of the whole list would discard any pin, + * rename or archive the user made while the request was in flight. Position + * follows the server's ordering (pinned first, then most recent first) so the + * row reappears where it belongs rather than at the end. + */ +export function restoreRow(list: ConversationRow[], row: ConversationRow): ConversationRow[] { + if (list.some((c) => c.id === row.id)) return list + const sortKey = (c: ConversationRow) => c.last_message_at ?? c.created_at ?? '' + const idx = list.findIndex((c) => { + if (row.pinned !== c.pinned) return row.pinned && !c.pinned + return sortKey(row) > sortKey(c) + }) + if (idx === -1) return [...list, row] + return [...list.slice(0, idx), row, ...list.slice(idx)] +} + +/** PATCH one conversation. Resolves false for a non-2xx AND for a thrown fetch. */ +export async function patchConversation( + id: string, + body: Record, +): Promise { + try { + const res = await fetch(`/api/agent/conversations/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return res.ok + } catch { + return false + } +} + +/** + * Per-row revision guard. + * + * Two writes to the same row can overlap (a double-click on pin, a rename + * while an archive is in flight). Without this, a failing FIRST request would + * roll back over the SECOND request's newer value. Each write claims a + * revision; a rollback only applies while its revision is still the latest for + * that row. + */ +export function createRevisionGuard() { + const revisions = new Map() + return { + claim(id: string): number { + const next = (revisions.get(id) ?? 0) + 1 + revisions.set(id, next) + return next + }, + isCurrent(id: string, revision: number): boolean { + return revisions.get(id) === revision + }, + } +} + +export interface OptimisticPatchArgs { + id: string + body: Record + /** Optimistic transform, applied immediately. */ + apply: (list: ConversationRow[]) => ConversationRow[] + /** Undo, applied to the CURRENT list only if this write is still the latest. */ + revert: (list: ConversationRow[]) => ConversationRow[] + setList: (updater: (prev: ConversationRow[]) => ConversationRow[]) => void + guard: ReturnType + onError: () => void + patch?: typeof patchConversation +} + +/** + * Apply a change optimistically, send it, and undo it if the server refuses. + * Returns whether the change stuck. + */ +export async function runOptimisticPatch({ + id, + body, + apply, + revert, + setList, + guard, + onError, + patch = patchConversation, +}: OptimisticPatchArgs): Promise { + const revision = guard.claim(id) + setList(apply) + + const ok = await patch(id, body) + if (ok) return true + + // A newer write for this row has since been issued: undoing here would clobber + // it. That write owns the row's state and will report its own failure. + if (!guard.isCurrent(id, revision)) return false + + setList(revert) + onError() + return false +} diff --git a/components/agent/use-conversation-list.ts b/components/agent/use-conversation-list.ts new file mode 100644 index 00000000..bdd73ca9 --- /dev/null +++ b/components/agent/use-conversation-list.ts @@ -0,0 +1,171 @@ +'use client' + +import { useCallback, useMemo, useRef, useState } from 'react' +import { useToast } from '@/components/ui/use-toast' +import { type ConversationRow, type DateBucket, groupConversations } from './conversation-display' +import { + createRevisionGuard, + removeRow, + restoreRow, + runOptimisticPatch, + setPinned, + setTitle, +} from './conversation-mutations' + +/** + * Shared state and mutations for the agent conversation list. + * + * The two surfaces that show conversations (the full-page /chat sidebar and the + * in-sheet resume list) had drifted apart in behaviour, not just chrome: the + * sheet rolled a failed rename back and toasted, while the sidebar fired pin, + * archive and rename blind, with no res.ok check, no rollback and no message. + * A failed archive there removed a conversation from the list while it still + * existed on the server, and a failed rename displayed a title the server never + * saved, both until the next reload. + * + * Owning the state and all three mutations here means the two surfaces cannot + * diverge again, and the sheet gains pin/archive it never had. The chrome + * stays with each surface: a 320px sidebar that collapses to a rail and a sheet + * panel are legitimately different shapes. + * + * The transforms and the write coordinator live in conversation-mutations.ts so + * they are testable without a React harness (this repo's unit project is + * node-only). + */ +export interface ConversationListApi { + conversations: ConversationRow[] + setConversations: React.Dispatch> + query: string + setQuery: (q: string) => void + grouped: { bucket: DateBucket; rows: ConversationRow[] }[] + togglePin: (id: string, current: boolean) => Promise + archive: (id: string) => Promise + rename: (id: string, title: string) => Promise + // Inline rename plumbing, shared so Esc-to-cancel behaves identically. + editingId: string | null + editValue: string + setEditValue: (v: string) => void + startEdit: (c: ConversationRow) => void + cancelEdit: () => void + commitEdit: (id: string) => Promise +} + +export function useConversationList(initial: ConversationRow[]): ConversationListApi { + const [conversations, setConversations] = useState(initial) + const [query, setQuery] = useState('') + const { toast } = useToast() + const guard = useRef(createRevisionGuard()).current + + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + // Set by Esc so the blur fired when the input unmounts doesn't save. + const cancelRef = useRef(false) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return conversations + return conversations.filter( + (c) => + (c.title ?? '').toLowerCase().includes(q) || + (c.last_message_preview ?? '').toLowerCase().includes(q) || + (c.context_ref ?? '').toLowerCase().includes(q) || + c.intent_id.toLowerCase().includes(q), + ) + }, [conversations, query]) + + const grouped = useMemo(() => groupConversations(filtered), [filtered]) + + const togglePin = useCallback( + async (id: string, current: boolean) => { + await runOptimisticPatch({ + id, + body: { pinned: !current }, + apply: (list) => setPinned(list, id, !current), + revert: (list) => setPinned(list, id, current), + setList: setConversations, + guard, + onError: () => toast({ variant: 'destructive', title: 'Kunde inte ändra fästningen.' }), + }) + }, + [guard, toast], + ) + + /** Resolves true when the row is really archived, so callers can navigate. */ + const archive = useCallback( + async (id: string) => { + const row = conversations.find((c) => c.id === id) + return runOptimisticPatch({ + id, + body: { archived: true }, + apply: (list) => removeRow(list, id), + // Restore into the CURRENT list, not a snapshot: changes made while the + // request was in flight must survive. + revert: (list) => (row ? restoreRow(list, row) : list), + setList: setConversations, + guard, + onError: () => toast({ variant: 'destructive', title: 'Kunde inte arkivera konversationen.' }), + }) + }, + [conversations, guard, toast], + ) + + const rename = useCallback( + async (id: string, title: string) => { + const previousTitle = conversations.find((c) => c.id === id)?.title ?? null + await runOptimisticPatch({ + id, + body: { title }, + apply: (list) => setTitle(list, id, title), + revert: (list) => setTitle(list, id, previousTitle), + setList: setConversations, + guard, + onError: () => + toast({ variant: 'destructive', title: 'Kunde inte byta namn på konversationen.' }), + }) + }, + [conversations, guard, toast], + ) + + const startEdit = useCallback((c: ConversationRow) => { + setEditingId(c.id) + setEditValue(c.title ?? '') + cancelRef.current = false + }, []) + + const cancelEdit = useCallback(() => { + cancelRef.current = true + setEditingId(null) + }, []) + + const commitEdit = useCallback( + async (id: string) => { + if (cancelRef.current) { + cancelRef.current = false + return + } + setEditingId(null) + const title = editValue.trim() + const current = conversations.find((c) => c.id === id) + if (!title || title === current?.title) return + await rename(id, title) + }, + [conversations, editValue, rename], + ) + + return { + conversations, + setConversations, + query, + setQuery, + grouped, + togglePin, + archive, + rename, + editingId, + editValue, + setEditValue, + startEdit, + cancelEdit, + commitEdit, + } +}