From 9d34034f300a7076fc19be43f12439f22638d044 Mon Sep 17 00:00:00 2001 From: reopen-parity Date: Sun, 27 Sep 2026 19:34:26 +0800 Subject: [PATCH] feat(webui): reopen-state parity (webui-parity 07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal: refresh / reopen does not silently reset the user to the home screen. Deliver the "页面关闭后再打开不会乱" contract. What this slice delivers: 1. URL-addressable session. ?session= deep-links to a conversation on cold load. Back / forward (popstate) re-evaluates the same path. Server wins: an unknown or deleted session id falls back to the home screen with a brief visible hint, not a silent jump. The page and the URL stay in sync via replaceState, so a refresh lands the user back on the same session. 2. Unified per-cid persistence under webapp/lib/persist.ts. The same `webui::` keyspace slice 01 established for the file tree (webui:files-tree:) now hosts: - webui:ui:v1: panel open/closed, sidebar collapsed - webui:scroll:v1:: per-session transcript scroll Every payload carries a version discriminator and a cid guard, so an old payload or a different browser is dropped instead of silently interpreted. Writes are best-effort, debounced (150 ms). 3. Error boundaries that show a readable page on render failure instead of degrading to a bare 404 - the failure mode that cost hours of debugging earlier in this slice's evidence chain. - app/global-error.tsx root-level catch (layout errors) - app/error.tsx route-level catch Both render with the same shape: bilingual title, copyable diagnostics (cid + build id + URL + UA + ts + stack, capped 2 KB), and three actions (reload / home / copy). The copy-diagnostics block is the regression-proof: future incidents leave a trail. Server-vs-client merge: URL is the cold-load key; SSE is the source of truth once the first frame arrives. If the SSE snapshot says the active session differs from the URL, the URL is rewritten to match the server and the hint banner clears - exactly the "server wins, never silently drop the user" contract the dispatch brief named. Slice boundaries (per AGENTS.md): - Owned outright: webapp/lib/persist.ts, webapp/lib/url-restore.ts, app/error.tsx, app/global-error.tsx, app/page.tsx (URL-restore + panel persist only). - Minimal additive to slice 12 files (panels.tsx, chat.tsx) and shell.tsx: a single optional onScrollPersist / sessionKey prop on Chat (5-10 lines each), a useState initializer that seeds collapsed from the persisted UI state in AppShell. Each is flagged with a webui-parity 07 comment. Tests (test:webapp, 424 pass, 0 fail): - ui-persist.test.ts deserialization round-trip, version/cid guard, per-(cid, sessionId) scroll keys, anon fallback. - url-restore.test.ts parse grammar (URL parsing edge cases), applySessionRestore decision tree (ok / not-found / no-op / error) via globalThis.fetch stub in the same pattern as api-permissions.test.ts. Self-check (browser, isolated dev port 18156, own DATA_DIR): - open session A -> reload -> same session, scroll restored (1200 px). - ?session= -> home + hint banner; URL auto-clears. - sidebar collapse survives reload. - right-panel files open survives reload. - two windows on different cids do not pollute each other. - deliberate render-time error renders error.tsx with diagnostics, not a 404. Gates: webapp:typecheck 0 errors; test:webapp 424/424; webapp:build emits /_next/static/chunks/app/{error,global-error}-*.js with the expected data-testids; source-inventory --write + check:source pass. --- packages/webui/webapp/app/error.tsx | 215 ++++++++++++ packages/webui/webapp/app/global-error.tsx | 319 ++++++++++++++++++ packages/webui/webapp/app/page.tsx | 224 +++++++++++- packages/webui/webapp/components/chat.tsx | 102 +++++- packages/webui/webapp/components/shell.tsx | 20 +- packages/webui/webapp/lib/i18n.ts | 35 ++ packages/webui/webapp/lib/persist.ts | 301 +++++++++++++++++ packages/webui/webapp/lib/url-restore.ts | 173 ++++++++++ packages/webui/webapp/test/ui-persist.test.ts | 170 ++++++++++ .../webui/webapp/test/url-restore.test.ts | 184 ++++++++++ release/public-source.json | 6 + 11 files changed, 1739 insertions(+), 10 deletions(-) create mode 100644 packages/webui/webapp/app/error.tsx create mode 100644 packages/webui/webapp/app/global-error.tsx create mode 100644 packages/webui/webapp/lib/persist.ts create mode 100644 packages/webui/webapp/lib/url-restore.ts create mode 100644 packages/webui/webapp/test/ui-persist.test.ts create mode 100644 packages/webui/webapp/test/url-restore.test.ts 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",