| author | |
| committer | |
| log | 1963ca0abe07f9b9aac67820bca45ea3b46ce023 |
| tree | db1795f1a99ce6569e33512997c93b13185c0f61 |
| parent | 4d3907f63d71644e1918d76cd104b036535a1173 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>7 files changed, 252 insertions(+), 12 deletions(-)
apps/web/src/components/Sidebar.tsx+16-4| ... | @@ -111,7 +111,11 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | ... | @@ -111,7 +111,11 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 111 | import { useLocalStorage } from "../hooks/useLocalStorage"; | 111 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 112 | import { useNowMinute } from "../hooks/useNowMinute"; | 112 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 113 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 113 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 114 | import { useProjects, useThreadShells } from "../state/entities"; | 114 | import { |
| 115 | useAllEnvironmentProjectSnapshotsReady, | ||
| 116 | useProjects, | ||
| 117 | useThreadShells, | ||
| 118 | } from "../state/entities"; | ||
| 115 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; | 119 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; |
| 116 | import { vcsEnvironment } from "../state/vcs"; | 120 | import { vcsEnvironment } from "../state/vcs"; |
| 117 | import { threadEnvironment } from "../state/threads"; | 121 | import { threadEnvironment } from "../state/threads"; |
| ... | @@ -2098,7 +2102,11 @@ export default function Sidebar() { | ... | @@ -2098,7 +2102,11 @@ export default function Sidebar() { |
| 2098 | 2102 | ||
| 2099 | // Project scope: one menu above the list. Scoping filters the list without | 2103 | // Project scope: one menu above the list. Scoping filters the list without |
| 2100 | // making the header width depend on the number or length of project names. | 2104 | // making the header width depend on the number or length of project names. |
| 2101 | const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null); | 2105 | // The selection lives in the persisted UI store next to the other sidebar |
| 2106 | // project preferences, so routes that unmount the sidebar (Settings) and | ||
| 2107 | // app restarts keep it. | ||
| 2108 | const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); | ||
| 2109 | const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); | ||
| 2102 | // {value, label} items let Base UI drive the combobox selection contract | 2110 | // {value, label} items let Base UI drive the combobox selection contract |
| 2103 | // while the popup search filters the same collection. | 2111 | // while the popup search filters the same collection. |
| 2104 | const projectScopeItems = useMemo( | 2112 | const projectScopeItems = useMemo( |
| ... | @@ -2162,11 +2170,15 @@ export default function Sidebar() { | ... | @@ -2162,11 +2170,15 @@ export default function Sidebar() { |
| 2162 | ), | 2170 | ), |
| 2163 | [scopedProjectGroup], | 2171 | [scopedProjectGroup], |
| 2164 | ); | 2172 | ); |
| 2173 | // A persisted scope whose project is gone falls back to all projects, but | ||
| 2174 | // only after every catalog environment has a live project snapshot. Cached | ||
| 2175 | // or disconnected environments cannot establish that the project is gone. | ||
| 2176 | const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); | ||
| 2165 | useEffect(() => { | 2177 | useEffect(() => { |
| 2166 | if (projectScopeKey !== null && scopedProjectGroup === null) { | 2178 | if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) { |
| 2167 | setProjectScopeKey(null); | 2179 | setProjectScopeKey(null); |
| 2168 | } | 2180 | } |
| 2169 | }, [projectScopeKey, scopedProjectGroup]); | 2181 | }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); |
| 2170 | // Count-only subscription: the parent needs "are there draft rows" for the | 2182 | // Count-only subscription: the parent needs "are there draft rows" for the |
| 2171 | // empty state, while SidebarDraftBlock owns the per-keystroke content | 2183 | // empty state, while SidebarDraftBlock owns the per-keystroke content |
| 2172 | // subscription. Selecting a number keeps typing in a draft composer from | 2184 | // subscription. Selecting a number keeps typing in a draft composer from |
apps/web/src/hostedPairing.ts+3-2| ... | @@ -40,8 +40,9 @@ export function isHostedStaticApp(url?: URL): boolean { | ... | @@ -40,8 +40,9 @@ export function isHostedStaticApp(url?: URL): boolean { |
| 40 | return true; | 40 | return true; |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | // No window (tests, static render) means no origin to be hosted at. | 43 | // No window, or a window without a location (tests, static render), means |
| 44 | if (url === undefined && typeof window === "undefined") { | 44 | // no origin to be hosted at. |
| 45 | if (url === undefined && (typeof window === "undefined" || window.location === undefined)) { | ||
| 45 | return false; | 46 | return false; |
| 46 | } | 47 | } |
| 47 | 48 |
apps/web/src/state/entities.ts+8-1| ... | @@ -15,7 +15,10 @@ import { useMemo } from "react"; | ... | @@ -15,7 +15,10 @@ import { useMemo } from "react"; |
| 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 16 | import { environmentProjects } from "./projects"; | 16 | import { environmentProjects } from "./projects"; |
| 17 | import { environmentServerConfigsAtom } from "./server"; | 17 | import { environmentServerConfigsAtom } from "./server"; |
| 18 | import { allEnvironmentShellsBootstrappedAtom } from "./shell"; | 18 | import { |
| 19 | allEnvironmentProjectSnapshotsReadyAtom, | ||
| 20 | allEnvironmentShellsBootstrappedAtom, | ||
| 21 | } from "./shell"; | ||
| 19 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; | 22 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; |
| 20 | 23 | ||
| 21 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); | 24 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); |
| ... | @@ -79,6 +82,10 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { | ... | @@ -79,6 +82,10 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { |
| 79 | return useAtomValue(allEnvironmentShellsBootstrappedAtom); | 82 | return useAtomValue(allEnvironmentShellsBootstrappedAtom); |
| 80 | } | 83 | } |
| 81 | 84 | ||
| 85 | export function useAllEnvironmentProjectSnapshotsReady(): boolean { | ||
| 86 | return useAtomValue(allEnvironmentProjectSnapshotsReadyAtom); | ||
| 87 | } | ||
| 88 | |||
| 82 | export function useThreadShellsForProjectRefs( | 89 | export function useThreadShellsForProjectRefs( |
| 83 | refs: ReadonlyArray<ScopedProjectRef>, | 90 | refs: ReadonlyArray<ScopedProjectRef>, |
| 84 | ): ReadonlyArray<EnvironmentThreadShell> { | 91 | ): ReadonlyArray<EnvironmentThreadShell> { |
apps/web/src/state/shell.test.ts created+135| ... | @@ -0,0 +1,135 @@ | ||
| 1 | import { | ||
| 2 | BearerConnectionTarget, | ||
| 3 | PrimaryConnectionTarget, | ||
| 4 | } from "@t3tools/client-runtime/connection"; | ||
| 5 | import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; | ||
| 6 | import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; | ||
| 7 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 10 | import { describe, expect, it } from "vite-plus/test"; | ||
| 11 | |||
| 12 | import { createAllEnvironmentProjectSnapshotsReadyAtom } from "./shell"; | ||
| 13 | |||
| 14 | const LOCAL = EnvironmentId.make("local"); | ||
| 15 | const REMOTE = EnvironmentId.make("remote"); | ||
| 16 | |||
| 17 | function shellState(status: EnvironmentShellState["status"]): EnvironmentShellState { | ||
| 18 | return { | ||
| 19 | status, | ||
| 20 | snapshot: | ||
| 21 | status === "empty" | ||
| 22 | ? Option.none() | ||
| 23 | : Option.some({ | ||
| 24 | snapshotSequence: 1, | ||
| 25 | updatedAt: "2026-09-04T00:00:00.000Z", | ||
| 26 | projects: [], | ||
| 27 | threads: [], | ||
| 28 | }), | ||
| 29 | error: Option.none(), | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCatalogState { | ||
| 34 | return { | ||
| 35 | isReady: true, | ||
| 36 | entries: new Map( | ||
| 37 | environmentIds.map((environmentId) => [ | ||
| 38 | environmentId, | ||
| 39 | { | ||
| 40 | target: | ||
| 41 | environmentId === LOCAL | ||
| 42 | ? new PrimaryConnectionTarget({ | ||
| 43 | environmentId, | ||
| 44 | label: environmentId, | ||
| 45 | httpBaseUrl: `https://${environmentId}.example.test`, | ||
| 46 | wsBaseUrl: `wss://${environmentId}.example.test`, | ||
| 47 | }) | ||
| 48 | : new BearerConnectionTarget({ | ||
| 49 | environmentId, | ||
| 50 | connectionId: environmentId, | ||
| 51 | label: environmentId, | ||
| 52 | }), | ||
| 53 | profile: Option.none(), | ||
| 54 | }, | ||
| 55 | ]), | ||
| 56 | ), | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | function makeHarness(requiresPrimaryEnvironment = true) { | ||
| 61 | const catalog = Atom.make<EnvironmentCatalogState>({ isReady: false, entries: new Map() }); | ||
| 62 | const shells = Atom.family((_environmentId: EnvironmentId) => | ||
| 63 | Atom.make<EnvironmentShellState>(shellState("empty")), | ||
| 64 | ); | ||
| 65 | const ready = createAllEnvironmentProjectSnapshotsReadyAtom({ | ||
| 66 | catalogValueAtom: catalog, | ||
| 67 | shellStateValueAtom: shells, | ||
| 68 | requiresPrimaryEnvironment, | ||
| 69 | }); | ||
| 70 | const registry = AtomRegistry.make(); | ||
| 71 | return { catalog, shells, ready, registry }; | ||
| 72 | } | ||
| 73 | |||
| 74 | describe("project snapshot readiness", () => { | ||
| 75 | it("does not clear a saved scope while the ready catalog is still empty", () => { | ||
| 76 | const { catalog, ready, registry } = makeHarness(); | ||
| 77 | expect(registry.get(ready)).toBe(false); | ||
| 78 | registry.set(catalog, catalogState([])); | ||
| 79 | expect(registry.get(ready)).toBe(false); | ||
| 80 | registry.dispose(); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("waits for primary discovery even if a persisted remote is already live", () => { | ||
| 84 | const { catalog, shells, ready, registry } = makeHarness(); | ||
| 85 | registry.set(catalog, catalogState([REMOTE])); | ||
| 86 | registry.set(shells(REMOTE), shellState("live")); | ||
| 87 | expect(registry.get(ready)).toBe(false); | ||
| 88 | |||
| 89 | registry.set(catalog, catalogState([LOCAL, REMOTE])); | ||
| 90 | expect(registry.get(ready)).toBe(false); | ||
| 91 | registry.set(shells(LOCAL), shellState("live")); | ||
| 92 | expect(registry.get(ready)).toBe(true); | ||
| 93 | registry.dispose(); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("allows a hosted client to load projects without a primary environment", () => { | ||
| 97 | const { catalog, shells, ready, registry } = makeHarness(false); | ||
| 98 | registry.set(catalog, catalogState([REMOTE])); | ||
| 99 | expect(registry.get(ready)).toBe(false); | ||
| 100 | registry.set(shells(REMOTE), shellState("live")); | ||
| 101 | expect(registry.get(ready)).toBe(true); | ||
| 102 | registry.dispose(); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("waits for live snapshots through offline startup and reconnect", () => { | ||
| 106 | const { catalog, shells, ready, registry } = makeHarness(); | ||
| 107 | registry.set(catalog, catalogState([LOCAL, REMOTE])); | ||
| 108 | registry.set(shells(LOCAL), shellState("live")); | ||
| 109 | expect(registry.get(ready)).toBe(false); | ||
| 110 | |||
| 111 | // An old cache and a reconnect in progress can both omit a real project. | ||
| 112 | registry.set(shells(REMOTE), shellState("cached")); | ||
| 113 | expect(registry.get(ready)).toBe(false); | ||
| 114 | registry.set(shells(REMOTE), shellState("synchronizing")); | ||
| 115 | expect(registry.get(ready)).toBe(false); | ||
| 116 | registry.set(shells(REMOTE), shellState("live")); | ||
| 117 | expect(registry.get(ready)).toBe(true); | ||
| 118 | |||
| 119 | registry.set(shells(REMOTE), shellState("cached")); | ||
| 120 | expect(registry.get(ready)).toBe(false); | ||
| 121 | registry.set(shells(REMOTE), shellState("live")); | ||
| 122 | expect(registry.get(ready)).toBe(true); | ||
| 123 | registry.dispose(); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it("stops waiting for an environment only when it leaves the catalog", () => { | ||
| 127 | const { catalog, shells, ready, registry } = makeHarness(); | ||
| 128 | registry.set(catalog, catalogState([LOCAL, REMOTE])); | ||
| 129 | registry.set(shells(LOCAL), shellState("live")); | ||
| 130 | expect(registry.get(ready)).toBe(false); | ||
| 131 | registry.set(catalog, catalogState([LOCAL])); | ||
| 132 | expect(registry.get(ready)).toBe(true); | ||
| 133 | registry.dispose(); | ||
| 134 | }); | ||
| 135 | }); | ||
apps/web/src/state/shell.ts+38| ... | @@ -7,12 +7,16 @@ import { | ... | @@ -7,12 +7,16 @@ import { |
| 7 | createEnvironmentShellSummaryAtom, | 7 | createEnvironmentShellSummaryAtom, |
| 8 | createEnvironmentSnapshotAtom, | 8 | createEnvironmentSnapshotAtom, |
| 9 | createShellEnvironmentAtoms, | 9 | createShellEnvironmentAtoms, |
| 10 | type EnvironmentShellState, | ||
| 10 | } from "@t3tools/client-runtime/state/shell"; | 11 | } from "@t3tools/client-runtime/state/shell"; |
| 12 | import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; | ||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 11 | import * as Option from "effect/Option"; | 14 | import * as Option from "effect/Option"; |
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 13 | 16 | ||
| 14 | import { environmentCatalog } from "../connection/catalog"; | 17 | import { environmentCatalog } from "../connection/catalog"; |
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { isHostedStaticApp } from "../hostedPairing"; | ||
| 16 | 20 | ||
| 17 | export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); | 21 | export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); |
| 18 | export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); | 22 | export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); |
| ... | @@ -46,3 +50,37 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { | ... | @@ -46,3 +50,37 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { |
| 46 | } | 50 | } |
| 47 | return true; | 51 | return true; |
| 48 | }).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped")); | 52 | }).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped")); |
| 53 | |||
| 54 | /** Cached or missing snapshots cannot establish that a saved project no longer exists. */ | ||
| 55 | export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { | ||
| 56 | readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>; | ||
| 57 | readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom<EnvironmentShellState>; | ||
| 58 | readonly requiresPrimaryEnvironment: boolean; | ||
| 59 | }) { | ||
| 60 | return Atom.make((get) => { | ||
| 61 | const catalog = get(input.catalogValueAtom); | ||
| 62 | // The persisted catalog can emit before platform discovery registers the | ||
| 63 | // primary environment. Neither that gap nor an empty catalog proves absence. | ||
| 64 | if (!catalog.isReady || catalog.entries.size === 0) return false; | ||
| 65 | if ( | ||
| 66 | input.requiresPrimaryEnvironment && | ||
| 67 | !Array.from(catalog.entries.values()).some( | ||
| 68 | (entry) => entry.target._tag === "PrimaryConnectionTarget", | ||
| 69 | ) | ||
| 70 | ) { | ||
| 71 | return false; | ||
| 72 | } | ||
| 73 | for (const environmentId of catalog.entries.keys()) { | ||
| 74 | const shell = get(input.shellStateValueAtom(environmentId)); | ||
| 75 | if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false; | ||
| 76 | } | ||
| 77 | return true; | ||
| 78 | }).pipe(Atom.withLabel("web-all-environment-project-snapshots-ready")); | ||
| 79 | } | ||
| 80 | |||
| 81 | export const allEnvironmentProjectSnapshotsReadyAtom = | ||
| 82 | createAllEnvironmentProjectSnapshotsReadyAtom({ | ||
| 83 | catalogValueAtom: environmentCatalog.catalogValueAtom, | ||
| 84 | shellStateValueAtom: environmentShell.stateValueAtom, | ||
| 85 | requiresPrimaryEnvironment: !isHostedStaticApp(), | ||
| 86 | }); |
apps/web/src/uiStateStore.test.ts+25| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | resolveProjectExpanded, | 13 | resolveProjectExpanded, |
| 14 | setDefaultAdvertisedEndpointKey, | 14 | setDefaultAdvertisedEndpointKey, |
| 15 | setProjectExpanded, | 15 | setProjectExpanded, |
| 16 | setSidebarProjectScopeKey, | ||
| 16 | setThreadChangedFilesExpanded, | 17 | setThreadChangedFilesExpanded, |
| 17 | type UiState, | 18 | type UiState, |
| 18 | } from "./uiStateStore"; | 19 | } from "./uiStateStore"; |
| ... | @@ -21,6 +22,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { | ... | @@ -21,6 +22,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { |
| 21 | return { | 22 | return { |
| 22 | projectExpandedById: {}, | 23 | projectExpandedById: {}, |
| 23 | projectOrder: [], | 24 | projectOrder: [], |
| 25 | sidebarProjectScopeKey: null, | ||
| 24 | threadLastVisitedAtById: {}, | 26 | threadLastVisitedAtById: {}, |
| 25 | threadChangedFilesExpandedById: {}, | 27 | threadChangedFilesExpandedById: {}, |
| 26 | defaultAdvertisedEndpointKey: null, | 28 | defaultAdvertisedEndpointKey: null, |
| ... | @@ -144,6 +146,15 @@ describe("uiStateStore pure functions", () => { | ... | @@ -144,6 +146,15 @@ describe("uiStateStore pure functions", () => { |
| 144 | defaultAdvertisedEndpointKey: null, | 146 | defaultAdvertisedEndpointKey: null, |
| 145 | }); | 147 | }); |
| 146 | }); | 148 | }); |
| 149 | |||
| 150 | it("stores the sidebar project scope and resets it to all projects", () => { | ||
| 151 | const scoped = setSidebarProjectScopeKey(makeUiState(), "github.com/pingdotgg/t3code"); | ||
| 152 | |||
| 153 | expect(scoped.sidebarProjectScopeKey).toBe("github.com/pingdotgg/t3code"); | ||
| 154 | expect(setSidebarProjectScopeKey(scoped, "github.com/pingdotgg/t3code")).toBe(scoped); | ||
| 155 | expect(setSidebarProjectScopeKey(scoped, null).sidebarProjectScopeKey).toBeNull(); | ||
| 156 | expect(setSidebarProjectScopeKey(scoped, "").sidebarProjectScopeKey).toBeNull(); | ||
| 157 | }); | ||
| 147 | }); | 158 | }); |
| 148 | 159 | ||
| 149 | describe("parsePersistedState", () => { | 160 | describe("parsePersistedState", () => { |
| ... | @@ -177,6 +188,7 @@ describe("parsePersistedState", () => { | ... | @@ -177,6 +188,7 @@ describe("parsePersistedState", () => { |
| 177 | "environment:thread-1": "2026-02-25T12:35:00.000Z", | 188 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 178 | }, | 189 | }, |
| 179 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | 190 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 191 | sidebarProjectScopeKey: null, | ||
| 180 | threadChangedFilesExpandedById: { | 192 | threadChangedFilesExpandedById: { |
| 181 | "environment:thread-1": { | 193 | "environment:thread-1": { |
| 182 | "turn-1": false, | 194 | "turn-1": false, |
| ... | @@ -297,6 +309,7 @@ describe("uiStateStore persistence", () => { | ... | @@ -297,6 +309,7 @@ describe("uiStateStore persistence", () => { |
| 297 | "environment:thread-1": "2026-02-25T12:35:00.000Z", | 309 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 298 | }, | 310 | }, |
| 299 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | 311 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 312 | sidebarProjectScopeKey: null, | ||
| 300 | threadChangedFilesExpansionVersion: 2, | 313 | threadChangedFilesExpansionVersion: 2, |
| 301 | threadChangedFilesExpandedById: { | 314 | threadChangedFilesExpandedById: { |
| 302 | "environment:thread-1": { | 315 | "environment:thread-1": { |
| ... | @@ -310,6 +323,18 @@ describe("uiStateStore persistence", () => { | ... | @@ -310,6 +323,18 @@ describe("uiStateStore persistence", () => { |
| 310 | }); | 323 | }); |
| 311 | }); | 324 | }); |
| 312 | 325 | ||
| 326 | it("restores the sidebar project scope across reloads", () => { | ||
| 327 | persistState(makeUiState({ sidebarProjectScopeKey: "github.com/pingdotgg/t3code" })); | ||
| 328 | |||
| 329 | const persisted = JSON.parse( | ||
| 330 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", | ||
| 331 | ) as PersistedUiState; | ||
| 332 | |||
| 333 | expect(parsePersistedState(persisted).sidebarProjectScopeKey).toBe( | ||
| 334 | "github.com/pingdotgg/t3code", | ||
| 335 | ); | ||
| 336 | }); | ||
| 337 | |||
| 313 | it("drops the temporary expanded-only migration fallback when rewriting state", () => { | 338 | it("drops the temporary expanded-only migration fallback when rewriting state", () => { |
| 314 | const migrated = parsePersistedState({ | 339 | const migrated = parsePersistedState({ |
| 315 | expandedProjectCwds: ["/repo/a"], | 340 | expandedProjectCwds: ["/repo/a"], |
apps/web/src/uiStateStore.ts+27-5| ... | @@ -26,6 +26,7 @@ export interface PersistedUiState { | ... | @@ -26,6 +26,7 @@ export interface PersistedUiState { |
| 26 | expandedProjectCwds?: string[]; | 26 | expandedProjectCwds?: string[]; |
| 27 | projectOrderCwds?: string[]; | 27 | projectOrderCwds?: string[]; |
| 28 | defaultAdvertisedEndpointKey?: string | null; | 28 | defaultAdvertisedEndpointKey?: string | null; |
| 29 | sidebarProjectScopeKey?: string | null; | ||
| 29 | threadChangedFilesExpansionVersion?: number; | 30 | threadChangedFilesExpansionVersion?: number; |
| 30 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; | 31 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 31 | } | 32 | } |
| ... | @@ -33,6 +34,10 @@ export interface PersistedUiState { | ... | @@ -33,6 +34,10 @@ export interface PersistedUiState { |
| 33 | export interface UiProjectState { | 34 | export interface UiProjectState { |
| 34 | projectExpandedById: Record<string, boolean>; | 35 | projectExpandedById: Record<string, boolean>; |
| 35 | projectOrder: string[]; | 36 | projectOrder: string[]; |
| 37 | // Logical project key the sidebar list is scoped to, or null for "all | ||
| 38 | // projects". Lives here so routes that unmount the sidebar (Settings) | ||
| 39 | // cannot reset the filter. | ||
| 40 | sidebarProjectScopeKey: string | null; | ||
| 36 | } | 41 | } |
| 37 | 42 | ||
| 38 | export interface UiThreadState { | 43 | export interface UiThreadState { |
| ... | @@ -49,6 +54,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState | ... | @@ -49,6 +54,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState |
| 49 | const initialState: UiState = { | 54 | const initialState: UiState = { |
| 50 | projectExpandedById: {}, | 55 | projectExpandedById: {}, |
| 51 | projectOrder: [], | 56 | projectOrder: [], |
| 57 | sidebarProjectScopeKey: null, | ||
| 52 | threadLastVisitedAtById: {}, | 58 | threadLastVisitedAtById: {}, |
| 53 | threadChangedFilesExpandedById: {}, | 59 | threadChangedFilesExpandedById: {}, |
| 54 | defaultAdvertisedEndpointKey: null, | 60 | defaultAdvertisedEndpointKey: null, |
| ... | @@ -84,6 +90,10 @@ function sanitizeBooleanRecord(value: unknown): Record<string, boolean> { | ... | @@ -84,6 +90,10 @@ function sanitizeBooleanRecord(value: unknown): Record<string, boolean> { |
| 84 | ); | 90 | ); |
| 85 | } | 91 | } |
| 86 | 92 | ||
| 93 | function sanitizeOptionalKey(value: unknown): string | null { | ||
| 94 | return typeof value === "string" && value.length > 0 ? value : null; | ||
| 95 | } | ||
| 96 | |||
| 87 | function sanitizeTimestampRecord(value: unknown): Record<string, string> { | 97 | function sanitizeTimestampRecord(value: unknown): Record<string, string> { |
| 88 | if (!value || typeof value !== "object") { | 98 | if (!value || typeof value !== "object") { |
| 89 | return {}; | 99 | return {}; |
| ... | @@ -131,11 +141,8 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { | ... | @@ -131,11 +141,8 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 131 | parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION | 141 | parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION |
| 132 | ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) | 142 | ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) |
| 133 | : {}, | 143 | : {}, |
| 134 | defaultAdvertisedEndpointKey: | 144 | defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), |
| 135 | typeof parsed.defaultAdvertisedEndpointKey === "string" && | 145 | sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey), |
| 136 | parsed.defaultAdvertisedEndpointKey.length > 0 | ||
| 137 | ? parsed.defaultAdvertisedEndpointKey | ||
| 138 | : null, | ||
| 139 | }; | 146 | }; |
| 140 | } | 147 | } |
| 141 | 148 | ||
| ... | @@ -206,6 +213,7 @@ export function persistState(state: UiState): void { | ... | @@ -206,6 +213,7 @@ export function persistState(state: UiState): void { |
| 206 | projectOrder: state.projectOrder, | 213 | projectOrder: state.projectOrder, |
| 207 | threadLastVisitedAtById: state.threadLastVisitedAtById, | 214 | threadLastVisitedAtById: state.threadLastVisitedAtById, |
| 208 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, | 215 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, |
| 216 | sidebarProjectScopeKey: state.sidebarProjectScopeKey, | ||
| 209 | threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, | 217 | threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, |
| 210 | threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, | 218 | threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, |
| 211 | } satisfies PersistedUiState), | 219 | } satisfies PersistedUiState), |
| ... | @@ -305,6 +313,17 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu | ... | @@ -305,6 +313,17 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu |
| 305 | }; | 313 | }; |
| 306 | } | 314 | } |
| 307 | 315 | ||
| 316 | export function setSidebarProjectScopeKey(state: UiState, projectKey: string | null): UiState { | ||
| 317 | const nextKey = sanitizeOptionalKey(projectKey); | ||
| 318 | if (state.sidebarProjectScopeKey === nextKey) { | ||
| 319 | return state; | ||
| 320 | } | ||
| 321 | return { | ||
| 322 | ...state, | ||
| 323 | sidebarProjectScopeKey: nextKey, | ||
| 324 | }; | ||
| 325 | } | ||
| 326 | |||
| 308 | export function resolveProjectExpanded( | 327 | export function resolveProjectExpanded( |
| 309 | projectExpandedById: Readonly<Record<string, boolean>>, | 328 | projectExpandedById: Readonly<Record<string, boolean>>, |
| 310 | preferenceKeys: readonly string[], | 329 | preferenceKeys: readonly string[], |
| ... | @@ -387,6 +406,7 @@ interface UiStateStore extends UiState { | ... | @@ -387,6 +406,7 @@ interface UiStateStore extends UiState { |
| 387 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; | 406 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; |
| 388 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; | 407 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; |
| 389 | setDefaultAdvertisedEndpointKey: (key: string | null) => void; | 408 | setDefaultAdvertisedEndpointKey: (key: string | null) => void; |
| 409 | setSidebarProjectScopeKey: (projectKey: string | null) => void; | ||
| 390 | setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; | 410 | setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; |
| 391 | reorderProjects: ( | 411 | reorderProjects: ( |
| 392 | currentProjectOrder: readonly string[], | 412 | currentProjectOrder: readonly string[], |
| ... | @@ -405,6 +425,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ | ... | @@ -405,6 +425,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ |
| 405 | set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), | 425 | set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), |
| 406 | setDefaultAdvertisedEndpointKey: (key) => | 426 | setDefaultAdvertisedEndpointKey: (key) => |
| 407 | set((state) => setDefaultAdvertisedEndpointKey(state, key)), | 427 | set((state) => setDefaultAdvertisedEndpointKey(state, key)), |
| 428 | setSidebarProjectScopeKey: (projectKey) => | ||
| 429 | set((state) => setSidebarProjectScopeKey(state, projectKey)), | ||
| 408 | setProjectExpanded: (projectIds, expanded) => | 430 | setProjectExpanded: (projectIds, expanded) => |
| 409 | set((state) => setProjectExpanded(state, projectIds, expanded)), | 431 | set((state) => setProjectExpanded(state, projectIds, expanded)), |
| 410 | reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => | 432 | reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => |