.
This commit is contained in:
+85
-25
@@ -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 { api, clearToken, getToken } from "./lib/api.ts";
|
||||||
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
|
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
FilePreview,
|
FilePreview,
|
||||||
User,
|
User,
|
||||||
} from "./lib/types.ts";
|
} 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 { Login } from "./components/Login.tsx";
|
||||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||||
import { Sidebar } from "./components/Sidebar.tsx";
|
import { Sidebar } from "./components/Sidebar.tsx";
|
||||||
@@ -91,6 +91,15 @@ export function App() {
|
|||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [convs, setConvs] = useState<Conversation[]>([]);
|
const [convs, setConvs] = useState<Conversation[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
// Async callbacks (refreshConvs) need the current selection without
|
||||||
|
// re-creating on every render.
|
||||||
|
const activeIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
activeIdRef.current = activeId;
|
||||||
|
}, [activeId]);
|
||||||
|
// True once the conversation list was fetched at least once (so an
|
||||||
|
// unknown /chat/<id> can be bounced instead of flashing empty).
|
||||||
|
const [convsReady, setConvsReady] = useState(false);
|
||||||
// Conversation opened from "Archived chats" (read-only, not in the sidebar list).
|
// Conversation opened from "Archived chats" (read-only, not in the sidebar list).
|
||||||
const [archivedConv, setArchivedConv] = useState<Conversation | null>(null);
|
const [archivedConv, setArchivedConv] = useState<Conversation | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
@@ -127,20 +136,28 @@ export function App() {
|
|||||||
return <SharePage publicId={SHARE_ID} />;
|
return <SharePage publicId={SHARE_ID} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshConvs = useCallback(async (select?: string) => {
|
// Reload the sidebar list; resolves the id that ends up selected so
|
||||||
|
// callers can sync the URL (/chat/<id>) with it.
|
||||||
|
const refreshConvs = useCallback(async (select?: string): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const res = await api.convs();
|
const res = await api.convs();
|
||||||
setConvs(res.conversations);
|
setConvs(res.conversations);
|
||||||
if (select) setActiveId(select);
|
setConvsReady(true);
|
||||||
else if (res.conversations.length > 0) {
|
let next: string | null = null;
|
||||||
setActiveId((cur) =>
|
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.some((c) => c.id === cur)
|
||||||
? cur
|
? cur
|
||||||
: res.conversations[0]!.id,
|
: (res.conversations[0]?.id ?? null);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
setActiveId(next);
|
||||||
|
return next;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore (session errors reload the page via api layer)
|
// ignore (session errors reload the page via api layer)
|
||||||
|
return activeIdRef.current;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -174,7 +191,18 @@ export function App() {
|
|||||||
setModels(m.models);
|
setModels(m.models);
|
||||||
if (m.models.length > 0) setModel(m.models[0]!.id);
|
if (m.models.length > 0) setModel(m.models[0]!.id);
|
||||||
setConvs(c.conversations);
|
setConvs(c.conversations);
|
||||||
if (c.conversations.length > 0) setActiveId(c.conversations[0]!.id);
|
setConvsReady(true);
|
||||||
|
// Deep link: /chat/<id> 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(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -197,7 +225,7 @@ export function App() {
|
|||||||
if (user) navigate("/chat", true);
|
if (user) navigate("/chat", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path === "/chat") {
|
if (isChatPath(path)) {
|
||||||
if (noToken || (authReady && !user)) navigate("/login", true);
|
if (noToken || (authReady && !user)) navigate("/login", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,6 +234,30 @@ export function App() {
|
|||||||
else if (authReady) navigate(user ? "/chat" : "/login", true);
|
else if (authReady) navigate(user ? "/chat" : "/login", true);
|
||||||
}, [path, user, authReady]);
|
}, [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/<id> (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
|
// Load messages for active conversation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFailed(null);
|
setFailed(null);
|
||||||
@@ -248,7 +300,10 @@ export function App() {
|
|||||||
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)),
|
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)),
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
refreshConvs();
|
// Land directly on the latest conversation's own URL.
|
||||||
|
refreshConvs().then((id) => {
|
||||||
|
if (id) navigate(`/chat/${id}`, true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -268,16 +323,6 @@ export function App() {
|
|||||||
navigate("/login", true);
|
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. */
|
/** Stream one assistant reply for an exact history; resolves its text. */
|
||||||
async function streamReply(history: ChatMessage[], convId: string): Promise<string> {
|
async function streamReply(history: ChatMessage[], convId: string): Promise<string> {
|
||||||
// "Thinking" feature flag: decorate the outgoing payload only — the
|
// "Thinking" feature flag: decorate the outgoing payload only — the
|
||||||
@@ -304,6 +349,7 @@ export function App() {
|
|||||||
const res = await api.createConv({ model });
|
const res = await api.createConv({ model });
|
||||||
convId = res.conversation.id;
|
convId = res.conversation.id;
|
||||||
await refreshConvs(convId);
|
await refreshConvs(convId);
|
||||||
|
navigate(`/chat/${convId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -449,7 +495,8 @@ export function App() {
|
|||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
await refreshConvs();
|
const next = await refreshConvs();
|
||||||
|
navigate(next ? `/chat/${next}` : "/chat");
|
||||||
pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
|
pushToast(t("toast.chatDeleted", { title: c.title }), { icon: "trash" });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -464,7 +511,8 @@ export function App() {
|
|||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
await refreshConvs();
|
const next = await refreshConvs();
|
||||||
|
navigate(next ? `/chat/${next}` : "/chat");
|
||||||
pushToast(t("toast.chatArchived", { title: c.title }), { icon: "archive" });
|
pushToast(t("toast.chatArchived", { title: c.title }), { icon: "archive" });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -478,6 +526,8 @@ export function App() {
|
|||||||
setArchivedConv(c);
|
setArchivedConv(c);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
// Archived views always live on bare /chat (no per-conversation URL).
|
||||||
|
navigate("/chat");
|
||||||
api
|
api
|
||||||
.getConv(c.id)
|
.getConv(c.id)
|
||||||
.then((res) => setMessages(res.messages))
|
.then((res) => setMessages(res.messages))
|
||||||
@@ -493,6 +543,7 @@ export function App() {
|
|||||||
pushToast(t("toast.chatUnarchived", { title: archivedConv.title }), { icon: "unarchive" });
|
pushToast(t("toast.chatUnarchived", { title: archivedConv.title }), { icon: "unarchive" });
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
await refreshConvs(id);
|
await refreshConvs(id);
|
||||||
|
navigate(`/chat/${id}`);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -506,6 +557,7 @@ export function App() {
|
|||||||
if (deletedId && archivedConv?.id === deletedId) {
|
if (deletedId && archivedConv?.id === deletedId) {
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
navigate("/chat");
|
||||||
}
|
}
|
||||||
await refreshConvs();
|
await refreshConvs();
|
||||||
}
|
}
|
||||||
@@ -542,7 +594,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// / (or unknown paths) while the guard above redirects.
|
// / (or unknown paths) while the guard above redirects.
|
||||||
if (path !== "/chat") {
|
if (!isChatPath(path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,12 +614,20 @@ export function App() {
|
|||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggle={() => setSidebarCollapsed(true)}
|
onToggle={() => setSidebarCollapsed(true)}
|
||||||
onNew={() => {
|
onNew={() => {
|
||||||
newChat();
|
// Bare /chat with an empty composer; the conversation is
|
||||||
|
// created lazily on send (then the URL becomes /chat/<id>).
|
||||||
|
setArchivedConv(null);
|
||||||
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
setFailed(null);
|
||||||
|
setRetryCount(0);
|
||||||
|
navigate("/chat");
|
||||||
setMobileNav(false);
|
setMobileNav(false);
|
||||||
}}
|
}}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
setArchivedConv(null);
|
setArchivedConv(null);
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
|
navigate(`/chat/${id}`);
|
||||||
setMobileNav(false);
|
setMobileNav(false);
|
||||||
}}
|
}}
|
||||||
onRename={setEditConv}
|
onRename={setEditConv}
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ body {
|
|||||||
/* markdown content */
|
/* markdown content */
|
||||||
.md p:first-child { margin-top: 0; }
|
.md p:first-child { margin-top: 0; }
|
||||||
.md p:last-child { margin-bottom: 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 {
|
.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;
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ export function getPath(): string {
|
|||||||
return normalizePath(window.location.pathname);
|
return normalizePath(window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** /chat itself or one conversation page (/chat/<id>). */
|
||||||
|
export function isChatPath(pathname: string): boolean {
|
||||||
|
const p = normalizePath(pathname);
|
||||||
|
return p === "/chat" || p.startsWith("/chat/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conversation id from /chat/<id>, 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). */
|
/** Client-side navigation without reload (same-origin path only). */
|
||||||
export function navigate(to: string, replace = false): void {
|
export function navigate(to: string, replace = false): void {
|
||||||
const dest = normalizePath(to);
|
const dest = normalizePath(to);
|
||||||
|
|||||||
Reference in New Issue
Block a user