From 844f6fa09f66348939550d8a730a4a7d23a722aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:11:04 +0000 Subject: [PATCH] Add chat history sidebar with restore across reloads Chat messages previously lived only in React state and vanished on navigation or refresh. Persist each conversation as a session in localStorage and add a sidebar to switch between, restore, or delete past chats, matching the pattern of the account page's client-side persistence. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012RrMZ8Y9BhuMkKNcw2bakZ --- app/routes/chat.tsx | 317 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 249 insertions(+), 68 deletions(-) diff --git a/app/routes/chat.tsx b/app/routes/chat.tsx index 83d5da3..2260fc2 100644 --- a/app/routes/chat.tsx +++ b/app/routes/chat.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { Nav } from '~/components/nav'; const API_URL = import.meta.env.VITE_API_URL ?? 'https://api.t4t-gateway.com'; +const SESSIONS_KEY = 't4t:chatSessions'; +const ACTIVE_SESSION_KEY = 't4t:activeChatId'; type Model = { id: string; object: 'model'; created: number; owned_by: string }; type Message = { role: 'user' | 'assistant' | 'system'; content: string }; @@ -11,6 +13,13 @@ type Completion = { choices: { index: number; message: ChoiceMessage; finish_reason: string }[]; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; }; +type ChatSession = { + id: string; + title: string; + model: string; + messages: Message[]; + updatedAt: number; +}; export function meta() { return [{ title: 'Chat · t4t Gateway' }]; @@ -37,9 +46,41 @@ async function api(path: string, init: RequestInit = {}): Promise { return body as T; } +function newSessionId(): string { + return typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function titleFor(messages: Message[]): string { + const firstUser = messages.find((m) => m.role === 'user'); + if (!firstUser) return 'New chat'; + const text = firstUser.content.trim().replace(/\s+/g, ' '); + if (!text) return 'New chat'; + return text.length > 40 ? `${text.slice(0, 40)}…` : text; +} + +function loadSessions(): ChatSession[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(SESSIONS_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveSessions(sessions: ChatSession[]) { + if (typeof window === 'undefined') return; + localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); +} + export default function Chat() { const [models, setModels] = useState([]); const [model, setModel] = useState(''); + const [sessions, setSessions] = useState([]); + const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); @@ -53,11 +94,39 @@ export default function Chat() { window.location.href = '/login'; return; } + + const stored = loadSessions(); + const storedActiveId = localStorage.getItem(ACTIVE_SESSION_KEY); + const initial = + stored.find((s) => s.id === storedActiveId) ?? + [...stored].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? + null; + + if (initial) { + setSessions(stored); + setActiveId(initial.id); + setMessages(initial.messages); + setModel(initial.model); + localStorage.setItem(ACTIVE_SESSION_KEY, initial.id); + } else { + const fresh: ChatSession = { + id: newSessionId(), + title: 'New chat', + model: '', + messages: [], + updatedAt: Date.now(), + }; + setSessions([fresh]); + setActiveId(fresh.id); + saveSessions([fresh]); + localStorage.setItem(ACTIVE_SESSION_KEY, fresh.id); + } + (async () => { try { const list = await api<{ data: Model[] }>('/v1/models'); setModels(list.data); - if (list.data.length > 0) setModel(list.data[0].id); + setModel((current) => current || (list.data.length > 0 ? list.data[0].id : '')); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { @@ -72,13 +141,85 @@ export default function Chat() { } }, [messages]); + function persist(id: string, mdl: string, msgs: Message[]) { + setSessions((prev) => { + const updated = prev.map((s) => + s.id === id ? { ...s, model: mdl, messages: msgs, title: titleFor(msgs), updatedAt: Date.now() } : s + ); + saveSessions(updated); + return updated; + }); + } + + function selectSession(id: string) { + const session = sessions.find((s) => s.id === id); + if (!session) return; + setActiveId(id); + setMessages(session.messages); + setModel(session.model || model); + setError(null); + localStorage.setItem(ACTIVE_SESSION_KEY, id); + } + + function newChat() { + const fresh: ChatSession = { + id: newSessionId(), + title: 'New chat', + model, + messages: [], + updatedAt: Date.now(), + }; + setSessions((prev) => { + const updated = [fresh, ...prev]; + saveSessions(updated); + return updated; + }); + setActiveId(fresh.id); + setMessages([]); + setError(null); + localStorage.setItem(ACTIVE_SESSION_KEY, fresh.id); + } + + function deleteSession(id: string) { + setSessions((prev) => { + const updated = prev.filter((s) => s.id !== id); + saveSessions(updated); + + if (id === activeId) { + const next = [...updated].sort((a, b) => b.updatedAt - a.updatedAt)[0]; + if (next) { + setActiveId(next.id); + setMessages(next.messages); + setModel(next.model || model); + localStorage.setItem(ACTIVE_SESSION_KEY, next.id); + } else { + const fresh: ChatSession = { + id: newSessionId(), + title: 'New chat', + model, + messages: [], + updatedAt: Date.now(), + }; + updated.push(fresh); + saveSessions(updated); + setActiveId(fresh.id); + setMessages([]); + localStorage.setItem(ACTIVE_SESSION_KEY, fresh.id); + } + } + + return updated; + }); + } + async function send(e: React.FormEvent) { e.preventDefault(); - if (!input.trim() || !model || sending) return; + if (!input.trim() || !model || sending || !activeId) return; setError(null); const userMsg: Message = { role: 'user', content: input }; const next = [...messages, userMsg]; setMessages(next); + persist(activeId, model, next); setInput(''); setSending(true); try { @@ -88,7 +229,9 @@ export default function Chat() { }); const reply = completion.choices[0]?.message; if (reply) { - setMessages([...next, { role: reply.role, content: reply.content }]); + const withReply = [...next, { role: reply.role, content: reply.content }]; + setMessages(withReply); + persist(activeId, model, withReply); } else { throw new Error('No reply from model'); } @@ -99,15 +242,20 @@ export default function Chat() { } } + const sortedSessions = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt); + return ( -
+
); }