diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b23e1a..2091163 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api, clearToken, getToken } from "./lib/api.ts"; import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts"; import type { @@ -9,7 +9,7 @@ import type { FilePreview, User, } from "./lib/types.ts"; -import { navigate, normalizePath, useRoute } from "./lib/route.ts"; +import { chatIdFromPath, isChatPath, navigate, normalizePath, useRoute } from "./lib/route.ts"; import { Login } from "./components/Login.tsx"; import { RegisterPage } from "./components/RegisterPage.tsx"; import { Sidebar } from "./components/Sidebar.tsx"; @@ -91,6 +91,15 @@ export function App() { const [model, setModel] = useState(""); const [convs, setConvs] = useState([]); const [activeId, setActiveId] = useState(null); + // Async callbacks (refreshConvs) need the current selection without + // re-creating on every render. + const activeIdRef = useRef(null); + useEffect(() => { + activeIdRef.current = activeId; + }, [activeId]); + // True once the conversation list was fetched at least once (so an + // unknown /chat/ can be bounced instead of flashing empty). + const [convsReady, setConvsReady] = useState(false); // Conversation opened from "Archived chats" (read-only, not in the sidebar list). const [archivedConv, setArchivedConv] = useState(null); const [messages, setMessages] = useState([]); @@ -127,20 +136,28 @@ export function App() { return ; } - const refreshConvs = useCallback(async (select?: string) => { + // Reload the sidebar list; resolves the id that ends up selected so + // callers can sync the URL (/chat/) with it. + const refreshConvs = useCallback(async (select?: string): Promise => { try { const res = await api.convs(); setConvs(res.conversations); - if (select) setActiveId(select); - else if (res.conversations.length > 0) { - setActiveId((cur) => + setConvsReady(true); + let next: string | null = null; + if (select) { + next = res.conversations.some((c) => c.id === select) ? select : null; + } else { + const cur = activeIdRef.current; + next = cur && res.conversations.some((c) => c.id === cur) ? cur - : res.conversations[0]!.id, - ); + : (res.conversations[0]?.id ?? null); } + setActiveId(next); + return next; } catch { // ignore (session errors reload the page via api layer) + return activeIdRef.current; } }, []); @@ -174,7 +191,18 @@ export function App() { setModels(m.models); if (m.models.length > 0) setModel(m.models[0]!.id); setConvs(c.conversations); - if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id); + setConvsReady(true); + // Deep link: /chat/ opens that conversation directly. + const urlId = chatIdFromPath(window.location.pathname); + if (urlId && c.conversations.some((x) => x.id === urlId)) { + setActiveId(urlId); + } else if (urlId) { + navigate("/chat", true); // unknown or deleted id + } else if (c.conversations.length > 0) { + const first = c.conversations[0]!.id; + setActiveId(first); + navigate(`/chat/${first}`, true); + } }) .catch(() => {}) .finally(() => { @@ -197,7 +225,7 @@ export function App() { if (user) navigate("/chat", true); return; } - if (path === "/chat") { + if (isChatPath(path)) { if (noToken || (authReady && !user)) navigate("/login", true); return; } @@ -206,6 +234,30 @@ export function App() { else if (authReady) navigate(user ? "/chat" : "/login", true); }, [path, user, authReady]); + // Browser back/forward (or manual URL edit): the URL is the source of + // truth for the selected conversation. Own navigations are no-ops here + // (state already matches), so this never fights the handlers below. + useEffect(() => { + if (!user || checking || !isChatPath(path)) return; + const id = chatIdFromPath(path); + if (id === activeId) return; + // Back to bare /chat (id null): empty composer, unless an archived + // chat is being viewed — archived views always live on bare /chat. + if (id !== null || !archivedConv) { + setArchivedConv(null); + setActiveId(id); + setMobileNav(false); + } + }, [path, user, checking, activeId, archivedConv]); + + // Unknown /chat/ (deleted elsewhere, typo): bounce to /chat once + // the list is loaded. Archived views always live on bare /chat. + useEffect(() => { + if (!user || checking || !convsReady || !isChatPath(path)) return; + const id = chatIdFromPath(path); + if (id && !convs.some((c) => c.id === id)) navigate("/chat", true); + }, [path, user, checking, convsReady, convs]); + // Load messages for active conversation useEffect(() => { setFailed(null); @@ -248,7 +300,10 @@ export function App() { setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)), ) .catch(() => {}); - refreshConvs(); + // Land directly on the latest conversation's own URL. + refreshConvs().then((id) => { + if (id) navigate(`/chat/${id}`, true); + }); } function logout() { @@ -268,16 +323,6 @@ export function App() { navigate("/login", true); } - async function newChat() { - try { - setArchivedConv(null); - const res = await api.createConv({ model }); - await refreshConvs(res.conversation.id); - } catch { - // fall back to local-only pending state - } - } - /** Stream one assistant reply for an exact history; resolves its text. */ async function streamReply(history: ChatMessage[], convId: string): Promise { // "Thinking" feature flag: decorate the outgoing payload only — the @@ -304,6 +349,7 @@ export function App() { const res = await api.createConv({ model }); convId = res.conversation.id; await refreshConvs(convId); + navigate(`/chat/${convId}`); } catch (err) { return; } @@ -449,7 +495,8 @@ export function App() { setArchivedConv(null); setMessages([]); } - await refreshConvs(); + const next = await refreshConvs(); + navigate(next ? `/chat/${next}` : "/chat"); pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" }); } catch { // ignore @@ -464,7 +511,8 @@ export function App() { setActiveId(null); setMessages([]); } - await refreshConvs(); + const next = await refreshConvs(); + navigate(next ? `/chat/${next}` : "/chat"); pushToast(t("toast.chatArchived", { title: c.title }), { icon: "archive" }); } catch { // ignore @@ -478,6 +526,8 @@ export function App() { setArchivedConv(c); setActiveId(null); setMessages([]); + // Archived views always live on bare /chat (no per-conversation URL). + navigate("/chat"); api .getConv(c.id) .then((res) => setMessages(res.messages)) @@ -493,6 +543,7 @@ export function App() { pushToast(t("toast.chatUnarchived", { title: archivedConv.title }), { icon: "unarchive" }); setArchivedConv(null); await refreshConvs(id); + navigate(`/chat/${id}`); } catch { // ignore } @@ -506,6 +557,7 @@ export function App() { if (deletedId && archivedConv?.id === deletedId) { setArchivedConv(null); setMessages([]); + navigate("/chat"); } await refreshConvs(); } @@ -542,7 +594,7 @@ export function App() { } // / (or unknown paths) while the guard above redirects. - if (path !== "/chat") { + if (!isChatPath(path)) { return null; } @@ -562,12 +614,20 @@ export function App() { collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(true)} onNew={() => { - newChat(); + // Bare /chat with an empty composer; the conversation is + // created lazily on send (then the URL becomes /chat/). + setArchivedConv(null); + setActiveId(null); + setMessages([]); + setFailed(null); + setRetryCount(0); + navigate("/chat"); setMobileNav(false); }} onSelect={(id) => { setArchivedConv(null); setActiveId(id); + navigate(`/chat/${id}`); setMobileNav(false); }} onRename={setEditConv} diff --git a/frontend/src/index.css b/frontend/src/index.css index a11084c..7532f0e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -52,6 +52,13 @@ body { /* markdown content */ .md p:first-child { margin-top: 0; } .md p:last-child { margin-bottom: 0; } +/* Long unbroken strings (URLs, file paths, hashes, tokens) must wrap + anywhere — otherwise they overflow the bubble, which then grows past its + row and glues itself to the screen edge. `pre` keeps its own horizontal + scroll (white-space:pre ignores wrapping). */ +.md { + overflow-wrap: anywhere; +} .md pre { @apply relative overflow-x-auto rounded-2xl border border-stone-200 bg-stone-900 p-4 text-[13.5px] leading-relaxed text-stone-100 dark:border-zinc-700; } diff --git a/frontend/src/lib/route.ts b/frontend/src/lib/route.ts index 38a68cb..58aece1 100644 --- a/frontend/src/lib/route.ts +++ b/frontend/src/lib/route.ts @@ -11,6 +11,18 @@ export function getPath(): string { return normalizePath(window.location.pathname); } +/** /chat itself or one conversation page (/chat/). */ +export function isChatPath(pathname: string): boolean { + const p = normalizePath(pathname); + return p === "/chat" || p.startsWith("/chat/"); +} + +/** Conversation id from /chat/, or null on /chat itself. */ +export function chatIdFromPath(pathname: string): string | null { + const m = normalizePath(pathname).match(/^\/chat\/([^/]+)$/); + return m ? decodeURIComponent(m[1]!) : null; +} + /** Client-side navigation without reload (same-origin path only). */ export function navigate(to: string, replace = false): void { const dest = normalizePath(to);