Compare commits

...
Author SHA1 Message Date
ffaerber c7ea9f26e8 Drop the /bee page (#2)
Docker / build (push) Successful in 20s
2026-09-17 14:51:30 +02:00
Felix FaerberandClaude Opus 5 79e79097ae drop the /bee page
Docker / build (pull_request) Successful in 17s
It showed node health, addresses, topology, wallet, stamps and chain state --
the same ground bee-manager covers at stamps.ffaerber.duckdns.org, and covers
better: bucket maps, top-up and dilute actions, the action ledger, alerting.
Two views of one node drift apart, and the weaker one is the one people find
first, from the gateway's own nav.

Route, component and nav entry go together. The gateway's /bee/stats endpoint
is left alone: nothing else in this app called it, but it is a public API route
and removing it is a decision about the API, not about this page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYFnHida4AdiNDek2pzgXx
2026-09-17 14:27:48 +03:00
Felix FaerberandClaude Opus 5 c0e7b0f8a5 declare the VITE args in the stage that builds
Docker / build (push) Successful in 19s
The build args were only in development-dependencies-env, which runs npm ci.
npm run build happens in build-env, and args do not cross stages -- so Vite has
never seen VITE_API_URL, VITE_GOOGLE_CLIENT_ID or VITE_COMMIT_SHA in any image
this Dockerfile has produced.

It went unnoticed because app/routes/*.tsx fall back to
https://api.t4t-gateway.com, which is correct for the VPS image and silently
wrong everywhere else: the homelab instance deployed today served a UI that
called production, exactly what building it separately was meant to prevent.
Verified from the served bundle -- assets/signup-*.js carried the VPS URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYFnHida4AdiNDek2pzgXx
2026-09-15 13:03:27 +03:00
Felix FaerberandClaude Opus 5 70786f73ab push with a write:package token, not the Actions token
Docker / build (push) Failing after 17s
Second build failed the same way: docker login to this instance's registry
returns "unauthorized" for the automatic Actions token, and declaring
permissions: packages: write did not change it -- Gitea's task token is accepted
by the API but not by the package registry.

So the push uses REGISTRY_TOKEN, an org secret holding a personal access token
scoped to write:package and nothing else. The step fails with that sentence in
the log rather than another bare "unauthorized", because the difference
between a missing secret and a rejected credential is not visible from the
error docker prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYFnHida4AdiNDek2pzgXx
2026-09-15 12:38:14 +03:00
Felix FaerberandClaude Opus 5 f2f3e4e3f9 declare packages: write so the registry login works
Docker / build (push) Failing after 17s
The first build pushed nothing: `docker login git.ffaerber.duckdns.org` came
back "unauthorized". Gitea scopes the automatic Actions token from the job's
permissions block, and without one it has no package write -- so the build
succeeded and the push failed on auth, which reads like a credential problem
rather than a missing declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYFnHida4AdiNDek2pzgXx
2026-09-15 12:36:51 +03:00
ffaerber 7534f34cfc Merge pull request 'Build on Gitea, push to the Gitea registry' (#1) from ci/gitea-actions into main
Docker / build (push) Failing after 17s
2026-09-15 11:33:20 +02:00
Felix Faerber 67b00b8848 build on Gitea, against the homelab API
Docker / build (pull_request) Successful in 23s
Same move as the gateway repo -- build on the homelab runner, push to this
Gitea's registry, no deploy step -- with one difference that matters more here
than anywhere else.

Vite inlines import.meta.env at BUILD time. VITE_API_URL is therefore baked into
the bundle, and the GitHub build bakes https://api.t4t-gateway.com. Running that
image on the homelab would put a UI at t4tgw.ffaerber.duckdns.org that talks to
the VPS: a test signup would create a real account on production, and the two
instances would look identical while being the same instance. This build bakes
the homelab API, which is what makes running them side by side mean anything.

The app also falls back to the production URL when the variable is missing
(app/routes/*.tsx), so an unset build arg fails the same way silently. It is set
explicitly in the workflow env rather than left to a default.

.github/workflows is removed from this copy so Gitea does not try to run the
GitHub one; the GitHub repository keeps its own.
2026-09-15 12:30:27 +03:00
Felix FaerberandClaude 793fa899bf Add chat history sidebar with restore across reloads (#1)
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.


Claude-Session: https://claude.ai/code/session_012RrMZ8Y9BhuMkKNcw2bakZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 00:20:30 +03:00
7 changed files with 284 additions and 284 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
name: Docker
# Builds the frontend for the HOMELAB instance and pushes it to this Gitea's
# registry.
#
# Read the build args before changing anything: Vite inlines import.meta.env at
# BUILD time, so VITE_API_URL is baked into the bundle and an image is tied to
# one API. The GitHub build bakes https://api.t4t-gateway.com -- running that
# image here would serve a UI at t4tgw.ffaerber.duckdns.org that talks to the
# VPS, so a test signup would create a real account on production. This build
# bakes the homelab API instead, which is the whole reason the two instances can
# run side by side and mean anything.
#
# VITE_GOOGLE_CLIENT_ID comes from a repo secret; unset, Google sign-in is the
# only thing that breaks, and the rest of the instance still exercises.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
HOMELAB_API_URL: https://t4tgw-api.ffaerber.duckdns.org
jobs:
build:
runs-on: ci-runner
steps:
- uses: actions/checkout@v4
- name: Build
run: |
set -eu
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "IMAGE=git.ffaerber.duckdns.org/token-for-token/hosted-gateway-frontend" >> "$GITHUB_ENV"
echo "SHA=$SHA" >> "$GITHUB_ENV"
docker build \
--build-arg VITE_API_URL="$HOMELAB_API_URL" \
--build-arg VITE_GOOGLE_CLIENT_ID="${{ secrets.VITE_GOOGLE_CLIENT_ID }}" \
--build-arg VITE_COMMIT_SHA=${{ github.sha }} \
-t "git.ffaerber.duckdns.org/token-for-token/hosted-gateway-frontend:$SHA" .
- name: Push
if: github.event_name != 'pull_request'
run: |
set -eu
# Not the automatic Actions token: this Gitea's package registry rejects
# it (docker login returns a bare "unauthorized"), with or without a
# permissions: packages: write block. REGISTRY_TOKEN is an org secret
# holding a personal access token scoped to write:package only.
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "REGISTRY_TOKEN is not set — create a token with the write:package scope" >&2
echo "and add it as an org secret on token-for-token. The image built fine;" >&2
echo "only the push needs it." >&2
exit 1
fi
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.ffaerber.duckdns.org -u "${{ vars.REGISTRY_USER || 'ffaerber' }}" --password-stdin
docker tag "$IMAGE:$SHA" "$IMAGE:latest"
docker push "$IMAGE:$SHA"
docker push "$IMAGE:latest"
docker logout git.ffaerber.duckdns.org
{
echo "### Pushed"
echo
echo '```'
echo "$IMAGE:$SHA"
echo '```'
echo
echo "API baked in: $HOMELAB_API_URL"
} >> "$GITHUB_STEP_SUMMARY"
-54
View File
@@ -1,54 +0,0 @@
name: Docker Image CI
on:
push:
branches: ['main', 'develop']
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/hosted-gateway-frontend:${{ github.ref_name }}
ghcr.io/${{ github.repository_owner }}/hosted-gateway-frontend:${{ github.sha }}
build-args: |
VITE_API_URL=https://api.t4t-gateway.com
VITE_GOOGLE_CLIENT_ID=${{ secrets.VITE_GOOGLE_CLIENT_ID }}
VITE_COMMIT_SHA=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: docker stack deploy
uses: appleboy/ssh-action@v1.0.3
with:
username: root
host: ${{ secrets.SSH_KNOWN_HOSTS }}
key: ${{ secrets.SSH_PRIV_KEY }}
script: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull ghcr.io/token-for-token/hosted-gateway-frontend:main
docker stack deploy -c ~/devops/hostinger/prod-stack.yaml prod --with-registry-auth --resolve-image always
docker image prune -f --filter "until=24h"
+15
View File
@@ -19,6 +19,21 @@ WORKDIR /app
RUN npm ci --omit=dev RUN npm ci --omit=dev
FROM public.ecr.aws/docker/library/node:20-alpine AS build-env FROM public.ecr.aws/docker/library/node:20-alpine AS build-env
# These have to be declared HERE, not only in development-dependencies-env:
# build args do not cross stages, and this is the stage that runs `npm run
# build`. Without them Vite inlines the fallbacks from app/routes/*.tsx --
# https://api.t4t-gateway.com -- so an image built for anywhere else silently
# drives production.
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ARG VITE_COMMIT_SHA
ENV VITE_COMMIT_SHA=$VITE_COMMIT_SHA
COPY . /app/ COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app WORKDIR /app
-1
View File
@@ -3,7 +3,6 @@ import { NavLink } from 'react-router';
const ITEMS = [ const ITEMS = [
{ to: '/chat', label: 'Chat' }, { to: '/chat', label: 'Chat' },
{ to: '/account', label: 'Account' }, { to: '/account', label: 'Account' },
{ to: '/bee', label: 'Bee' },
]; ];
export function Nav() { export function Nav() {
-1
View File
@@ -7,5 +7,4 @@ export default [
route('login', 'routes/login.tsx'), route('login', 'routes/login.tsx'),
route('account', 'routes/account.tsx'), route('account', 'routes/account.tsx'),
route('chat', 'routes/chat.tsx'), route('chat', 'routes/chat.tsx'),
route('bee', 'routes/bee.tsx'),
] satisfies RouteConfig; ] satisfies RouteConfig;
-209
View File
@@ -1,209 +0,0 @@
import { useEffect, useState } from 'react';
import { Nav } from '~/components/nav';
const API_URL = import.meta.env.VITE_API_URL ?? 'https://api.t4t-gateway.com';
type Health = { status: string; version: string; apiVersion: string };
type Addresses = {
overlay: string;
ethereum: string;
publicKey: string;
pssPublicKey: string;
underlay: string[];
};
type Topology = {
connected: number;
population: number;
depth: number;
reachability: string;
networkAvailability: string;
};
type Wallet = {
bzz: string;
xdai: string;
walletAddress: string;
chainID: number;
};
type Stamp = {
batchID: string;
label: string;
depth: number;
amount: string;
usable: boolean;
usageText: string;
durationDays: number;
};
type ChainState = { block: number; chainTip: number; currentPrice: number };
type Stats = {
health: Health | null;
addresses: Addresses | null;
topology: Topology | null;
wallet: Wallet | null;
stamps: Stamp[] | null;
chain: ChainState | null;
};
export function meta() {
return [{ title: 'Bee · t4t Gateway' }];
}
async function fetchStats(): Promise<Stats> {
const token = localStorage.getItem('jwt');
if (!token) throw new Error('Not authenticated');
const res = await fetch(`${API_URL}/bee/stats`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
localStorage.removeItem('jwt');
window.location.href = '/login';
throw new Error('Unauthorized');
}
const body = await res.json();
if (!res.ok) throw new Error(body?.error ?? `Request failed (${res.status})`);
return body as Stats;
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className='rounded-lg border border-neutral-200 bg-white p-5 shadow-sm'>
<h2 className='text-xs font-semibold uppercase tracking-wide text-neutral-500'>{title}</h2>
<div className='mt-3 text-sm'>{children}</div>
</section>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className='flex justify-between gap-4 py-1'>
<span className='text-neutral-500'>{label}</span>
<span className='break-all text-right font-mono text-neutral-900'>{value}</span>
</div>
);
}
export default function BeePage() {
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!localStorage.getItem('jwt')) {
window.location.href = '/login';
return;
}
fetchStats()
.then((s) => setStats(s))
.catch((err) => setError(err instanceof Error ? err.message : 'Unknown error'))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<>
<Nav />
<main className='flex min-h-[60vh] items-center justify-center'>
<p className='text-sm text-neutral-600'>Loading Bee stats…</p>
</main>
</>
);
}
return (
<>
<Nav />
<main className='mx-auto max-w-3xl px-6 py-10'>
<h1 className='text-2xl font-semibold'>Bee node</h1>
<p className='mt-1 text-sm text-neutral-600'>Read-only status of the operator's Bee node.</p>
{error && (
<p className='mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700'>{error}</p>
)}
<div className='mt-6 grid gap-4'>
<Card title='Health'>
{stats?.health ? (
<>
<Row label='Status' value={stats.health.status} />
<Row label='Version' value={stats.health.version} />
<Row label='API version' value={stats.health.apiVersion} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Addresses'>
{stats?.addresses ? (
<>
<Row label='Overlay' value={stats.addresses.overlay} />
<Row label='Ethereum' value={stats.addresses.ethereum} />
<Row label='Underlays' value={stats.addresses.underlay.length} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Topology'>
{stats?.topology ? (
<>
<Row label='Connected peers' value={stats.topology.connected} />
<Row label='Known peers' value={stats.topology.population} />
<Row label='Depth' value={stats.topology.depth} />
<Row label='Reachability' value={stats.topology.reachability} />
<Row label='Network availability' value={stats.topology.networkAvailability} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Wallet'>
{stats?.wallet ? (
<>
<Row label='Address' value={stats.wallet.walletAddress} />
<Row label='xBZZ' value={stats.wallet.bzz} />
<Row label='xDAI' value={stats.wallet.xdai} />
<Row label='Chain ID' value={stats.wallet.chainID} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Chain state'>
{stats?.chain ? (
<>
<Row label='Block' value={stats.chain.block} />
<Row label='Chain tip' value={stats.chain.chainTip} />
<Row label='Current price (PLUR/chunk)' value={stats.chain.currentPrice} />
</>
) : (
<p className='text-neutral-500'>unavailable</p>
)}
</Card>
<Card title='Postage batches'>
{stats?.stamps && stats.stamps.length > 0 ? (
<ul className='divide-y divide-neutral-200'>
{stats.stamps.map((s) => (
<li key={s.batchID} className='py-2'>
<p className='font-mono text-xs text-neutral-900'>{s.batchID}</p>
<p className='mt-1 text-xs text-neutral-500'>
{s.label || '(unlabeled)'} · depth {s.depth} · used {s.usageText} ·{' '}
{s.usable ? 'usable' : 'not usable'} · {s.durationDays.toFixed(1)}d left
</p>
</li>
))}
</ul>
) : (
<p className='text-neutral-500'>no batches</p>
)}
</Card>
</div>
</main>
</>
);
}
+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>
); );
} }