Files
accounted/components/agent/AgentSessionList.tsx
T
Jakob Wennberg a43a8b03cf 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:30:01 +02:00

217 lines
9.3 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { Search, X, Loader2, MessageSquare, Pencil, Pin, PinOff, Archive } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
type ConversationRow,
BUCKET_LABELS,
relativeTime,
intentLabel,
} from './conversation-display'
import { useConversationList } from './use-conversation-list'
interface Props {
// Highlight the row for the conversation currently open in the sheet.
activeConversationId?: string | null
// Fired when the user picks a conversation to resume. The sheet fetches its
// messages and swaps back to the chat view: the list itself stays dumb.
onSelect: (id: string) => void
}
// In-sheet conversation picker. Renders the same grouped/searchable list as the
// /chat sidebar (shared helpers in conversation-display.ts), but instead of
// navigating to /chat/[id] it hands the id back so the conversation opens
// 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) {
// 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<string | null>(null)
useEffect(() => {
let cancelled = false
void (async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/agent/conversations?limit=100')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = (await res.json()) as { data?: ConversationRow[] }
if (!cancelled) setConversations(json.data ?? [])
} catch {
if (!cancelled) setError('Kunde inte hämta konversationer.')
} finally {
if (!cancelled) setLoading(false)
}
})()
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
}, [])
return (
<div className="flex flex-1 flex-col min-h-0">
<div className="border-b border-border px-5 py-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Sök konversationer…"
className="w-full rounded-md border border-border bg-background pl-8 pr-7 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => setQuery('')}
aria-label="Rensa sökning"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Hämtar
</div>
) : error ? (
<div className="p-6 text-sm text-destructive">{error}</div>
) : grouped.length === 0 ? (
<div className="flex flex-col items-center gap-2 p-10 text-center text-sm text-muted-foreground">
<MessageSquare className="h-6 w-6 opacity-40" />
{conversations.length === 0 ? 'Inga konversationer ännu.' : 'Inga träffar.'}
</div>
) : (
grouped.map(({ bucket, rows }) => (
<section key={bucket} className="py-2">
<p className="px-4 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
{BUCKET_LABELS[bucket]}
</p>
<ul className="space-y-1">
{rows.map((c) => (
<li key={c.id}>
{editingId === c.id ? (
<div className="px-4 py-2">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => void commitEdit(c.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.target as HTMLInputElement).blur()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
}}
placeholder="Namnge konversationen…"
maxLength={200}
aria-label="Nytt namn på konversationen"
className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
) : (
<div
className={cn(
'group flex items-stretch border-l-2 transition-colors',
activeConversationId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent hover:bg-secondary/60',
)}
>
<button
type="button"
onClick={() => onSelect(c.id)}
className="flex flex-1 min-w-0 items-start gap-2 px-4 py-2 text-left"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
</button>
<div className="shrink-0 flex items-center pr-1">
<button
type="button"
onClick={() => void togglePin(c.id, c.pinned)}
title={c.pinned ? 'Lossa' : 'Fäst överst'}
aria-label={
c.pinned ? 'Lossa konversationen' : 'Fäst konversationen överst'
}
aria-pressed={c.pinned}
className="flex h-8 w-8 items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors"
>
{c.pinned ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={() => startEdit(c)}
title="Byt namn"
aria-label="Byt namn på konversation"
className="flex h-8 w-8 items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => void archive(c.id)}
title="Arkivera"
aria-label="Arkivera konversationen"
className="flex h-8 w-8 items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors"
>
<Archive className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</li>
))}
</ul>
</section>
))
)}
</div>
</div>
)
}