From f82a1576a6c62b730daf280811df4c57980e4bd8 Mon Sep 17 00:00:00 2001 From: Dinesh Pandiyan Date: Thu, 15 Oct 2026 17:55:02 +1100 Subject: [PATCH] feat: live stream URLs, collect on load, per-stream collapse state /streams/{id} follows latest head; /contexts/{id} pins. Local live loads collect the checkout first. --- apps/web/src/ProjectsNav.tsx | 18 +- apps/web/src/app-state.tsx | 22 +- apps/web/src/session-context.tsx | 213 +++++++++++++----- apps/web/src/use-file-navigation.ts | 7 +- apps/web/test/browser/local-workspace.spec.ts | 162 ++++++++++++- docs/architecture.md | 22 +- docs/development.md | 11 +- docs/system.md | 13 +- internal/httpapi/multi.go | 4 +- internal/httpapi/multi_test.go | 2 +- ...l-kRpbsHnd.js => angular-html-BdzEVgDY.js} | 2 +- ...-ts-BHVRHau6.js => angular-ts-CGpblEzq.js} | 2 +- .../{apl-CwW3H4-7.js => apl-CNArUoj3.js} | 2 +- .../{astro-dqEMBWdy.js => astro-Dhfw5SVf.js} | 2 +- .../{blade-DVRWyGVX.js => blade-DvAyYQom.js} | 2 +- .../assets/{c-DogBJJWi.js => c-BeoJyHwC.js} | 2 +- ...{chapel-6v1P2rRQ.js => chapel-DYvCUqeA.js} | 2 +- .../{cobol-SUF9mewz.js => cobol-Bo4OKA0E.js} | 2 +- ...{coffee-DznH0xj7.js => coffee-BOj8q1Zh.js} | 2 +- .../{cpp-BQIKo-pY.js => cpp-69KkH-9e.js} | 2 +- ...rystal-G-SAFUvp.js => crystal-D7QKE2u7.js} | 2 +- .../{css-Aob7ot4z.js => css-CVnWIgbb.js} | 2 +- .../{edge-3w9XsgqE.js => edge-Dk5zbpfL.js} | 2 +- ...{elixir-Cu30Sber.js => elixir-nDH184PO.js} | 2 +- .../{elm-DYcBWMBU.js => elm-BbGwbans.js} | 2 +- .../{erb-Bc0SVzH0.js => erb-N3WE7q37.js} | 2 +- ...ase-C1G8EMCX.js => git-rebase-D6o-7HaQ.js} | 2 +- ...-js-DHkzRk2r.js => glimmer-js-DYUzpOLs.js} | 2 +- ...-ts-Dewch2rF.js => glimmer-ts-iDtmbxoy.js} | 2 +- .../{glsl-D4Wmm5Of.js => glsl-B4xUBfzF.js} | 2 +- ...raphql-DEIh20a5.js => graphql-BUzMpHKK.js} | 2 +- .../{hack-BiKZrh2Z.js => hack-CUbkd3N1.js} | 2 +- .../{haml-BzLu36Z5.js => haml-ClknCSyI.js} | 2 +- ...ars-DUJFuh3-.js => handlebars-CmgeKehs.js} | 2 +- .../{html-CbhujaK5.js => html-CQfkWBxv.js} | 2 +- ...cBTzkT9.js => html-derivative-ML7EWSby.js} | 2 +- .../{http-Bd50oGog.js => http-DOAu2FVo.js} | 2 +- .../{hurl-BKEmuR0P.js => hurl-DR1dGmhx.js} | 2 +- .../{index-DHiVSgKk.js => index-ChYS2DN7.js} | 18 +- .../{java-BNLZN11I.js => java-cSiPACSk.js} | 2 +- ...ipt-COhWpa15.js => javascript-xyn_TqGX.js} | 2 +- .../{jinja-BDNxEyCS.js => jinja-Cnd3u5wf.js} | 2 +- .../{jison-DGWzvXmK.js => jison-BpViUsNr.js} | 2 +- .../{json-Vu9uRe-v.js => json-DLhi61pq.js} | 2 +- .../{jsx-jGcp2Xkb.js => jsx-BsjJMY1W.js} | 2 +- .../{julia-BH8lO1sq.js => julia-Z7n49rMs.js} | 2 +- .../{just-BmD21ZLc.js => just-Q1gruYzS.js} | 2 +- .../{latex-BZ65rsfe.js => latex-fYFK6Ovv.js} | 2 +- ...{liquid-DhOlp607.js => liquid-BD3YmwwH.js} | 2 +- .../{lua-CYbZF6KZ.js => lua-CVKC6tvp.js} | 2 +- .../{marko-DW2dEDg2.js => marko-BzcVYSc9.js} | 2 +- .../{mdc-yemXb1vl.js => mdc-C0Rff-MZ.js} | 2 +- .../{nginx-Dolk5Lg1.js => nginx-C-UCMZte.js} | 2 +- .../{nim-DK8bPyCn.js => nim-Db4XjAxj.js} | 2 +- .../{org-DqaqlRt2.js => org-C0dz7Nkk.js} | 2 +- .../{perl-BA2ad6UZ.js => perl-D_Pv9Gut.js} | 2 +- .../{php-DL6_wW6F.js => php-BFLB4U3G.js} | 2 +- .../{pug-B7WQo0-U.js => pug-Bn5FSvNL.js} | 2 +- .../{qml-B5BkCXpX.js => qml-Cq_LQYFZ.js} | 2 +- .../assets/{r-BGaz7xct.js => r-B8i3h8Vf.js} | 2 +- .../{razor-CmQ-hjlp.js => razor-gUbRriUn.js} | 2 +- ...{regexp-BWG5eBOH.js => regexp-CMlLGWLU.js} | 2 +- .../{rst-Dxn-ZQuo.js => rst-XC88BZI_.js} | 2 +- .../{ruby-Ck7B4y7r.js => ruby-B7jeIc6u.js} | 2 +- .../{sas-D1cwmWHe.js => sas-4QGeF8AD.js} | 2 +- .../{scss-CeQ2Gqjd.js => scss-CG6VLMQX.js} | 2 +- ...pt-BHOqvJta.js => shellscript-B7ilZQuP.js} | 2 +- ...n-DHBoGRUo.js => shellsession-7Q0qamCA.js} | 2 +- .../{soy-DL9RUNF4.js => soy-BLRhC5O8.js} | 2 +- .../{sql-BexbxqKg.js => sql-biAvtprx.js} | 2 +- .../{stata-Cns2CxcY.js => stata-BG_pRUJm.js} | 2 +- ...alql-CAXRB2z0.js => surrealql-C2Unt0DY.js} | 2 +- ...{svelte-B0pZrBUZ.js => svelte-CN38kTkB.js} | 2 +- .../{templ-BMT0SIeg.js => templ-DNz9bhdP.js} | 2 +- .../{tex-DrwTw1EC.js => tex-BBr_tpVE.js} | 2 +- ...s-tags-DZfR8O4m.js => ts-tags-D_p9fDF_.js} | 2 +- .../{tsx-D5cZNqTM.js => tsx-D1QJfNmc.js} | 2 +- .../{twig-B2cKhkqv.js => twig-VcJwhz2F.js} | 2 +- ...ipt-BjcJ55ux.js => typescript-C4S5rCN3.js} | 2 +- .../{typst-NdYIdykf.js => typst-BpNEUcgh.js} | 2 +- .../{vue-utf5ZU-G.js => vue-DRbaVyqi.js} | 2 +- ...-html-BGPcFp0z.js => vue-html-Boq9TSBA.js} | 2 +- ...-vine-DBIBiGEc.js => vue-vine-uSxiIelG.js} | 2 +- .../{xml-DQTrsaMi.js => xml-ZcrblCqP.js} | 2 +- .../{xsl-BtLSLbaD.js => xsl-Cr4wQbx2.js} | 2 +- .../{yaml-qCnTQiL-.js => yaml-AGjLRxD5.js} | 2 +- internal/webui/dist/index.html | 2 +- 87 files changed, 465 insertions(+), 179 deletions(-) rename internal/webui/dist/assets/{angular-html-kRpbsHnd.js => angular-html-BdzEVgDY.js} (99%) rename internal/webui/dist/assets/{angular-ts-BHVRHau6.js => angular-ts-CGpblEzq.js} (99%) rename internal/webui/dist/assets/{apl-CwW3H4-7.js => apl-CNArUoj3.js} (99%) rename internal/webui/dist/assets/{astro-dqEMBWdy.js => astro-Dhfw5SVf.js} (98%) rename internal/webui/dist/assets/{blade-DVRWyGVX.js => blade-DvAyYQom.js} (99%) rename internal/webui/dist/assets/{c-DogBJJWi.js => c-BeoJyHwC.js} (99%) rename internal/webui/dist/assets/{chapel-6v1P2rRQ.js => chapel-DYvCUqeA.js} (99%) rename internal/webui/dist/assets/{cobol-SUF9mewz.js => cobol-Bo4OKA0E.js} (99%) rename internal/webui/dist/assets/{coffee-DznH0xj7.js => coffee-BOj8q1Zh.js} (99%) rename internal/webui/dist/assets/{cpp-BQIKo-pY.js => cpp-69KkH-9e.js} (99%) rename internal/webui/dist/assets/{crystal-G-SAFUvp.js => crystal-D7QKE2u7.js} (99%) rename internal/webui/dist/assets/{css-Aob7ot4z.js => css-CVnWIgbb.js} (99%) rename internal/webui/dist/assets/{edge-3w9XsgqE.js => edge-Dk5zbpfL.js} (94%) rename internal/webui/dist/assets/{elixir-Cu30Sber.js => elixir-nDH184PO.js} (99%) rename internal/webui/dist/assets/{elm-DYcBWMBU.js => elm-BbGwbans.js} (99%) rename internal/webui/dist/assets/{erb-Bc0SVzH0.js => erb-N3WE7q37.js} (94%) rename internal/webui/dist/assets/{git-rebase-C1G8EMCX.js => git-rebase-D6o-7HaQ.js} (93%) rename internal/webui/dist/assets/{glimmer-js-DHkzRk2r.js => glimmer-js-DYUzpOLs.js} (99%) rename internal/webui/dist/assets/{glimmer-ts-Dewch2rF.js => glimmer-ts-iDtmbxoy.js} (99%) rename internal/webui/dist/assets/{glsl-D4Wmm5Of.js => glsl-B4xUBfzF.js} (96%) rename internal/webui/dist/assets/{graphql-DEIh20a5.js => graphql-BUzMpHKK.js} (98%) rename internal/webui/dist/assets/{hack-BiKZrh2Z.js => hack-CUbkd3N1.js} (99%) rename internal/webui/dist/assets/{haml-BzLu36Z5.js => haml-ClknCSyI.js} (98%) rename internal/webui/dist/assets/{handlebars-DUJFuh3-.js => handlebars-CmgeKehs.js} (98%) rename internal/webui/dist/assets/{html-CbhujaK5.js => html-CQfkWBxv.js} (99%) rename internal/webui/dist/assets/{html-derivative-9cBTzkT9.js => html-derivative-ML7EWSby.js} (92%) rename internal/webui/dist/assets/{http-Bd50oGog.js => http-DOAu2FVo.js} (95%) rename internal/webui/dist/assets/{hurl-BKEmuR0P.js => hurl-DR1dGmhx.js} (96%) rename internal/webui/dist/assets/{index-DHiVSgKk.js => index-ChYS2DN7.js} (73%) rename internal/webui/dist/assets/{java-BNLZN11I.js => java-cSiPACSk.js} (99%) rename internal/webui/dist/assets/{javascript-COhWpa15.js => javascript-xyn_TqGX.js} (99%) rename internal/webui/dist/assets/{jinja-BDNxEyCS.js => jinja-Cnd3u5wf.js} (98%) rename internal/webui/dist/assets/{jison-DGWzvXmK.js => jison-BpViUsNr.js} (99%) rename internal/webui/dist/assets/{json-Vu9uRe-v.js => json-DLhi61pq.js} (97%) rename internal/webui/dist/assets/{jsx-jGcp2Xkb.js => jsx-BsjJMY1W.js} (99%) rename internal/webui/dist/assets/{julia-BH8lO1sq.js => julia-Z7n49rMs.js} (99%) rename internal/webui/dist/assets/{just-BmD21ZLc.js => just-Q1gruYzS.js} (97%) rename internal/webui/dist/assets/{latex-BZ65rsfe.js => latex-fYFK6Ovv.js} (99%) rename internal/webui/dist/assets/{liquid-DhOlp607.js => liquid-BD3YmwwH.js} (98%) rename internal/webui/dist/assets/{lua-CYbZF6KZ.js => lua-CVKC6tvp.js} (99%) rename internal/webui/dist/assets/{marko-DW2dEDg2.js => marko-BzcVYSc9.js} (99%) rename internal/webui/dist/assets/{mdc-yemXb1vl.js => mdc-C0Rff-MZ.js} (99%) rename internal/webui/dist/assets/{nginx-Dolk5Lg1.js => nginx-C-UCMZte.js} (99%) rename internal/webui/dist/assets/{nim-DK8bPyCn.js => nim-Db4XjAxj.js} (98%) rename internal/webui/dist/assets/{org-DqaqlRt2.js => org-C0dz7Nkk.js} (96%) rename internal/webui/dist/assets/{perl-BA2ad6UZ.js => perl-D_Pv9Gut.js} (99%) rename internal/webui/dist/assets/{php-DL6_wW6F.js => php-BFLB4U3G.js} (99%) rename internal/webui/dist/assets/{pug-B7WQo0-U.js => pug-Bn5FSvNL.js} (99%) rename internal/webui/dist/assets/{qml-B5BkCXpX.js => qml-Cq_LQYFZ.js} (98%) rename internal/webui/dist/assets/{r-BGaz7xct.js => r-B8i3h8Vf.js} (99%) rename internal/webui/dist/assets/{razor-CmQ-hjlp.js => razor-gUbRriUn.js} (99%) rename internal/webui/dist/assets/{regexp-BWG5eBOH.js => regexp-CMlLGWLU.js} (99%) rename internal/webui/dist/assets/{rst-Dxn-ZQuo.js => rst-XC88BZI_.js} (96%) rename internal/webui/dist/assets/{ruby-Ck7B4y7r.js => ruby-B7jeIc6u.js} (98%) rename internal/webui/dist/assets/{sas-D1cwmWHe.js => sas-4QGeF8AD.js} (99%) rename internal/webui/dist/assets/{scss-CeQ2Gqjd.js => scss-CG6VLMQX.js} (99%) rename internal/webui/dist/assets/{shellscript-BHOqvJta.js => shellscript-B7ilZQuP.js} (99%) rename internal/webui/dist/assets/{shellsession-DHBoGRUo.js => shellsession-7Q0qamCA.js} (91%) rename internal/webui/dist/assets/{soy-DL9RUNF4.js => soy-BLRhC5O8.js} (99%) rename internal/webui/dist/assets/{sql-BexbxqKg.js => sql-biAvtprx.js} (99%) rename internal/webui/dist/assets/{stata-Cns2CxcY.js => stata-BG_pRUJm.js} (99%) rename internal/webui/dist/assets/{surrealql-CAXRB2z0.js => surrealql-C2Unt0DY.js} (99%) rename internal/webui/dist/assets/{svelte-B0pZrBUZ.js => svelte-CN38kTkB.js} (99%) rename internal/webui/dist/assets/{templ-BMT0SIeg.js => templ-DNz9bhdP.js} (99%) rename internal/webui/dist/assets/{tex-DrwTw1EC.js => tex-BBr_tpVE.js} (98%) rename internal/webui/dist/assets/{ts-tags-DZfR8O4m.js => ts-tags-D_p9fDF_.js} (98%) rename internal/webui/dist/assets/{tsx-D5cZNqTM.js => tsx-D1QJfNmc.js} (99%) rename internal/webui/dist/assets/{twig-B2cKhkqv.js => twig-VcJwhz2F.js} (98%) rename internal/webui/dist/assets/{typescript-BjcJ55ux.js => typescript-C4S5rCN3.js} (99%) rename internal/webui/dist/assets/{typst-NdYIdykf.js => typst-BpNEUcgh.js} (99%) rename internal/webui/dist/assets/{vue-utf5ZU-G.js => vue-DRbaVyqi.js} (98%) rename internal/webui/dist/assets/{vue-html-BGPcFp0z.js => vue-html-Boq9TSBA.js} (99%) rename internal/webui/dist/assets/{vue-vine-DBIBiGEc.js => vue-vine-uSxiIelG.js} (99%) rename internal/webui/dist/assets/{xml-DQTrsaMi.js => xml-ZcrblCqP.js} (97%) rename internal/webui/dist/assets/{xsl-BtLSLbaD.js => xsl-Cr4wQbx2.js} (95%) rename internal/webui/dist/assets/{yaml-qCnTQiL-.js => yaml-AGjLRxD5.js} (99%) diff --git a/apps/web/src/ProjectsNav.tsx b/apps/web/src/ProjectsNav.tsx index 2dd4df38..1ce8b9bc 100644 --- a/apps/web/src/ProjectsNav.tsx +++ b/apps/web/src/ProjectsNav.tsx @@ -30,7 +30,7 @@ import { type SeenStreams, } from "./project-nav.ts"; import { useProjectsNavState } from "./projects-nav-context.tsx"; -import { contextPath, useCatalog, useSession } from "./session-context.tsx"; +import { streamPath, useCatalog, useSession } from "./session-context.tsx"; import { useSidebarState } from "./sidebar-context.tsx"; const shortcut = navigator.platform.includes("Mac") ? "⌘K" : "Ctrl K"; @@ -116,7 +116,7 @@ function useSeenStreams(heads: ApiContext[], loaded: boolean) { export function ProjectsNav() { const projects = useProjectsNavState(); const sidebar = useSidebarState(); - const { heads, current, select, openPicker } = useCatalog(); + const { heads, current, openStream, openPicker } = useCatalog(); const seen = useSeenStreams(heads.contexts, heads.loaded); const [closed, setClosed] = useState(readCollapsedGroups); const [query, setQuery] = useState(""); @@ -138,11 +138,11 @@ export function ProjectsNav() { if (!next.delete(id)) next.add(id); return next; }); - const open = (event: MouseEvent, id: string) => { + const open = (event: MouseEvent, head: ApiContext) => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; event.preventDefault(); - select(id, { restoreFocus: false }); + openStream(head, { restoreFocus: false }); projects.closeMobile(); }; return ( @@ -262,7 +262,7 @@ function ProjectItem({ onToggle: () => void; currentStream: string; now: number; - onOpen: (event: MouseEvent, id: string) => void; + onOpen: (event: MouseEvent, head: ApiContext) => void; }) { const unseen = group.streams.some((stream) => stream.unseen); const Icon = group.kind === "piped" ? SquareTerminalIcon : FolderGit2Icon; @@ -294,10 +294,10 @@ function ProjectItem({
  • onOpen(event, context.id)} + onClick={(event) => onOpen(event, context)} > {!contextIsPiped(context) && ( @@ -368,7 +368,7 @@ function ProjectsEmpty({ /** Announces a newer head for the viewed stream without replacing the review. */ export function NewerSnapshotNotice() { const session = useSession(); - const { heads, select } = useCatalog(); + const { heads, openStream } = useCatalog(); const newer = newerHead(session, heads.contexts); const now = useNow(); return ( @@ -384,7 +384,7 @@ export function NewerSnapshotNotice() { type="button" variant="outline-muted" size="sm" - onClick={() => select(newer.id, { restoreFocus: false })} + onClick={() => openStream(newer, { restoreFocus: false })} > View latest diff --git a/apps/web/src/app-state.tsx b/apps/web/src/app-state.tsx index 1fc6903d..3993b754 100644 --- a/apps/web/src/app-state.tsx +++ b/apps/web/src/app-state.tsx @@ -11,6 +11,7 @@ import { type SetStateAction, } from "react"; import { saveSessionPaths, sessionPaths } from "./preferences.ts"; +import { streamKey } from "./project-nav.ts"; import { capabilityEnabled, useCatalog, @@ -56,11 +57,14 @@ const CollapseContext = createContext<{ setCollapsed: Dispatch>>; } | null>(null); -const collapsedKey = (contextId: string, mode: DiffMode) => - `collapsed-files:${contextId}:${mode}`; +// Collapse state belongs to the stream so it survives newer snapshots. +const collapsedKey = (stream: string, mode: DiffMode) => + `collapsed-files:${stream}:${mode}`; function WorkspaceProvider({ children }: { children: ReactNode }) { - const { id: contextId, capabilities } = useSession(); + const session = useSession(); + const { id: contextId, capabilities } = session; + const stream = streamKey(session); const { collect } = useCatalog(); const refreshEnabled = collect !== null || capabilityEnabled(capabilities.diff.refresh); @@ -69,11 +73,11 @@ function WorkspaceProvider({ children }: { children: ReactNode }) { const [mode, setMode] = useState("all"); const [draft, setDraft] = useState(null); const [collapsed, setCollapsed] = useState(() => - sessionPaths(collapsedKey(contextId, "all")), + sessionPaths(collapsedKey(stream, "all")), ); useEffect(() => { - saveSessionPaths(collapsedKey(contextId, mode), collapsed); - }, [contextId, mode, collapsed]); + saveSessionPaths(collapsedKey(stream, mode), collapsed); + }, [stream, mode, collapsed]); const diff = useDiff(contextId, mode); const repository = diff.repository; const review = useReview( @@ -98,12 +102,12 @@ function WorkspaceProvider({ children }: { children: ReactNode }) { (value: DiffMode) => { if (value === mode || !scopes.includes(value)) return; setMode(value); - setCollapsed(sessionPaths(collapsedKey(contextId, value))); + setCollapsed(sessionPaths(collapsedKey(stream, value))); }, - [contextId, mode, scopes], + [stream, mode, scopes], ); const navigation = useFileNavigation({ - contextId, + stream, repository, mode, scopes, diff --git a/apps/web/src/session-context.tsx b/apps/web/src/session-context.tsx index 8eca592a..84dc6a15 100644 --- a/apps/web/src/session-context.tsx +++ b/apps/web/src/session-context.tsx @@ -33,7 +33,10 @@ interface Catalog { /** The selected review once loaded, otherwise its catalog entry if known. */ current: ApiContext | undefined; selectedId: string; + /** Pins one snapshot. */ select: (id: string, options?: { restoreFocus?: boolean }) => void; + /** Follows a stream's latest head; piped snapshots stay pinned. */ + openStream: (head: ApiContext, options?: { restoreFocus?: boolean }) => void; deleteContext: (id: string) => Promise; pickerOpen: boolean; openPicker: () => void; @@ -49,8 +52,19 @@ export function capabilityEnabled(capability: { state: string }) { return capability.state === "enabled"; } -function contextFromUrl() { - const match = /^\/contexts\/([^/]+)\/?$/.exec(window.location.pathname); +/** + * `/contexts/{id}` pins one snapshot. `/streams/{id}` follows a stream's latest + * head, resolved on load and navigation only. `/` resolves to a live stream. + */ +type Route = + | { kind: "pinned"; id: string } + | { kind: "live"; stream: string } + | { kind: "root" }; + +function routeValue(prefix: string) { + const match = new RegExp(`^/${prefix}/([^/]+)/?$`).exec( + window.location.pathname, + ); if (!match?.[1]) return ""; try { return decodeURIComponent(match[1]); @@ -59,10 +73,23 @@ function contextFromUrl() { } } +function routeFromUrl(): Route { + const id = routeValue("contexts"); + if (id) return { kind: "pinned", id }; + const stream = routeValue("streams"); + return stream ? { kind: "live", stream } : { kind: "root" }; +} + export function contextPath(id: string) { return `/contexts/${encodeURIComponent(id)}`; } +export function streamPath(head: ApiContext) { + return head.streamId + ? `/streams/${encodeURIComponent(head.streamId)}` + : contextPath(head.id); +} + export function CurrentContext() { const catalog = useContext(CatalogContext); return catalog?.current ? ( @@ -78,7 +105,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { identity.status === "ready" && identity.bootstrap.mode === "local" && identity.bootstrap.collect; - const [selectedId, setSelectedId] = useState(contextFromUrl); + const [route, setRoute] = useState(routeFromUrl); + const [selectedId, setSelectedId] = useState(() => + route.kind === "pinned" ? route.id : "", + ); + // A live route without a current head (expired, deleted, or unknown). + const [missing, setMissing] = useState(false); const [state, setState] = useState({ status: "loading" }); const [attempt, setAttempt] = useState(0); const [pickerOpen, setPickerOpen] = useState(false); @@ -88,26 +120,56 @@ export function SessionProvider({ children }: { children: ReactNode }) { const [collecting, setCollecting] = useState(false); const [collectError, setCollectError] = useState(""); const collectingRef = useRef(false); + const selectedRef = useRef(selectedId); + useEffect(() => { + selectedRef.current = selectedId; + }, [selectedId]); const statusRef = useRef(null); const restorePickerFocus = useRef(false); const openPicker = useCallback(() => setPickerOpen(true), []); - const select = useCallback( - (id: string, options?: { restoreFocus?: boolean }) => { - if (id === contextFromUrl()) { - window.history.replaceState(null, "", contextPath(id)); - return; - } + const show = useCallback( + ( + next: Route, + path: string, + id: string, + options?: { restoreFocus?: boolean; replace?: boolean }, + ) => { + if (options?.replace || path === window.location.pathname) + window.history.replaceState(null, "", path); + else window.history.pushState(null, "", path); + setRoute(next); + setMissing(false); + if (id === selectedRef.current) return; restorePickerFocus.current = options?.restoreFocus ?? true; - window.history.pushState(null, "", contextPath(id)); setSelectedId(id); setState({ status: "loading" }); }, [], ); + const select = useCallback( + (id: string, options?: { restoreFocus?: boolean }) => + show({ kind: "pinned", id }, contextPath(id), id, options), + [show], + ); + const openStream = useCallback( + ( + head: ApiContext, + options?: { restoreFocus?: boolean; replace?: boolean }, + ) => + show( + head.streamId + ? { kind: "live", stream: head.streamId } + : { kind: "pinned", id: head.id }, + streamPath(head), + head.id, + options, + ), + [show], + ); const { remove: removeHead, retry: reloadHeads } = heads; - // An explicit request may replace the open review; arrivals never do. - const collectCheckout = useCallback(async () => { - if (collectingRef.current) return "failed" as const; + // Collects the local checkout; null when it fails or one is running. + const collectLatest = useCallback(async () => { + if (collectingRef.current) return null; collectingRef.current = true; setCollecting(true); setCollectError(""); @@ -116,17 +178,23 @@ export function SessionProvider({ children }: { children: ReactNode }) { if (!data) throw new Error(errorDetail(error, "Unable to refresh changes")); reloadHeads(); - if (data.id === contextFromUrl()) return "unchanged" as const; - select(data.id, { restoreFocus: false }); - return "changed" as const; + return data; } catch (error) { setCollectError(errorDetail(error, "Unable to refresh changes")); - return "failed" as const; + return null; } finally { collectingRef.current = false; setCollecting(false); } - }, [reloadHeads, select]); + }, [reloadHeads]); + // An explicit request may replace the open review; arrivals never do. + const collectCheckout = useCallback(async () => { + const latest = await collectLatest(); + if (!latest) return "failed" as const; + const unchanged = latest.id === selectedRef.current; + openStream(latest, { restoreFocus: false }); + return unchanged ? ("unchanged" as const) : ("changed" as const); + }, [collectLatest, openStream]); const collect = collectEnabled ? collectCheckout : null; const { remove: removeHistory } = history; const deleteContext = useCallback( @@ -143,39 +211,68 @@ export function SessionProvider({ children }: { children: ReactNode }) { ); removeHead(id); removeHistory(id); - if (contextFromUrl() === id) { + if (selectedRef.current === id) { const next = heads.contexts.find( (context) => context.id !== id && contextHasChanges(context), ); - restorePickerFocus.current = true; - window.history.replaceState( - null, - "", - next ? contextPath(next.id) : "/", - ); - setSelectedId(next?.id ?? ""); - setState({ status: "loading" }); + if (next) openStream(next, { replace: true }); + else { + restorePickerFocus.current = true; + window.history.replaceState(null, "", "/"); + setRoute({ kind: "root" }); + setSelectedId(""); + setState({ status: "loading" }); + } } reloadHeads(); }, - [heads.contexts, removeHead, removeHistory, reloadHeads], + [heads.contexts, removeHead, removeHistory, reloadHeads, openStream], ); useEffect(() => { const handleNavigation = () => { - setSelectedId(contextFromUrl()); + const next = routeFromUrl(); + setRoute(next); + setMissing(false); + setSelectedId(next.kind === "pinned" ? next.id : ""); setState({ status: "loading" }); }; window.addEventListener("popstate", handleNavigation); return () => window.removeEventListener("popstate", handleNavigation); }, []); - // The root URL opens the most recently submitted current work. + // Live routes resolve once per load or navigation. Local checkouts collect + // first; otherwise the stream's ingested head (or the newest changed head + // for the root URL) opens. Later arrivals only raise the newer-head notice. + // The route whose load-time collection started, and one whose failed. + const [collected, setCollected] = useState(null); + const [uncollected, setUncollected] = useState(null); + const resolving = route.kind !== "pinned" && !selectedId && !missing; + const collectOnLoad = resolving && collectEnabled && uncollected !== route; + useEffect(() => { + if (!collectOnLoad || collected === route) return; + setCollected(route); + void collectLatest().then((latest) => { + if (latest) openStream(latest, { replace: true, restoreFocus: false }); + else setUncollected(route); + }); + }, [collectOnLoad, collected, route, collectLatest, openStream]); useEffect(() => { - if (contextFromUrl()) return; - const first = heads.contexts.find(contextHasChanges); - if (!first) return; - window.history.replaceState(null, "", contextPath(first.id)); - setSelectedId(first.id); - }, [heads.contexts]); + if (!resolving || !heads.loaded) return; + if (collectEnabled && uncollected !== route) return; + const head = + route.kind === "live" + ? heads.contexts.find((context) => context.streamId === route.stream) + : heads.contexts.find(contextHasChanges); + if (head) openStream(head, { replace: true, restoreFocus: false }); + else if (route.kind === "live") setMissing(true); + }, [ + resolving, + heads.loaded, + heads.contexts, + collectEnabled, + uncollected, + route, + openStream, + ]); useEffect(() => { if (!selectedId) return; const controller = new AbortController(); @@ -213,6 +310,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { current, selectedId, select, + openStream, deleteContext, pickerOpen, openPicker, @@ -225,6 +323,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { current, selectedId, select, + openStream, deleteContext, pickerOpen, openPicker, @@ -233,7 +332,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { collectError, ], ); - const empty = heads.loaded && !selectedId; + // The root URL keeps waiting for changed work while showing the empty state. + const empty = + heads.loaded && !selectedId && route.kind === "root" && !collectOnLoad; useEffect(() => { if (!restorePickerFocus.current || (!empty && state.status === "loading")) return; @@ -271,22 +372,32 @@ export function SessionProvider({ children }: { children: ReactNode }) { tabIndex={-1} >

    - {empty - ? heads.contexts.length - ? "No changes to review" - : "No review contexts yet" - : state.status === "error" || heads.error - ? "Cannot load diffx" - : "Loading diffx"} + {missing + ? "No current snapshot" + : empty + ? heads.contexts.length + ? "No changes to review" + : "No review contexts yet" + : state.status === "error" || heads.error + ? "Cannot load diffx" + : collectOnLoad + ? "Collecting latest changes" + : "Loading diffx"}

    - {empty - ? remote - ? "Create a collector token in settings, then run diffx sync to publish your changes." - : "Run diffx to review a checkout, or diffx sync to publish remotely." - : state.status === "error" - ? state.detail - : heads.error || "Reading review context…"} + {missing + ? collectError || + "This stream's snapshots expired or were deleted. Pick current work from Projects." + : empty + ? remote + ? "Create a collector token in settings, then run diffx sync to publish your changes." + : "Run diffx to review a checkout, or diffx sync to publish remotely." + : state.status === "error" + ? state.detail + : heads.error || + (collectOnLoad + ? "Reading the checkout for new changes…" + : "Reading review context…")}

    {empty && remote && (