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, + } +}