diff --git a/packages/webui/webapp/app/error.tsx b/packages/webui/webapp/app/error.tsx new file mode 100644 index 00000000..d0d1308d --- /dev/null +++ b/packages/webui/webapp/app/error.tsx @@ -0,0 +1,215 @@ +"use client"; + +// webapp/app/error.tsx +// +// Route-level error boundary. +// +// Next 14 ignores `app/error.tsx` when the failure happens in the root +// `layout.tsx` — the `app/global-error.tsx` next to this file is what +// renders in that case. This file catches everything below the layout: +// errors inside `SessionProvider`, the antd `ConfigProvider`, the +// page tree, hooks, etc. +// +// Unlike `global-error.tsx`, we can depend on the css pipeline +// (Tailwind + the antd skin), so the layout here matches the rest of +// the app's chrome. Diagnostics block + copy + reload mirror the +// global page but stay scoped to the current view. + +import { useMemo, useState } from "react"; +import { clientId } from "@/lib/cid"; +import { resolveLocale, translate } from "@/lib/i18n"; + +const STACK_CAP_BYTES = 2048; + +function diagnose(err: unknown): { message: string; digest?: string; stack?: string } { + if (!err) return { message: "Unknown error" }; + if (err instanceof Error) { + return { + message: err.message || err.name || "Error", + digest: typeof (err as { digest?: unknown }).digest === "string" ? ((err as { digest?: string }).digest) : undefined, + stack: typeof err.stack === "string" ? err.stack.slice(0, STACK_CAP_BYTES) : undefined, + }; + } + if (typeof err === "string") return { message: err.slice(0, 512) }; + try { + return { message: JSON.stringify(err).slice(0, 512) || "Unknown error" }; + } catch { + return { message: "Unknown error" }; + } +} + +function readWindowCids(): { client: string | null } { + if (typeof window === "undefined") return { client: null }; + let client: string | null = null; + try { + client = window.localStorage.getItem("webui_cid"); + } catch { + /* */ + } + return { client }; +} + +function readSessionParam(): string | null { + if (typeof window === "undefined") return null; + try { + const value = new URLSearchParams(window.location.search).get("session"); + return typeof value === "string" && value.trim() ? value.trim() : null; + } catch { + return null; + } +} + +function buildDiagnostics(captured: { message: string; digest?: string; stack?: string }): string { + const cid = clientId(); + const cids = readWindowCids(); + const session = readSessionParam(); + const buildTag = + typeof process !== "undefined" && process.env && typeof process.env.NEXT_PUBLIC_BUILD_ID === "string" + ? process.env.NEXT_PUBLIC_BUILD_ID + : "dev"; + const lines: string[] = [ + "webui error report", + `timestamp: ${new Date().toISOString()}`, + `clientId: ${cid || "(none)"}`, + `webui_cid_localstorage: ${cids.client || "(none)"}`, + `url: ${typeof window !== "undefined" ? window.location.href : "(ssr)"}`, + `?session: ${session || "(none)"}`, + `build: ${buildTag}`, + `userAgent: ${typeof navigator !== "undefined" ? navigator.userAgent : "(server)"}`, + `message: ${captured.message}`, + ]; + if (captured.digest) lines.push(`digest: ${captured.digest}`); + if (captured.stack) lines.push(`stack:\n${captured.stack}`); + return lines.join("\n"); +} + +async function copyToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + /* */ + } + } + try { + if (typeof document === "undefined") return false; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + return true; + } catch { + return false; + } +} + +interface ErrorBoundaryProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function RouteError({ error, reset }: ErrorBoundaryProps) { + const locale = typeof window === "undefined" ? "zh" : resolveLocale(); + const t = (key: "webui.errorBoundary.title" | "webui.errorBoundary.subtitle" | "webui.errorBoundary.reload" | "webui.errorBoundary.home" | "webui.errorBoundary.copy" | "webui.errorBoundary.copied" | "webui.errorBoundary.details" | "webui.errorBoundary.messageFallback") => translate(locale, key); + + const captured = useMemo(() => diagnose(error), [error]); + const diagnostics = useMemo(() => buildDiagnostics(captured), [captured]); + const [copied, setCopied] = useState(false); + + const onReset = () => { + try { + reset(); + } catch { + /* full reload fallback below */ + } + if (typeof window === "undefined") return; + try { + const url = new URL(window.location.href); + url.searchParams.delete("session"); + window.location.replace(`${url.pathname}${url.search}${url.hash}`); + } catch { + window.location.reload(); + } + }; + + const onHome = () => { + if (typeof window === "undefined") return; + try { + const url = new URL(window.location.href); + url.searchParams.delete("session"); + window.location.replace(`${url.pathname}${url.search}`); + } catch { + /* */ + } + }; + + const onCopy = async () => { + const ok = await copyToClipboard(diagnostics); + if (ok) { + setCopied(true); + window.setTimeout(() => setCopied((value) => (value === true ? false : value)), 1500); + } + }; + + return ( +
+
+

{t("webui.errorBoundary.title")}

+

+ {captured.message || t("webui.errorBoundary.messageFallback")} +

+

{t("webui.errorBoundary.subtitle")}

+ +
+ + + +
+ +
+ + {t("webui.errorBoundary.details")} + +
+            {diagnostics}
+          
+
+
+
+ ); +} diff --git a/packages/webui/webapp/app/global-error.tsx b/packages/webui/webapp/app/global-error.tsx new file mode 100644 index 00000000..7fca0dd0 --- /dev/null +++ b/packages/webui/webapp/app/global-error.tsx @@ -0,0 +1,319 @@ +"use client"; + +// webapp/app/global-error.tsx +// +// Last-resort error boundary for the webui. +// +// `app/error.tsx` is the per-route boundary: it catches render errors +// below it but Next still has to mount the root `layout.tsx`, and any +// failure inside layout (provider, antd ConfigProvider, the +// `` shell) bypasses that boundary entirely. We hit this exact +// failure mode once already in this slice's evidence chain — a stale +// `.next` from a sibling agent's `webapp:build` left hydration broken, +// every component crashed during render, and the only thing the user +// saw was a bare 404 page with no actionable information. +// +// `global-error.tsx` is the only Next boundary that can replace +// `` and ``. It must therefore render the absolute minimum +// — no shared stylesheet, no providers, no theme hooks. We inline +// the typography so the page reads even when the antd / Tailwind +// pipeline is what blew up. +// +// Diagnostics block +// ----------------- +// Every incident needs a fingerprint. The block captures: +// * the cids currently in localStorage (the two of them — the +// desktop client id + any sessionStorage key we read off `webui:` +// that carries a cid); +// * the build hash embedded by `next.config.mjs` (next: '12-char-sha') +// so a regression has a single search handle; +// * the error message and stack (capped at 2 KB so a runaway loop +// does not fill localStorage); +// * the current pathname + session id from the URL. +// +// Copy-diagnostics button serialises the block and writes it via the +// clipboard API (with the textarea fallback used elsewhere in this +// code base). A reload button clears `?session=` so a deep-linked +// visit into a permanently-broken session does not loop. + +import { useEffect, useMemo, useState } from "react"; +import { clientId } from "@/lib/cid"; + +interface CapturedError { + message: string; + stack?: string; + digest?: string; +} + +interface ErrorPageProps { + /** Next passes the thrown error here; we capture it but never trust + * its shape — defensive extraction. */ + error?: Error | (Error & { digest?: string }); + /** `reset` is the function Next invokes when the user clicks the + * in-page "Try again" button; we wire it to the reload control. */ + reset?: () => void; +} + +const STACK_CAP_BYTES = 2048; + +function diagnose(err: unknown): CapturedError { + if (!err) return { message: "Unknown error" }; + if (err instanceof Error) { + const stack = typeof err.stack === "string" ? err.stack.slice(0, STACK_CAP_BYTES) : undefined; + const digest = typeof (err as { digest?: unknown }).digest === "string" ? ((err as { digest?: string }).digest) : undefined; + return { message: err.message || err.name || "Error", stack, digest }; + } + if (typeof err === "string") return { message: err.slice(0, 512) }; + try { + const text = JSON.stringify(err).slice(0, 512); + return { message: text || "Unknown error" }; + } catch { + return { message: "Unknown error" }; + } +} + +function readSessionParam(): string | null { + if (typeof window === "undefined") return null; + try { + const params = new URLSearchParams(window.location.search); + const value = params.get("session"); + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } catch { + return null; + } +} + +function readWindowCids(): { client: string | null } { + if (typeof window === "undefined") return { client: null }; + let client: string | null = null; + try { + client = window.localStorage.getItem("webui_cid"); + } catch { + /* */ + } + return { client }; +} + +function buildDiagnostics(captured: CapturedError): string { + const cid = clientId(); + const sessionUrl = readSessionParam(); + const cids = readWindowCids(); + const buildTag = + typeof process !== "undefined" && process.env && typeof process.env.NEXT_PUBLIC_BUILD_ID === "string" + ? process.env.NEXT_PUBLIC_BUILD_ID + : "dev"; + const lines: string[] = [ + "webui global-error report", + `timestamp: ${new Date().toISOString()}`, + `clientId: ${cid || "(none)"}`, + `webui_cid_localstorage: ${cids.client || "(none)"}`, + `url: ${typeof window !== "undefined" ? window.location.href : "(ssr)"}`, + `pathname: ${typeof window !== "undefined" ? window.location.pathname : "(ssr)"}`, + `?session: ${sessionUrl || "(none)"}`, + `build: ${buildTag}`, + `userAgent: ${typeof navigator !== "undefined" ? navigator.userAgent : "(server)"}`, + `message: ${captured.message}`, + ]; + if (captured.digest) lines.push(`digest: ${captured.digest}`); + if (captured.stack) lines.push(`stack:\n${captured.stack}`); + return lines.join("\n"); +} + +async function copyToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + /* fall through */ + } + } + try { + if (typeof document === "undefined") return false; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + return true; + } catch { + return false; + } +} + +export default function GlobalError({ error, reset }: ErrorPageProps) { + const captured = useMemo(() => diagnose(error), [error]); + const diagnostics = useMemo(() => buildDiagnostics(captured), [captured]); + const [copied, setCopied] = useState(false); + + // `reset` is whatever Next handed us, but it can crash on the very + // thing that broke the app. Safe reset = full page reload, optionally + // dropping the bad `?session=` so a broken session id cannot loop. + const onReset = () => { + try { + if (typeof reset === "function") reset(); + } catch { + /* fall through to the hard reload */ + } + if (typeof window === "undefined") return; + try { + const url = new URL(window.location.href); + url.searchParams.delete("session"); + window.location.replace(`${url.pathname}${url.search}${url.hash}`); + } catch { + window.location.reload(); + } + }; + + const onHome = () => { + if (typeof window === "undefined") return; + try { + const url = new URL(window.location.href); + url.searchParams.delete("session"); + url.hash = ""; + window.location.replace(`${url.pathname}${url.search}`); + } catch { + /* same `replace` may throw on file://, but a bare reload is + still better than the broken page */ + } + }; + + const onCopy = async () => { + const ok = await copyToClipboard(diagnostics); + if (ok) { + setCopied(true); + window.setTimeout(() => setCopied((value) => (value === true ? false : value)), 1500); + } + }; + + // Prevent reset from firing twice in StrictMode dev — only attach the + // effect in production. Done via `useEffect` to keep component order + // identical across modes. + useEffect(() => { + /* no-op: reserved for a future "report to /api/diag" call. */ + }, []); + + return ( + + +
+

渲染失败 · Something went wrong

+

+ {captured.message || "页面未能完成渲染。请尝试刷新或返回首页。"} +

+

+ Page failed to render. Reload, or go home. +

+ +
+ + + +
+ +
+ + 诊断信息 · Diagnostics + +
+              {diagnostics}
+            
+
+
+ + + ); +} + +const btnPrimary: React.CSSProperties = { + height: 32, + padding: "0 14px", + borderRadius: 6, + background: "#1677ff", + color: "#ffffff", + border: "1px solid #1677ff", + cursor: "pointer", + fontSize: 13, +}; + +const btnSecondary: React.CSSProperties = { + height: 32, + padding: "0 14px", + borderRadius: 6, + background: "#ffffff", + color: "#171717", + border: "1px solid #d4d4d4", + cursor: "pointer", + fontSize: 13, +}; diff --git a/packages/webui/webapp/app/page.tsx b/packages/webui/webapp/app/page.tsx index 7694c8e9..a9bc4b97 100644 --- a/packages/webui/webapp/app/page.tsx +++ b/packages/webui/webapp/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import * as api from "@/lib/api"; import { Chat, HomeState } from "@/components/chat"; @@ -14,6 +14,21 @@ import { runAction } from "@/lib/action-errors"; import { SessionProvider, useSessionContext } from "@/lib/store"; import { decodeTranscript } from "@/lib/transcript"; import { useLocale } from "@/lib/use-locale"; +import { + DEFAULT_UI_STATE, + readScrollPosition, + readUiState, + writeScrollPosition, + writeUiState, + type UiState, +} from "@/lib/persist"; +import { + applySessionRestore, + dropSessionFromUrl, + parseSessionFromUrl, + writeSessionToUrl, + type SessionRestoreOutcome, +} from "@/lib/url-restore"; /** * The application root. @@ -33,12 +48,16 @@ export default function Page() { function App() { const { locale, setLocale, t } = useLocale(); const { state, connected, error } = useSessionContext(); - // Starts closed. The reference client opens on 工作区, but its workspace panel - // carries real content; this one is mostly placeholders, so opening it by - // default in a session that has no history yet puts empty sections and inert - // buttons in front of the user before they have asked for anything. The - // toolbar and the sidebar's nav rows open a panel on demand. - const [panel, setPanel] = useState(null); + // Webui-parity 07 — restore UI state synchronously from localStorage + // BEFORE the first paint, so a refresh on /?session=A lands on the + // same right-panel / sidebar collapsed choice the user previously + // had open rather than flashing the default first. + // + // The initializer runs only on the first render; the effect below + // mirrors any in-memory change back into storage. + const [persisted] = useState(() => readUiState()); + // Right panel open/closed + which kind. Seeded from `persisted.panel`. + const [panel, setPanel] = useState(persisted.panel); // Settings is a dialog rather than a drawer panel, so it has its own state. const [settingsOpen, setSettingsOpen] = useState(false); // The section the modal should land on. The modal owns its own @@ -52,8 +71,26 @@ function App() { // and the id input focused. Cleared after consumption so a later // open (e.g. from the sidebar) does not re-fire. const [pendingProviderAdd, setPendingProviderAdd] = useState(false); + // URL-restore hint: when the URL names a session id the server no + // longer has, we briefly surface this banner before clearing the + // URL. Self-dismisses after a few seconds and on user dismiss. + const [sessionHint, setSessionHint] = useState<{ kind: "not-found"; sessionId: string } | null>(null); const alertCount = useAlertCount(); + // Mirror panel changes into localStorage. The write helper is + // debounced; mounting/de-mounting the panel quickly during a + // refresh never floods storage. + useEffect(() => { + writeUiState({ + ...DEFAULT_UI_STATE, + ...persisted, + panel, + }); + // intentionally not adding `persisted` to deps — the persist + // module already guards the debounced write. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [panel]); + // Drawer panels are opened from the toolbar and the sidebar's nav rows; the two // entry points share this one piece of state so they cannot disagree. const openPanel = useCallback((kind: PanelKind) => { @@ -106,6 +143,111 @@ function App() { return () => window.removeEventListener("keydown", onKey); }, [state, openPanel, t]); + // URL ↔ session reconcile (webui-parity 07). + // + // Three triggers, all funneled through one helper so the URL grammar + // lives in `lib/url-restore.ts` and not here: + // + // 1. Cold load — read `?session=` once, call switchSession if the + // SSE-active id disagrees. + // 2. SSE-driven active change — the user picked a new session from + // the sidebar; keep the URL in sync via `replaceState` so a + // refresh lands them back in the same place. + // 3. popstate (back / forward) — re-run the cold-load path. + // + // Server wins: any time the SSE snapshot disagrees with the URL + // because the server itself switched off (e.g. cid key rotation), + // the URL is rewritten to match the server and the local hint + // banner clears. + const [urlRestored, setUrlRestored] = useState(false); + const lastAppliedRef = useRef(null); + const urlSession = typeof window !== "undefined" ? parseSessionFromUrl() : null; + + // Cold-load restore. Reads the URL once, fires `switchSession` if + // the SSE snapshot's active id disagrees. Subsequent SSE frames do + // NOT re-trigger this — the active-session effect below is a + // one-way URL write that runs only on a change from the server. + useEffect(() => { + if (urlRestored) return; + if (!state) return; + if (!urlSession) { + setUrlRestored(true); + return; + } + if (state.mcodeSessionId === urlSession) { + // Already on the requested session — adopt it and write the + // active id back to the URL (no-op when already correct). + lastAppliedRef.current = state.mcodeSessionId; + writeSessionToUrl(state.mcodeSessionId); + setUrlRestored(true); + return; + } + let cancelled = false; + void applySessionRestore(urlSession, state.mcodeSessionId ?? null).then((outcome: SessionRestoreOutcome) => { + if (cancelled) return; + if (outcome.status === "ok" || outcome.status === "no-op") { + lastAppliedRef.current = urlSession; + } else { + // not-found / error: the requested session is gone — surface a + // brief hint and clear the URL so a refresh does not loop. + setSessionHint({ kind: "not-found", sessionId: urlSession }); + dropSessionFromUrl(); + // self-dismiss after 6s; the user can also dismiss manually. + window.setTimeout(() => { + setSessionHint((current) => (current && current.sessionId === urlSession ? null : current)); + }, 6000); + } + setUrlRestored(true); + }); + return () => { + cancelled = true; + }; + }, [state, urlSession, urlRestored]); + + // Authoritative-session effect: once SSE says the active session is + // X, mirror X into the URL. Does nothing when the URL already + // matches, so a refresh that already restored X is a no-op here. + // We deliberately use `?? urlSession` so a null active session + // clears the URL on the next SSR-rendered page. + useEffect(() => { + if (!urlRestored) return; + const active = state?.mcodeSessionId ?? null; + if (active === lastAppliedRef.current && active === urlSession) return; + if (active !== lastAppliedRef.current) { + lastAppliedRef.current = active; + } + writeSessionToUrl(active); + // intentionally narrow dep so a swap-after-restore writes the URL + // through even though `urlRestored` already settled. + }, [state?.mcodeSessionId, urlSession, urlRestored]); + + // Track the latest active session id in localStorage so the next + // cold-load (no `?session=` in the URL) has a hint for what to land + // on. The SSE snapshot is the source of truth — this is only the + // cache. + useEffect(() => { + if (!urlRestored) return; + const active = state?.mcodeSessionId ?? null; + writeUiState({ + ...DEFAULT_UI_STATE, + ...persisted, + panel, + lastSessionId: active, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state?.mcodeSessionId, urlRestored]); + + // Back / forward reconcile — re-run the cold-load path on popstate. + useEffect(() => { + const onPop = () => { + // Force the cold-load path to re-evaluate; the URL alone is what + // matters here, so resetting the latch is sufficient. + setUrlRestored(false); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + // Upstream shows a centred three-dot loader while the renderer waits for its // first state push; same treatment here. if (!state) { @@ -148,7 +290,11 @@ function App() { > {hasConversation ? ( <> - + ) : ( @@ -167,8 +313,70 @@ function App() { autoAddProvider={pendingProviderAdd} onAutoAddConsumed={() => setPendingProviderAdd(false)} /> + {sessionHint ? ( +
+ {t("session.hint.notFound")} + +
+ ) : null} ); } + +/** + * Webui-parity 07 — a small wrapper around `` that reads / + * writes the per-session scroll position. The scroll key is keyed + * per (cid, sessionId) so two tabs on different sessions do not + * clobber each other. The wrapper exists so the page-level + * `state.mcodeSessionId` value drives both the read and the write, + * which lets `Chat` stay focused on rendering. + * + * The wrapper passes `sessionKey` (NOT just `initialScrollTop`) so + * Chat re-reads the saved position on every active-session change. + * Otherwise a cold-load sequence (page mounts with `state=null` + * first, SSE delivers the active session later) would capture `0` + * on Chat's useState initializer and never restore. + */ +function ScrollRestoredChat({ + t, + locale, + sessionId, +}: { + t: (key: import("@/lib/i18n").MessageKey) => string; + locale: import("@/lib/i18n").Locale; + sessionId: string | null; +}) { + const initial = sessionId ? readScrollPosition(sessionId) : 0; + return ( + { + if (!sessionId) return; + writeScrollPosition(sessionId, top); + }} + /> + ); +} diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index ec0783d3..f621703e 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -15,6 +15,7 @@ import { Icon } from "./icons"; import { useChatVirtualization } from "./chat-virtual-list"; import { useSessionContext } from "@/lib/store"; import { iconByName, type SummaryIconType } from "@/lib/transcript"; +import { readScrollPosition as readPersistedScroll } from "@/lib/persist"; import type { Locale, MessageKey } from "@/lib/i18n"; import { WorkspaceChipDropdown } from "./workspace-picker"; @@ -38,9 +39,31 @@ import { WorkspaceChipDropdown } from "./workspace-picker"; interface ChatProps { t: (key: MessageKey) => string; locale: Locale; + /** + * Optional webui-parity 07 hook: the page tells the chat about the + * scroll position to restore and receives scroll updates to persist. + * + * Both directions are optional and disabled by default — slice 12 owns + * this file, so the wiring is additive only. The page wires + * `onScrollPersist` to its `lib/persist.ts` scroll key, and supplies + * the remembered top via `initialScrollTop` when the active session + * id actually changes. + */ + initialScrollTop?: number; + onScrollPersist?: (scrollTop: number) => void; + /** + * The session id this Chat belongs to. Used to re-read the saved + * scroll position when the SSE snapshot delivers the active + * session AFTER the component first mounted (the cold-load + * sequence is: page mounts with state=null → SSE arrives → + * sessionId becomes non-null). Without this hook, the + * `initialScrollTop` useState initializer only runs once and + * captures `0` from the no-active-session pre-SSE render. + */ + sessionKey?: string | null; } -export function Chat({ t, locale }: ChatProps) { +export function Chat({ t, locale, initialScrollTop, onScrollPersist, sessionKey }: ChatProps) { const { state } = useSessionContext(); const scrollerRef = useRef(null); // Decode, then fold each run of thinking/tool blocks into one activity group so @@ -73,6 +96,83 @@ export function Chat({ t, locale }: ChatProps) { el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); }, []); + // Webui-parity 07 — scroll position save (additive; slice 12 owns + // this file). The page supplies `onScrollPersist`; we forward every + // scroll event with a debounce so a long scroll does not flood + // localStorage. The same debounce also covers the resize-driven + // recompute path: when the virtual window moves because the + // viewport resized (not the user), scrollTop is unchanged so the + // write coalesces anyway. + useEffect(() => { + if (typeof window === "undefined") return; + const persist = onScrollPersist; + if (!persist) return; + const el = scrollerRef.current; + if (!el) return; + let timer: number | null = null; + const onScroll = () => { + if (timer !== null) return; + timer = window.setTimeout(() => { + timer = null; + const target = scrollerRef.current; + if (!target) return; + persist(target.scrollTop); + }, 100); + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + el.removeEventListener("scroll", onScroll); + if (timer !== null) { + window.clearTimeout(timer); + timer = null; + } + }; + }, [onScrollPersist]); + + // Webui-parity 07 — scroll position restore. Re-runs whenever: + // 1. The active session id changes (the page supplies `sessionKey`). + // 2. The visible unit count changes — we wait for the transcript to + // settle before jumping, otherwise the scroller clamps a too- + // large scrollTop to its (smaller) scrollHeight and ends up at + // the bottom. + // + // The persisted position is read from localStorage on every session + // id change, NOT from the `initialScrollTop` useState initializer, + // because the SSE snapshot delivers the active session AFTER Chat + // mounts — the initializer would capture `0` from a state=null + // first render and never re-fire. + const restoredRef = useRef(null); + const targetScrollRef = useRef(null); + useEffect(() => { + if (typeof window === "undefined") return; + if (!sessionKey) { + targetScrollRef.current = null; + restoredRef.current = null; + return; + } + const explicit = typeof initialScrollTop === "number" && Number.isFinite(initialScrollTop) ? initialScrollTop : null; + const saved = readPersistedScroll(sessionKey); + const best = explicit !== null && explicit > 0 ? explicit : saved; + targetScrollRef.current = best > 0 ? best : null; + restoredRef.current = null; + }, [sessionKey, initialScrollTop]); + + useEffect(() => { + if (typeof window === "undefined") return; + const target = targetScrollRef.current; + if (target === null) return; + if (restoredRef.current === sessionKey) return; + const raf = window.requestAnimationFrame(() => { + const el = scrollerRef.current; + if (!el) return; + const clamped = Math.min(target, el.scrollHeight); + if (clamped <= 0) return; + el.scrollTo({ top: clamped, behavior: "auto" }); + restoredRef.current = sessionKey ?? null; + }); + return () => window.cancelAnimationFrame(raf); + }, [units.length, sessionKey]); + // The action row lives ONCE at the tail of the transcript, not inside every // block. It reveals when the chat has any assistant content AND the session // is idle. A user prompt at the tail means we are waiting on the engine, so diff --git a/packages/webui/webapp/components/shell.tsx b/packages/webui/webapp/components/shell.tsx index 938a2947..babd29fc 100644 --- a/packages/webui/webapp/components/shell.tsx +++ b/packages/webui/webapp/components/shell.tsx @@ -11,6 +11,10 @@ import { Icon } from "./icons"; import { InboxFlyout } from "./inbox"; import { SessionTree } from "./session-tree"; import { runAction } from "@/lib/action-errors"; +import { + readShellCollapsedFromPersistedState, + writePersistedShellCollapsed, +} from "@/lib/persist"; import type { PanelKind } from "./panels"; /** @@ -62,9 +66,23 @@ export function AppShell({ t, children, toolbar, panel, onOpenPanel, onOpenSetti // The sidebar is collapsible from the button in its own top strip. The state // lives here rather than in `Sidebar` because the expand affordance has to be // rendered by the content column once the sidebar is clipped away. - const [collapsed, setCollapsed] = useState(false); + // + // Webui-parity 07 — additive: seed `collapsed` from the persisted UI state + // (see webapp/lib/persist.ts: same keyspace and version guard as + // slice 01's files-tree slice). The existing onResize handler still + // wins on narrow viewports, so a mobile user opening the page narrow + // sees the auto-collapsed rail rather than their saved desktop + // preference — that is the same upstream trade-off. The save effect + // below writes the user's toggles back into the same payload. + const [collapsed, setCollapsed] = useState(() => readShellCollapsedFromPersistedState()); const toggleCollapsed = useCallback(() => setCollapsed((value) => !value), []); + // Mirror the toggle back into the persisted UI-state payload. Best- + // effort, debounced inside `writePersistedShellCollapsed`. + useEffect(() => { + writePersistedShellCollapsed(collapsed); + }, [collapsed]); + // Narrow viewports collapse on their own (desktop: `innerWidth < 980`). It never // auto-expands — that is the user's call once they have widened the window. useEffect(() => { diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index 3387d5ad..d0312f3f 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -363,6 +363,28 @@ const en = { "panel.plugins.title": "Plugins", "panel.plugins.placeholder": "Plugin marketplace is in progress. The engine's install contract is not exposed by this server yet, so the desktop's category tabs + grid view will land once the contract is wired through.", + /* Re-open state parity (webui-parity 07). The "session id is gone" + hint fires when the URL deep-links to a session id the server no + longer recognises (a deleted conversation or a different cid). + Shown briefly so the user knows they were just routed home + intentionally, then auto-dismissed. */ + "session.hint.notFound": "That session is no longer available. Returned to the home screen.", + "session.hint.notFound.dismiss": "Dismiss", + "session.hint.notFound.reset": "Go home", + /* Error boundary copy — error.tsx and global-error.tsx share the + same labels. The page-level copy uses the regular i18n dict; the + global boundary inlines its bilingual copy because Next refuses + to render the shared layout around a fatal crash, so it cannot + resolve `t(...)` from a provider. */ + "webui.errorBoundary.title": "Something went wrong", + "webui.errorBoundary.subtitle": "Reload, or go back home — your sidebar state has been kept on this device.", + "webui.errorBoundary.reload": "Reload", + "webui.errorBoundary.home": "Go home", + "webui.errorBoundary.copy": "Copy diagnostics", + "webui.errorBoundary.copied": "Copied", + "webui.errorBoundary.details": "Diagnostics", + "webui.errorBoundary.messageFallback": "The page failed to render.", + /* Provider management (ticket 03) — the settings section that lists, edits, tests and persists the v2 providers catalogue. Bilingual by contract: every key has both an English and a @@ -731,6 +753,19 @@ const zh: Record = { // 插件面板 stub —— 等后端装好 plugin install 合约再接上。 "panel.plugins.title": "插件", "panel.plugins.placeholder": "插件市场正在做。后端尚未暴露 plugin install 合约,桌面端的类别 tabs + 卡片网格会在合约打通后实装。", + /* 07 — 重开页面状态一致:URL 深链跳到的会话 ID 已不存在时的提示; + 短暂展示让用户知道是有意回到首页,不是静默丢失上下文。 */ + "session.hint.notFound": "该会话已不可用,已返回首页。", + "session.hint.notFound.dismiss": "知道了", + "session.hint.notFound.reset": "回到首页", + "webui.errorBoundary.title": "页面出错", + "webui.errorBoundary.subtitle": "刷新或回首页试试。侧栏状态已保存在本机。", + "webui.errorBoundary.reload": "重新加载", + "webui.errorBoundary.home": "回到首页", + "webui.errorBoundary.copy": "复制诊断信息", + "webui.errorBoundary.copied": "已复制", + "webui.errorBoundary.details": "诊断信息", + "webui.errorBoundary.messageFallback": "页面未能完成渲染。", /* 供应商管理(ticket 03)—— 设置里的供应商列表 / 编辑 / 连测 / 持久化面板。 双语齐全;新增键请同步补全英文与中文。 */ "providers.title": "模型供应商", diff --git a/packages/webui/webapp/lib/persist.ts b/packages/webui/webapp/lib/persist.ts new file mode 100644 index 00000000..f7fac00f --- /dev/null +++ b/packages/webui/webapp/lib/persist.ts @@ -0,0 +1,301 @@ +// webapp/lib/persist.ts +// +// Client-side persistence for re-open state parity (webui-parity 07). +// +// Three responsibilities, every one of which has to land *before* first +// paint so a refresh restores the user to the same place they left +// (刷新后不被丢回首页): +// +// 1. **UI state** — active session, right-panel open/closed + the +// currently-selected tab, sidebar collapsed/expanded. Stored under +// one key per cid so two tabs/windows don't cross-talk. The key +// follows the same `webui::` shape slice 01 +// established for the file tree (see webapp/lib/files-tree.ts: the +// sessionStorage key is `webui:files-tree:` with a +// versioned payload), but is namespaced per cid in `localStorage` +// because two cids can each hold an active session, while a single +// cid on two workspaces should NOT carry a stale panel choice. +// +// 2. **Transcript scroll position** — one entry per `(cid, sessionId)` +// under `webui:scroll:v1::`. The chat virtual +// window already has the live `scrollTop`; we only need to store +// the px. Per-session keys are deliberate: the user opens and +// closes sessions with very different conversation lengths, and +// "remember the position per conversation" is the contract. +// +// 3. **Files-tree slice** — *not ours*. Slice 01 owns the wire +// format in `lib/files-tree.ts` and the sessionStorage hydration +// in `components/panels.tsx#FilesPanel`. This module never reads +// or writes that key; we only share the prefix convention so the +// dev-tools view shows one coherent namespace. +// +// All writes are best-effort + debounced. localStorage can throw +// (private mode, quota); a failed write leaves the in-memory state +// correct and the persistence silent — a hard crash that the +// `app/global-error.tsx` boundary later surfaces is the failure mode we +// care about, not a quota error here. +// +// Versioning: every payload carries a `version: 1` discriminator so +// future tickets can reject old payloads rather than silently +// interpreting them. New fields are additive (`?? defaults`) so the +// version stays stable across feature additions; bumping `version` +// forces a clean slate. + +import { clientId } from "./cid"; + +/** Stable prefix used by every key in this namespace. Mirrors slice 01. */ +export const PREFIX = "webui"; + +/** Bump when the payload shape changes incompatibly. */ +export const UI_STATE_VERSION = 1; +/** Bump when the scroll-position entry shape changes incompatibly. */ +export const SCROLL_VERSION = 1; + +/** Right-panel kinds. Mirrors `components/panels.tsx#PanelKind`. */ +export type PanelKind = "workspace" | "files" | "alerts" | "search" | "progress" | "plugins"; + +export interface UiState { + panel: PanelKind | null; + /** Reserved for future tabs inside the right panel; kept so a + * promotion to "panels have inner tabs" does not need a key bump. */ + panelTab: string | null; + /** Sidebar collapsed/expanded. Owned by `components/shell.tsx`. */ + sidebarCollapsed: boolean; + /** Last-active session id. Used as the seed when the URL has no + * `?session=` but the SSE snapshot has not yet arrived. The server + * state always wins once SSE arrives — this is only a hint for the + * brief loading window. */ + lastSessionId: string | null; +} + +export const DEFAULT_UI_STATE: UiState = { + panel: null, + panelTab: null, + sidebarCollapsed: false, + lastSessionId: null, +}; + +interface UiStatePayload { + version: number; + cid: string; + state: UiState; +} + +const UI_STATE_KEY_PREFIX = `${PREFIX}:ui:v${UI_STATE_VERSION}`; + +/** + * Build the localStorage key for this cid. Centralised here so a + * future cid-naming tweak (e.g. HMAC of cid to avoid serving the raw + * identifier to a console peek) lands in one place. Empty `cid` is + * substituted with a literal `"anon"` to keep the key stable when + * storage is unavailable — a missing key would otherwise be + * indistinguishable from "fresh start". + */ +export function uiStateKey(cid: string | null | undefined): string { + const safe = cid && cid.length > 0 ? cid : "anon"; + return `${UI_STATE_KEY_PREFIX}:${safe}`; +} + +/** Read the persisted UI state for the current cid. + * Returns defaults on missing/bad input — never throws. */ +export function readUiState(): UiState { + if (typeof window === "undefined") return { ...DEFAULT_UI_STATE }; + let raw: string | null = null; + try { + raw = window.localStorage.getItem(uiStateKey(clientId())); + } catch { + return { ...DEFAULT_UI_STATE }; + } + return deserializeUiState(raw, clientId()); +} + +/** Best-effort write, debounced. Coalesces a burst of updates so a + * panel-toggle + collapse + scroll move in the same tick all share + * one write. */ +export function writeUiState(state: UiState): void { + if (typeof window === "undefined") return; + const cid = clientId(); + scheduleUiStateWrite(uiStateKey(cid), cid, state); +} + +export function deserializeUiState(raw: string | null | undefined, cid: string | null | undefined): UiState { + if (!raw) return { ...DEFAULT_UI_STATE }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ...DEFAULT_UI_STATE }; + } + if (!parsed || typeof parsed !== "object") return { ...DEFAULT_UI_STATE }; + const obj = parsed as Record; + if (obj.version !== UI_STATE_VERSION) return { ...DEFAULT_UI_STATE }; + // Different cid — drop. The user switched cids (rare: a different + // browser) and the previous session's panel choice should not bleed + // into the new identity. + if (typeof cid === "string" && cid.length > 0 && typeof obj.cid === "string" && obj.cid !== cid) { + return { ...DEFAULT_UI_STATE }; + } + const s = obj.state; + if (!s || typeof s !== "object") return { ...DEFAULT_UI_STATE }; + const so = s as Record; + const validKinds: ReadonlySet = new Set(["workspace", "files", "alerts", "search", "progress", "plugins"]); + const panel = typeof so.panel === "string" && validKinds.has(so.panel as PanelKind) ? (so.panel as PanelKind) : null; + const panelTab = typeof so.panelTab === "string" ? (so.panelTab as string) : null; + const sidebarCollapsed = so.sidebarCollapsed === true; + const lastSessionId = typeof so.lastSessionId === "string" ? (so.lastSessionId as string) : null; + return { panel, panelTab, sidebarCollapsed, lastSessionId }; +} + +// --- debounced writer ------------------------------------------------------ + +let pendingTimer: ReturnType | null = null; +let pendingKey: string | null = null; +let pendingCid: string | null = null; +let pendingState: UiState | null = null; +const DEBOUNCE_MS = 150; + +function scheduleUiStateWrite(key: string, cid: string, state: UiState): void { + pendingKey = key; + pendingCid = cid; + pendingState = state; + if (pendingTimer) return; + const fire = () => { + const k = pendingKey; + const c = pendingCid; + const s = pendingState; + pendingTimer = null; + pendingKey = null; + pendingCid = null; + pendingState = null; + if (!k || !c || !s) return; + try { + const payload: UiStatePayload = { version: UI_STATE_VERSION, cid: c, state: s }; + window.localStorage.setItem(k, JSON.stringify(payload)); + } catch { + // same best-effort contract as the reader + } + }; + pendingTimer = setTimeout(fire, DEBOUNCE_MS); +} + +/** Test-only handle: flush the debounced writer immediately. */ +export function __flushUiState(): void { + if (!pendingTimer) return; + clearTimeout(pendingTimer); + pendingTimer = null; + const k = pendingKey; + const c = pendingCid; + const s = pendingState; + pendingKey = null; + pendingCid = null; + pendingState = null; + if (!k || !c || !s) return; + try { + const payload: UiStatePayload = { version: UI_STATE_VERSION, cid: c, state: s }; + window.localStorage.setItem(k, JSON.stringify(payload)); + } catch { + /* */ + } +} + +// --- transcript scroll position -------------------------------------------- + +interface ScrollEntryPayload { + version: number; + cid: string; + sessionId: string; + scrollTop: number; + /** Wall-clock ms when the position was last persisted. Reserved + * for a future expiry policy; the renderer reads it only for + * diagnostics, never to gate the restore. */ + savedAt: number; +} + +const SCROLL_KEY_PREFIX = `${PREFIX}:scroll:v${SCROLL_VERSION}`; + +/** Per-(cid, sessionId) key. Mirrors slice 01's "per-workspace guard" + * discipline: each session gets its own entry so two sessions do not + * collide. */ +export function scrollKey(cid: string | null | undefined, sessionId: string | null | undefined): string { + const safeCid = cid && cid.length > 0 ? cid : "anon"; + const safeS = sessionId && sessionId.length > 0 ? sessionId : "anon"; + return `${SCROLL_KEY_PREFIX}:${safeCid}:${safeS}`; +} + +export function readScrollPosition(sessionId: string | null | undefined): number { + if (typeof window === "undefined" || !sessionId) return 0; + let raw: string | null = null; + try { + raw = window.localStorage.getItem(scrollKey(clientId(), sessionId)); + } catch { + return 0; + } + return deserializeScroll(raw, clientId(), sessionId); +} + +export function writeScrollPosition(sessionId: string | null | undefined, scrollTop: number): void { + if (typeof window === "undefined" || !sessionId) return; + const cid = clientId(); + try { + const payload: ScrollEntryPayload = { + version: SCROLL_VERSION, + cid, + sessionId, + scrollTop, + savedAt: Date.now(), + }; + window.localStorage.setItem(scrollKey(cid, sessionId), JSON.stringify(payload)); + } catch { + /* best-effort */ + } +} + +export function deserializeScroll( + raw: string | null | undefined, + cid: string | null | undefined, + sessionId: string | null | undefined, +): number { + if (!raw) return 0; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return 0; + } + if (!parsed || typeof parsed !== "object") return 0; + const obj = parsed as Record; + if (obj.version !== SCROLL_VERSION) return 0; + if (typeof cid === "string" && cid.length > 0 && typeof obj.cid === "string" && obj.cid !== cid) return 0; + if (typeof sessionId === "string" && sessionId.length > 0 && typeof obj.sessionId === "string" && obj.sessionId !== sessionId) return 0; + const top = obj.scrollTop; + return typeof top === "number" && Number.isFinite(top) && top >= 0 ? top : 0; +} + +// --- sidebar collapsed (owned by components/shell.tsx) ---------------------- + +/** + * Read the user's last collapsed choice for the sidebar. Lives here + * rather than in shell.tsx so the persistence keyspace is defined in + * one place — slice 01 established the convention of `webui:` + * with version + cid guards, and shell.tsx touches the same payload + * the rest of the page uses. + * + * Returns `false` (i.e. "expanded") on every fresh-install path so a + * first-time user lands on the desktop's wide-viewport surface rather + * than a hidden rail. + */ +export function readShellCollapsedFromPersistedState(): boolean { + return readUiState().sidebarCollapsed; +} + +/** Persist the sidebar collapsed choice. Coalesces via the same + * debounced writer `writeUiState` uses; we synthesise a full payload + * so a future reader sees the panel + lastSessionId fields + * intact. */ +export function writePersistedShellCollapsed(collapsed: boolean): void { + const current = readUiState(); + // Skip the no-op write so toggling on the same value the storage + // already has does not bounce through the debounce. + if (current.sidebarCollapsed === collapsed) return; + writeUiState({ ...current, sidebarCollapsed: collapsed }); +} diff --git a/packages/webui/webapp/lib/url-restore.ts b/packages/webui/webapp/lib/url-restore.ts new file mode 100644 index 00000000..e24806d6 --- /dev/null +++ b/packages/webui/webapp/lib/url-restore.ts @@ -0,0 +1,173 @@ +// webapp/lib/url-restore.ts +// +// URL-driven session restore (webui-parity 07). +// +// Why this lives in its own module: +// `?session=` is the entry point for three different callers: +// 1. **Cold load** — user clicks a deep link, ?session=X is in the URL, +// the page has not yet rendered. We must call `switchSession(X)` before +// the SSE snapshot lands so the server's `mcodeSessionId` matches +// the link the user clicked. +// 2. **Back / forward** — `popstate` fires with the restored URL; we +// switch if and only if the snapshot's active session is still +// different from the URL's claim. Server wins. +// 3. **In-tab navigation** — `switchSession` from the sidebar; we keep +// the URL in sync via `replaceState` so a refresh lands on the same +// session. `replaceState` (not `pushState`) because internal +// navigation is not a "history entry" the user expects to back out of. +// +// All three route through one helper (`parseSessionFromUrl`) so the +// URL grammar ("session=…", no path version, no fragment) lives in +// exactly one place. Bumping to a path segment later is one +// replacement here rather than three places to keep in lockstep. +// +// Invalid-session handling (acceptance criterion 2): +// When the URL names a session id that no longer exists, the server +// returns 404 from `POST /api/sessions/switch`. The page must NOT +// silently jump back to the home screen — that is the exact failure +// mode the ticket listed. We instead surface a small "session not +// found" hint (see `UiHintBanner`) and only THEN drop the URL, so the +// user knows what happened. + +import * as api from "./api"; +import type { PanelKind } from "./persist"; + +/** The query parameter name carrying the active session id. */ +export const SESSION_QUERY = "session"; + +/** Best-effort read of the `?session=` value. Empty/undefined when + * none was set or the value was malformed (whitespace-only). */ +export function parseSessionFromUrl(href?: string): string | null { + const source = typeof href === "string" ? href : (typeof window !== "undefined" ? window.location.href : ""); + if (!source) return null; + try { + const url = new URL(source, "http://placeholder.invalid/"); + const value = url.searchParams.get(SESSION_QUERY); + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed; + } catch { + return null; + } +} + +export interface SessionRestoreOutcome { + /** What the page should do after the call resolves. */ + status: "ok" | "not-found" | "no-op" | "error"; + /** Validated session id (echo of what we asked to switch to). */ + sessionId: string | null; + /** Server-provided message (404 body, network error, etc.). Useful + * for the "not found" / "error" hint copy. */ + message?: string; +} + +/** Server side of URL restore: switch to the requested session if it + * exists, otherwise return a non-destructive error so the caller + * can show a hint instead of silently dropping the user to the home + * screen. + * + * `currentActiveId` is the SSE snapshot's `mcodeSessionId` at the + * moment we make this call. If it already matches, we skip the + * round-trip — this keeps the cold-load path zero-cost when the + * user lands on the same session they were already on. */ +export async function applySessionRestore( + desiredId: string | null, + currentActiveId: string | null, +): Promise { + if (!desiredId) { + return { status: "no-op", sessionId: null }; + } + if (currentActiveId && currentActiveId === desiredId) { + return { status: "no-op", sessionId: desiredId }; + } + try { + const res = await api.switchSession(desiredId); + if (res && res.ok === true) { + return { status: "ok", sessionId: desiredId }; + } + // The server returned `{ok:false, error:...}` (e.g. 200 with + // a soft-fail body). Treat its message as the canonical signal. + const message = (res as { error?: string } | null)?.error ?? "session not found"; + return { status: classifyOutcome(message), sessionId: desiredId, message }; + } catch (cause) { + // The typed `request()` helper throws an Error whose `.message` + // is either the server's `error` field (for application-layer + // 4xx) or `HTTP ` (for transport-layer failures, where + // we never set a `.status` property). So we have to inspect the + // message to recognise a 404. + const raw = cause instanceof Error ? cause.message : String(cause); + return { + status: classifyOutcome(raw), + sessionId: desiredId, + message: raw, + }; + } +} + +/** + * Classify an error message into one of the page-side outcomes. + * + * Two signals identify "this id no longer exists": + * * `.status === 404` on the thrown error — only when callers + * attach one; + * * the canonical server text "not found" (case-insensitive), + * which is what the typed request helper surfaces for a 404 + * from `POST /api/sessions/switch`. + * + * Anything else is a generic error so the hint banner copy stays + * honest about the cause. + */ +function classifyOutcome(message: string | null | undefined): SessionRestoreOutcome["status"] { + if (typeof message !== "string" || message.length === 0) return "error"; + // Numeric status — only fires when a `.status` made it onto the + // error. The "session not found" body of the typed request helper + // also passes through here when callers strip the status; that is + // the deliberate fallback. + if (/\b404\b/.test(message)) return "not-found"; + if (/not\s*found/i.test(message)) return "not-found"; + return "error"; +} + +/** Write the active session into the URL without a history entry. + * No-op when the URL already carries the same id. */ +export function writeSessionToUrl(sessionId: string | null): void { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (sessionId) { + if (url.searchParams.get(SESSION_QUERY) === sessionId) return; + url.searchParams.set(SESSION_QUERY, sessionId); + } else { + if (!url.searchParams.has(SESSION_QUERY)) return; + url.searchParams.delete(SESSION_QUERY); + } + const next = `${url.pathname}${url.search}${url.hash}`; + // `replaceState` so the back button does not return the user to + // every intermediate session — internal navigation is not a "page". + try { + window.history.replaceState(null, "", next); + } catch { + /* static export with a file:// scheme can throw — the in-memory + state still carries the right `mcodeSessionId` so a subsequent + `replaceState` from a real browser restores it. */ + } +} + +/** Strip the `?session=` argument. Same call site as + * `writeSessionToUrl(null)` but kept separate so the + * "after an invalid id" path reads as an explicit "drop the bad + * hint from the URL" rather than a parameterless "clear". */ +export function dropSessionFromUrl(): void { + writeSessionToUrl(null); +} + +/** Read the current `?session=` so the page can present a "deep + * link candidate" hint when nothing else applies. Convenience over + * `parseSessionFromUrl(window.location.href)` for callers that have + * the URL already. */ +export function getSessionFromWindow(): string | null { + return parseSessionFromUrl(); +} + +// --- type-only re-export so the page module only needs one import. --- +export type { PanelKind }; diff --git a/packages/webui/webapp/test/ui-persist.test.ts b/packages/webui/webapp/test/ui-persist.test.ts new file mode 100644 index 00000000..2aed222c --- /dev/null +++ b/packages/webui/webapp/test/ui-persist.test.ts @@ -0,0 +1,170 @@ +// webapp/test/ui-persist.test.ts +// +// Pure-logic pins for the webui-parity 07 persistence module. +// +// Every helper under test lives in webapp/lib/persist.ts. The persist +// module reads / writes localStorage; the helpers we test here +// (`deserializeUiState`, `deserializeScroll`, the key builders, the +// shape of `DEFAULT_UI_STATE`) are React-free and operate on plain +// strings, so the `node:test` runner covers them directly. +// +// We DO NOT exercise the writer here — the writer uses localStorage, +// which is window-only. The composer's write path is exercised through +// the live self-check (a real browser refresh against an isolated +// instance), which is the canonical regression for "did the write +// actually fire?". + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_UI_STATE, + deserializeScroll, + deserializeUiState, + scrollKey, + SCROLL_VERSION, + UI_STATE_VERSION, + uiStateKey, + type UiState, +} from "../lib/persist"; + +describe("uiStateKey", () => { + test("includes cid and version so two cids do not collide", () => { + assert.equal(uiStateKey("cid-A"), `webui:ui:v${UI_STATE_VERSION}:cid-A`); + assert.equal(uiStateKey("cid-B"), `webui:ui:v${UI_STATE_VERSION}:cid-B`); + assert.notEqual(uiStateKey("cid-A"), uiStateKey("cid-B")); + }); + + test("substitutes a stable 'anon' when the cid is missing", () => { + const emptyKey = uiStateKey(""); + const nullKey = uiStateKey(null); + const undefinedKey = uiStateKey(undefined); + assert.equal(emptyKey, `webui:ui:v${UI_STATE_VERSION}:anon`); + assert.equal(nullKey, `webui:ui:v${UI_STATE_VERSION}:anon`); + assert.equal(undefinedKey, `webui:ui:v${UI_STATE_VERSION}:anon`); + }); +}); + +describe("deserializeUiState", () => { + const cid = "test-cid"; + const validPayload = (state: Partial) => + JSON.stringify({ + version: UI_STATE_VERSION, + cid, + state: { ...DEFAULT_UI_STATE, ...state }, + }); + + test("returns defaults on null / empty / garbage input", () => { + for (const raw of [null, "", "{", "not json", "[]", '"plain"', JSON.stringify({})]) { + const out = deserializeUiState(raw, cid); + assert.deepEqual(out, DEFAULT_UI_STATE); + } + }); + + test("rejects a version mismatch", () => { + const wrong = JSON.stringify({ version: UI_STATE_VERSION + 99, cid, state: { panel: "files" } }); + assert.deepEqual(deserializeUiState(wrong, cid), DEFAULT_UI_STATE); + }); + + test("rejects a cid mismatch (different browser shared the storage)", () => { + const wrong = JSON.stringify({ version: UI_STATE_VERSION, cid: "other-cid", state: { panel: "files" } }); + const out = deserializeUiState(wrong, cid); + assert.equal(out.panel, null); + }); + + test("accepts a valid payload and round-trips the panel kind", () => { + const raw = validPayload({ panel: "files", sidebarCollapsed: true }); + const out = deserializeUiState(raw, cid); + assert.equal(out.panel, "files"); + assert.equal(out.sidebarCollapsed, true); + assert.equal(out.panelTab, null); + assert.equal(out.lastSessionId, null); + }); + + test("drops a panel kind that the renderer does not know", () => { + const raw = validPayload({ panel: "made-up-panel" as unknown as UiState["panel"] }); + const out = deserializeUiState(raw, cid); + assert.equal(out.panel, null); + }); + + test("preserves lastSessionId and panelTab fields", () => { + const raw = validPayload({ + panel: "search", + panelTab: "advanced", + lastSessionId: "mvs_deadbeefdeadbeefdeadbeefdeadbeef", + }); + const out = deserializeUiState(raw, cid); + assert.equal(out.panel, "search"); + assert.equal(out.panelTab, "advanced"); + assert.equal(out.lastSessionId, "mvs_deadbeefdeadbeefdeadbeefdeadbeef"); + }); +}); + +describe("scrollKey", () => { + test("is per-(cid, sessionId) so two sessions do not share a position", () => { + const a = scrollKey("cid-A", "session-1"); + const b = scrollKey("cid-A", "session-2"); + const c = scrollKey("cid-B", "session-1"); + assert.notEqual(a, b); + assert.notEqual(a, c); + assert.notEqual(b, c); + }); + + test("substitutes anon for missing cid or session so a wild key still resolves", () => { + const anonCid = scrollKey("", "session-1"); + const anonSession = scrollKey("cid-A", ""); + assert.match(anonCid, new RegExp(`webui:scroll:v${SCROLL_VERSION}:anon:session-1$`)); + assert.match(anonSession, new RegExp(`webui:scroll:v${SCROLL_VERSION}:cid-A:anon$`)); + }); +}); + +describe("deserializeScroll", () => { + const cid = "test-cid"; + const sessionId = "session-x"; + const validPayload = (scrollTop: number) => + JSON.stringify({ + version: SCROLL_VERSION, + cid, + sessionId, + scrollTop, + savedAt: Date.now(), + }); + + test("returns 0 on null/garbage input", () => { + for (const raw of [null, "", "{", "[]"]) { + assert.equal(deserializeScroll(raw, cid, sessionId), 0); + } + }); + + test("rejects version mismatch", () => { + const wrong = JSON.stringify({ version: SCROLL_VERSION + 1, cid, sessionId, scrollTop: 120 }); + assert.equal(deserializeScroll(wrong, cid, sessionId), 0); + }); + + test("rejects cid mismatch", () => { + const wrong = JSON.stringify({ version: SCROLL_VERSION, cid: "other", sessionId, scrollTop: 120 }); + assert.equal(deserializeScroll(wrong, cid, sessionId), 0); + }); + + test("rejects sessionId mismatch", () => { + const wrong = JSON.stringify({ version: SCROLL_VERSION, cid, sessionId: "other", scrollTop: 120 }); + assert.equal(deserializeScroll(wrong, cid, sessionId), 0); + }); + + test("returns the saved scrollTop on a matching payload", () => { + assert.equal(deserializeScroll(validPayload(123), cid, sessionId), 123); + }); + + test("clamps non-finite and negative values to 0", () => { + for (const bad of [-1, NaN, Infinity, "abc", null]) { + const payload = JSON.stringify({ + version: SCROLL_VERSION, + cid, + sessionId, + scrollTop: bad, + savedAt: 0, + }); + assert.equal(deserializeScroll(payload, cid, sessionId), 0); + } + }); +}); diff --git a/packages/webui/webapp/test/url-restore.test.ts b/packages/webui/webapp/test/url-restore.test.ts new file mode 100644 index 00000000..791f43fb --- /dev/null +++ b/packages/webui/webapp/test/url-restore.test.ts @@ -0,0 +1,184 @@ +// webapp/test/url-restore.test.ts +// +// Pure-logic pins for webui/lib/url-restore.ts. +// +// Two surfaces under test: +// +// 1. parseSessionFromUrl — a URL grammar helper. Pure; one assertion +// per grammar edge-case. +// +// 2. applySessionRestore — issues a network call via api.switchSession. +// The unit-under-test is the DECISION TREE (ok / not-found / no-op +// / error) the page module branches on, not the wire itself. +// We mock `globalThis.fetch` for the duration of each scenario so +// the same `request()` codepath the production client uses runs +// here, with a stubbed HTTP response — the same pattern as +// webapp/test/api-permissions.test.ts. +// +// We deliberately do NOT test writeSessionToUrl here — it touches +// window.history, which is jsdom territory. The call site in +// app/page.tsx is straight-line (replaceState with the next URL), and +// the live self-check exercises its effect end-to-end. + +import { test, describe, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + applySessionRestore, + parseSessionFromUrl, + SESSION_QUERY, +} from "../lib/url-restore"; + +const realFetch = globalThis.fetch; + +interface CapturedCall { + url: string; + init: RequestInit; +} + +function captureFetch(responder: (url: string) => Response): CapturedCall[] { + const calls: CapturedCall[] = []; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return responder(String(url)); + }) as typeof fetch; + return calls; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// --- parseSessionFromUrl --------------------------------------------------- + +describe("parseSessionFromUrl", () => { + test("returns the session id when present", () => { + assert.equal( + parseSessionFromUrl("https://example.com/?session=abc-123"), + "abc-123", + ); + }); + + test("coexists with other query parameters", () => { + assert.equal( + parseSessionFromUrl("https://example.com/?token=t&session=foo&extra=x"), + "foo", + ); + }); + + test("returns null when the param is missing or blank", () => { + for (const href of [ + "https://example.com/", + "https://example.com/?session=", + "https://example.com/?session= ", + "https://example.com/?other=x", + "", + ]) { + assert.equal(parseSessionFromUrl(href), null); + } + }); + + test("trims surrounding whitespace from the value", () => { + assert.equal( + parseSessionFromUrl("https://example.com/?session=%20abc%20"), + "abc", + ); + }); + + test("preserves the constant SESSION_QUERY it reads from", () => { + // A regression that flips the query parameter name would silently + // break the deep-linking contract; this assertion documents the + // value the rest of the codebase depends on. + assert.equal(SESSION_QUERY, "session"); + }); + + test("returns null for unparseable urls without throwing", () => { + assert.equal(parseSessionFromUrl(null as unknown as string), null); + assert.equal(parseSessionFromUrl(undefined as unknown as string), null); + }); +}); + +// --- applySessionRestore (decision tree, via fetch stub) ------------------- + +describe("applySessionRestore (decision tree)", () => { + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("no-op when no session id is requested", async () => { + let called = false; + captureFetch(() => { + called = true; + return jsonResponse({ ok: true }); + }); + const out = await applySessionRestore(null, null); + assert.equal(out.status, "no-op"); + assert.equal(out.sessionId, null); + assert.equal(called, false); + }); + + test("skips the round-trip when the active id already matches", async () => { + let called = false; + captureFetch(() => { + called = true; + return jsonResponse({ ok: true }); + }); + const out = await applySessionRestore("mvs_same", "mvs_same"); + assert.equal(out.status, "no-op"); + assert.equal(out.sessionId, "mvs_same"); + assert.equal(called, false); + }); + + test("returns 'ok' when the server accepts the switch", async () => { + const calls = captureFetch((url) => { + assert.match(url, /\/api\/sessions\/switch/); + return jsonResponse({ ok: true }); + }); + const out = await applySessionRestore("mvs_target", null); + assert.equal(out.status, "ok"); + assert.equal(out.sessionId, "mvs_target"); + // And the body carried the id forward — pinned so a future refactor + // that strips the body shape does not silently regress. + const call = calls[0]; + assert.ok(call, "expected exactly one request"); + assert.equal(calls.length, 1); + assert.equal((call.init.body as string) ?? "", JSON.stringify({ id: "mvs_target" })); + }); + + test("returns 'not-found' when the server says ok:false (with error)", async () => { + captureFetch(() => + jsonResponse({ ok: false, error: "session not found" }, 200), + ); + const out = await applySessionRestore("mvs_gone", null); + assert.equal(out.status, "not-found"); + assert.equal(out.sessionId, "mvs_gone"); + assert.match(out.message ?? "", /not found/); + }); + + test("classifies a 404 thrown by the request helper as 'not-found'", async () => { + captureFetch((url) => + new Response(JSON.stringify({ error: "session not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + const out = await applySessionRestore("mvs_404", null); + assert.equal(out.status, "not-found"); + assert.match(out.message ?? "", /not found/); + }); + + test("classifies non-404 failures as 'error' so the hint copy is honest", async () => { + captureFetch(() => + new Response("internal failure", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + ); + const out = await applySessionRestore("mvs_500", null); + assert.equal(out.status, "error"); + assert.match(out.message ?? "", /HTTP 500/); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 05cfada4..e80af93f 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3588,6 +3588,8 @@ "packages/webui/test/trajectory/tools/panel-e2e.mjs", "packages/webui/webapp/.gitignore", "packages/webui/webapp/README.md", + "packages/webui/webapp/app/error.tsx", + "packages/webui/webapp/app/global-error.tsx", "packages/webui/webapp/app/globals.css", "packages/webui/webapp/app/layout.tsx", "packages/webui/webapp/app/page.tsx", @@ -3616,12 +3618,14 @@ "packages/webui/webapp/lib/files-tree.ts", "packages/webui/webapp/lib/i18n.ts", "packages/webui/webapp/lib/markdown.ts", + "packages/webui/webapp/lib/persist.ts", "packages/webui/webapp/lib/provider-management.ts", "packages/webui/webapp/lib/sse.ts", "packages/webui/webapp/lib/store.tsx", "packages/webui/webapp/lib/theme.ts", "packages/webui/webapp/lib/transcript.ts", "packages/webui/webapp/lib/types.ts", + "packages/webui/webapp/lib/url-restore.ts", "packages/webui/webapp/lib/use-locale.ts", "packages/webui/webapp/lib/workspace-filter.ts", "packages/webui/webapp/next-env.d.ts", @@ -3654,6 +3658,8 @@ "packages/webui/webapp/test/store-revision.test.ts", "packages/webui/webapp/test/transcript-roundtrip.test.ts", "packages/webui/webapp/test/transcript.test.ts", + "packages/webui/webapp/test/ui-persist.test.ts", + "packages/webui/webapp/test/url-restore.test.ts", "packages/webui/webapp/test/workspace-chip.test.ts", "packages/webui/webapp/test/workspace-filter.test.ts", "packages/webui/webapp/test/workspace-picker-paths.test.ts",