| 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 | 111 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 112 | 112 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 113 | 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 | 119 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; |
| 116 | 120 | import { vcsEnvironment } from "../state/vcs"; |
| 117 | 121 | import { threadEnvironment } from "../state/threads"; |
| ... | ... | @@ -2098,7 +2102,11 @@ export default function Sidebar() { |
| 2098 | 2102 | |
| 2099 | 2103 | // Project scope: one menu above the list. Scoping filters the list without |
| 2100 | 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 | 2110 | // {value, label} items let Base UI drive the combobox selection contract |
| 2103 | 2111 | // while the popup search filters the same collection. |
| 2104 | 2112 | const projectScopeItems = useMemo( |
| ... | ... | @@ -2162,11 +2170,15 @@ export default function Sidebar() { |
| 2162 | 2170 | ), |
| 2163 | 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 | 2177 | useEffect(() => { |
| 2166 | if (projectScopeKey !== null && scopedProjectGroup === null) { | |
| 2178 | if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) { | |
| 2167 | 2179 | setProjectScopeKey(null); |
| 2168 | 2180 | } |
| 2169 | }, [projectScopeKey, scopedProjectGroup]); | |
| 2181 | }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); | |
| 2170 | 2182 | // Count-only subscription: the parent needs "are there draft rows" for the |
| 2171 | 2183 | // empty state, while SidebarDraftBlock owns the per-keystroke content |
| 2172 | 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 | 40 | return true; |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | // No window (tests, static render) means no origin to be hosted at. | |
| 44 | if (url === undefined && typeof window === "undefined") { | |
| 43 | // No window, or a window without a location (tests, static render), means | |
| 44 | // no origin to be hosted at. | |
| 45 | if (url === undefined && (typeof window === "undefined" || window.location === undefined)) { | |
| 45 | 46 | return false; |
| 46 | 47 | } |
| 47 | 48 |
apps/web/src/state/entities.ts+8-1| ... | ... | @@ -15,7 +15,10 @@ import { useMemo } from "react"; |
| 15 | 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 16 | 16 | import { environmentProjects } from "./projects"; |
| 17 | 17 | import { environmentServerConfigsAtom } from "./server"; |
| 18 | import { allEnvironmentShellsBootstrappedAtom } from "./shell"; | |
| 18 | import { | |
| 19 | allEnvironmentProjectSnapshotsReadyAtom, | |
| 20 | allEnvironmentShellsBootstrappedAtom, | |
| 21 | } from "./shell"; | |
| 19 | 22 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; |
| 20 | 23 | |
| 21 | 24 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); |
| ... | ... | @@ -79,6 +82,10 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { |
| 79 | 82 | return useAtomValue(allEnvironmentShellsBootstrappedAtom); |
| 80 | 83 | } |
| 81 | 84 | |
| 85 | export function useAllEnvironmentProjectSnapshotsReady(): boolean { | |
| 86 | return useAtomValue(allEnvironmentProjectSnapshotsReadyAtom); | |
| 87 | } | |
| 88 | ||
| 82 | 89 | export function useThreadShellsForProjectRefs( |
| 83 | 90 | refs: ReadonlyArray<ScopedProjectRef>, |
| 84 | 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 | 7 | createEnvironmentShellSummaryAtom, |
| 8 | 8 | createEnvironmentSnapshotAtom, |
| 9 | 9 | createShellEnvironmentAtoms, |
| 10 | type EnvironmentShellState, | |
| 10 | 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 | 14 | import * as Option from "effect/Option"; |
| 12 | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 13 | 16 | |
| 14 | 17 | import { environmentCatalog } from "../connection/catalog"; |
| 15 | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { isHostedStaticApp } from "../hostedPairing"; | |
| 16 | 20 | |
| 17 | 21 | export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); |
| 18 | 22 | export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); |
| ... | ... | @@ -46,3 +50,37 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { |
| 46 | 50 | } |
| 47 | 51 | return true; |
| 48 | 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 | 13 | resolveProjectExpanded, |
| 14 | 14 | setDefaultAdvertisedEndpointKey, |
| 15 | 15 | setProjectExpanded, |
| 16 | setSidebarProjectScopeKey, | |
| 16 | 17 | setThreadChangedFilesExpanded, |
| 17 | 18 | type UiState, |
| 18 | 19 | } from "./uiStateStore"; |
| ... | ... | @@ -21,6 +22,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { |
| 21 | 22 | return { |
| 22 | 23 | projectExpandedById: {}, |
| 23 | 24 | projectOrder: [], |
| 25 | sidebarProjectScopeKey: null, | |
| 24 | 26 | threadLastVisitedAtById: {}, |
| 25 | 27 | threadChangedFilesExpandedById: {}, |
| 26 | 28 | defaultAdvertisedEndpointKey: null, |
| ... | ... | @@ -144,6 +146,15 @@ describe("uiStateStore pure functions", () => { |
| 144 | 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 | 160 | describe("parsePersistedState", () => { |
| ... | ... | @@ -177,6 +188,7 @@ describe("parsePersistedState", () => { |
| 177 | 188 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 178 | 189 | }, |
| 179 | 190 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 191 | sidebarProjectScopeKey: null, | |
| 180 | 192 | threadChangedFilesExpandedById: { |
| 181 | 193 | "environment:thread-1": { |
| 182 | 194 | "turn-1": false, |
| ... | ... | @@ -297,6 +309,7 @@ describe("uiStateStore persistence", () => { |
| 297 | 309 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 298 | 310 | }, |
| 299 | 311 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 312 | sidebarProjectScopeKey: null, | |
| 300 | 313 | threadChangedFilesExpansionVersion: 2, |
| 301 | 314 | threadChangedFilesExpandedById: { |
| 302 | 315 | "environment:thread-1": { |
| ... | ... | @@ -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 | 338 | it("drops the temporary expanded-only migration fallback when rewriting state", () => { |
| 314 | 339 | const migrated = parsePersistedState({ |
| 315 | 340 | expandedProjectCwds: ["/repo/a"], |
apps/web/src/uiStateStore.ts+27-5| ... | ... | @@ -26,6 +26,7 @@ export interface PersistedUiState { |
| 26 | 26 | expandedProjectCwds?: string[]; |
| 27 | 27 | projectOrderCwds?: string[]; |
| 28 | 28 | defaultAdvertisedEndpointKey?: string | null; |
| 29 | sidebarProjectScopeKey?: string | null; | |
| 29 | 30 | threadChangedFilesExpansionVersion?: number; |
| 30 | 31 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 31 | 32 | } |
| ... | ... | @@ -33,6 +34,10 @@ export interface PersistedUiState { |
| 33 | 34 | export interface UiProjectState { |
| 34 | 35 | projectExpandedById: Record<string, boolean>; |
| 35 | 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 | 43 | export interface UiThreadState { |
| ... | ... | @@ -49,6 +54,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState |
| 49 | 54 | const initialState: UiState = { |
| 50 | 55 | projectExpandedById: {}, |
| 51 | 56 | projectOrder: [], |
| 57 | sidebarProjectScopeKey: null, | |
| 52 | 58 | threadLastVisitedAtById: {}, |
| 53 | 59 | threadChangedFilesExpandedById: {}, |
| 54 | 60 | defaultAdvertisedEndpointKey: null, |
| ... | ... | @@ -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 | 97 | function sanitizeTimestampRecord(value: unknown): Record<string, string> { |
| 88 | 98 | if (!value || typeof value !== "object") { |
| 89 | 99 | return {}; |
| ... | ... | @@ -131,11 +141,8 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 131 | 141 | parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION |
| 132 | 142 | ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) |
| 133 | 143 | : {}, |
| 134 | defaultAdvertisedEndpointKey: | |
| 135 | typeof parsed.defaultAdvertisedEndpointKey === "string" && | |
| 136 | parsed.defaultAdvertisedEndpointKey.length > 0 | |
| 137 | ? parsed.defaultAdvertisedEndpointKey | |
| 138 | : null, | |
| 144 | defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), | |
| 145 | sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey), | |
| 139 | 146 | }; |
| 140 | 147 | } |
| 141 | 148 | |
| ... | ... | @@ -206,6 +213,7 @@ export function persistState(state: UiState): void { |
| 206 | 213 | projectOrder: state.projectOrder, |
| 207 | 214 | threadLastVisitedAtById: state.threadLastVisitedAtById, |
| 208 | 215 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, |
| 216 | sidebarProjectScopeKey: state.sidebarProjectScopeKey, | |
| 209 | 217 | threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, |
| 210 | 218 | threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, |
| 211 | 219 | } satisfies PersistedUiState), |
| ... | ... | @@ -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 | 327 | export function resolveProjectExpanded( |
| 309 | 328 | projectExpandedById: Readonly<Record<string, boolean>>, |
| 310 | 329 | preferenceKeys: readonly string[], |
| ... | ... | @@ -387,6 +406,7 @@ interface UiStateStore extends UiState { |
| 387 | 406 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; |
| 388 | 407 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; |
| 389 | 408 | setDefaultAdvertisedEndpointKey: (key: string | null) => void; |
| 409 | setSidebarProjectScopeKey: (projectKey: string | null) => void; | |
| 390 | 410 | setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; |
| 391 | 411 | reorderProjects: ( |
| 392 | 412 | currentProjectOrder: readonly string[], |
| ... | ... | @@ -405,6 +425,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ |
| 405 | 425 | set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), |
| 406 | 426 | setDefaultAdvertisedEndpointKey: (key) => |
| 407 | 427 | set((state) => setDefaultAdvertisedEndpointKey(state, key)), |
| 428 | setSidebarProjectScopeKey: (projectKey) => | |
| 429 | set((state) => setSidebarProjectScopeKey(state, projectKey)), | |
| 408 | 430 | setProjectExpanded: (projectIds, expanded) => |
| 409 | 431 | set((state) => setProjectExpanded(state, projectIds, expanded)), |
| 410 | 432 | reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => |