Compare commits

...
Author SHA1 Message Date
Claude 844f6fa09f 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RrMZ8Y9BhuMkKNcw2bakZ
2026-08-18 21:11:04 +00:00
+200 -19
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import { Nav } from '~/components/nav'; import { Nav } from '~/components/nav';
const API_URL = import.meta.env.VITE_API_URL ?? 'https://api.t4t-gateway.com'; 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 Model = { id: string; object: 'model'; created: number; owned_by: string };
type Message = { role: 'user' | 'assistant' | 'system'; content: string }; type Message = { role: 'user' | 'assistant' | 'system'; content: string };
@@ -11,6 +13,13 @@ type Completion = {
choices: { index: number; message: ChoiceMessage; finish_reason: string }[]; choices: { index: number; message: ChoiceMessage; finish_reason: string }[];
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; 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() { export function meta() {
return [{ title: 'Chat · t4t Gateway' }]; return [{ title: 'Chat · t4t Gateway' }];
@@ -37,9 +46,41 @@ async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
return body as T; 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() { export default function Chat() {
const [models, setModels] = useState<Model[]>([]); const [models, setModels] = useState<Model[]>([]);
const [model, setModel] = useState<string>(''); const [model, setModel] = useState<string>('');
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -53,11 +94,39 @@ export default function Chat() {
window.location.href = '/login'; window.location.href = '/login';
return; 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 () => { (async () => {
try { try {
const list = await api<{ data: Model[] }>('/v1/models'); const list = await api<{ data: Model[] }>('/v1/models');
setModels(list.data); 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) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
} finally { } finally {
@@ -72,13 +141,85 @@ export default function Chat() {
} }
}, [messages]); }, [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) { async function send(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!input.trim() || !model || sending) return; if (!input.trim() || !model || sending || !activeId) return;
setError(null); setError(null);
const userMsg: Message = { role: 'user', content: input }; const userMsg: Message = { role: 'user', content: input };
const next = [...messages, userMsg]; const next = [...messages, userMsg];
setMessages(next); setMessages(next);
persist(activeId, model, next);
setInput(''); setInput('');
setSending(true); setSending(true);
try { try {
@@ -88,7 +229,9 @@ export default function Chat() {
}); });
const reply = completion.choices[0]?.message; const reply = completion.choices[0]?.message;
if (reply) { 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 { } else {
throw new Error('No reply from model'); 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 ( return (
<main className='flex min-h-screen flex-col'> <main className='flex h-screen flex-col'>
<Nav /> <Nav />
<div className='border-b border-neutral-200 bg-white px-6 py-2'> <div className='border-b border-neutral-200 bg-white px-6 py-2'>
<div className='mx-auto flex max-w-3xl items-center justify-end'> <div className='mx-auto flex max-w-5xl items-center justify-end'>
<select <select
aria-label='Model' aria-label='Model'
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => {
setModel(e.target.value);
if (activeId) persist(activeId, e.target.value, messages);
}}
disabled={loadingModels || models.length === 0} disabled={loadingModels || models.length === 0}
className='rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900' className='rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900'
> >
@@ -122,10 +270,48 @@ export default function Chat() {
</div> </div>
</div> </div>
<div <div className='mx-auto flex w-full max-w-5xl flex-1 overflow-hidden'>
ref={scrollRef} <aside className='hidden w-64 shrink-0 flex-col border-r border-neutral-200 bg-white sm:flex'>
className='mx-auto w-full max-w-3xl flex-1 overflow-y-auto px-6 py-6' <div className='p-3'>
<button
onClick={newChat}
className='inline-flex h-9 w-full items-center justify-center rounded-md bg-neutral-900 px-3 text-sm font-medium text-white hover:bg-neutral-700'
> >
New chat
</button>
</div>
<ul className='flex-1 space-y-1 overflow-y-auto px-2 pb-3'>
{sortedSessions.map((s) => (
<li key={s.id} className='group flex items-center'>
<button
onClick={() => selectSession(s.id)}
className={`flex-1 truncate rounded-md px-3 py-2 text-left text-sm ${
s.id === activeId
? 'bg-neutral-900 text-white'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
title={s.title}
>
{s.title}
</button>
<button
onClick={() => deleteSession(s.id)}
aria-label={`Delete chat: ${s.title}`}
className={`ml-1 hidden shrink-0 rounded-md px-2 py-2 text-xs group-hover:block ${
s.id === activeId
? 'text-neutral-300 hover:text-white'
: 'text-neutral-400 hover:text-neutral-900'
}`}
>
✕
</button>
</li>
))}
</ul>
</aside>
<div className='flex min-w-0 flex-1 flex-col'>
<div ref={scrollRef} className='mx-auto w-full max-w-3xl flex-1 overflow-y-auto px-6 py-6'>
{messages.length === 0 && ( {messages.length === 0 && (
<p className='text-center text-sm text-neutral-500'> <p className='text-center text-sm text-neutral-500'>
Pick a model and send a message to start. Pick a model and send a message to start.
@@ -135,9 +321,7 @@ export default function Chat() {
{messages.map((m, i) => ( {messages.map((m, i) => (
<li <li
key={i} key={i}
className={ className={m.role === 'user' ? 'flex justify-end' : 'flex justify-start'}
m.role === 'user' ? 'flex justify-end' : 'flex justify-start'
}
> >
<div <div
className={ className={
@@ -161,15 +345,10 @@ export default function Chat() {
</div> </div>
{error && ( {error && (
<p className='mx-auto w-full max-w-3xl px-6 pb-2 text-sm text-red-700'> <p className='mx-auto w-full max-w-3xl px-6 pb-2 text-sm text-red-700'>{error}</p>
{error}
</p>
)} )}
<form <form onSubmit={send} className='border-t border-neutral-200 bg-white px-6 py-3'>
onSubmit={send}
className='border-t border-neutral-200 bg-white px-6 py-3'
>
<div className='mx-auto flex max-w-3xl gap-2'> <div className='mx-auto flex max-w-3xl gap-2'>
<input <input
type='text' type='text'
@@ -188,6 +367,8 @@ export default function Chat() {
</button> </button>
</div> </div>
</form> </form>
</div>
</div>
</main> </main>
); );
} }