authorgravatar for 56691628+Mnigos@users.noreply.github.comIgor Makowski <56691628+Mnigos@users.noreply.github.com> 2026-09-05 02:19:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:19:36-07:00
log1963ca0abe07f9b9aac67820bca45ea3b46ce023
treedb1795f1a99ce6569e33512997c93b13185c0f61
parent4d3907f63d71644e1918d76cd104b036535a1173
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the sidebar project filter across navigation (#9416)

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";
111111import { useLocalStorage } from "../hooks/useLocalStorage";
112112import { useNowMinute } from "../hooks/useNowMinute";
113113import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
114import { useProjects, useThreadShells } from "../state/entities";
114import {
115 useAllEnvironmentProjectSnapshotsReady,
116 useProjects,
117 useThreadShells,
118} from "../state/entities";
115119import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
116120import { vcsEnvironment } from "../state/vcs";
117121import { threadEnvironment } from "../state/threads";
......@@ -2098,7 +2102,11 @@ export default function Sidebar() {
20982102
20992103 // Project scope: one menu above the list. Scoping filters the list without
21002104 // 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);
21022110 // {value, label} items let Base UI drive the combobox selection contract
21032111 // while the popup search filters the same collection.
21042112 const projectScopeItems = useMemo(
......@@ -2162,11 +2170,15 @@ export default function Sidebar() {
21622170 ),
21632171 [scopedProjectGroup],
21642172 );
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();
21652177 useEffect(() => {
2166 if (projectScopeKey !== null && scopedProjectGroup === null) {
2178 if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) {
21672179 setProjectScopeKey(null);
21682180 }
2169 }, [projectScopeKey, scopedProjectGroup]);
2181 }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]);
21702182 // Count-only subscription: the parent needs "are there draft rows" for the
21712183 // empty state, while SidebarDraftBlock owns the per-keystroke content
21722184 // 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 {
4040 return true;
4141 }
4242
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)) {
4546 return false;
4647 }
4748
apps/web/src/state/entities.ts+8-1
......@@ -15,7 +15,10 @@ import { useMemo } from "react";
1515import { appAtomRegistry } from "../rpc/atomRegistry";
1616import { environmentProjects } from "./projects";
1717import { environmentServerConfigsAtom } from "./server";
18import { allEnvironmentShellsBootstrappedAtom } from "./shell";
18import {
19 allEnvironmentProjectSnapshotsReadyAtom,
20 allEnvironmentShellsBootstrappedAtom,
21} from "./shell";
1922import { environmentThreadDetails, environmentThreadShells } from "./threads";
2023
2124const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
......@@ -79,6 +82,10 @@ export function useAllEnvironmentShellsBootstrapped(): boolean {
7982 return useAtomValue(allEnvironmentShellsBootstrappedAtom);
8083}
8184
85export function useAllEnvironmentProjectSnapshotsReady(): boolean {
86 return useAtomValue(allEnvironmentProjectSnapshotsReadyAtom);
87}
88
8289export function useThreadShellsForProjectRefs(
8390 refs: ReadonlyArray<ScopedProjectRef>,
8491): ReadonlyArray<EnvironmentThreadShell> {
apps/web/src/state/shell.test.ts created+135
......@@ -0,0 +1,135 @@
1import {
2 BearerConnectionTarget,
3 PrimaryConnectionTarget,
4} from "@t3tools/client-runtime/connection";
5import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections";
6import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell";
7import { EnvironmentId } from "@t3tools/contracts";
8import * as Option from "effect/Option";
9import { Atom, AtomRegistry } from "effect/unstable/reactivity";
10import { describe, expect, it } from "vite-plus/test";
11
12import { createAllEnvironmentProjectSnapshotsReadyAtom } from "./shell";
13
14const LOCAL = EnvironmentId.make("local");
15const REMOTE = EnvironmentId.make("remote");
16
17function 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
33function 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
60function 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
74describe("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 {
77 createEnvironmentShellSummaryAtom,
88 createEnvironmentSnapshotAtom,
99 createShellEnvironmentAtoms,
10 type EnvironmentShellState,
1011} from "@t3tools/client-runtime/state/shell";
12import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections";
13import type { EnvironmentId } from "@t3tools/contracts";
1114import * as Option from "effect/Option";
1215import { AsyncResult, Atom } from "effect/unstable/reactivity";
1316
1417import { environmentCatalog } from "../connection/catalog";
1518import { connectionAtomRuntime } from "../connection/runtime";
19import { isHostedStaticApp } from "../hostedPairing";
1620
1721export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime);
1822export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime);
......@@ -46,3 +50,37 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {
4650 }
4751 return true;
4852}).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped"));
53
54/** Cached or missing snapshots cannot establish that a saved project no longer exists. */
55export 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
81export 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 {
1313 resolveProjectExpanded,
1414 setDefaultAdvertisedEndpointKey,
1515 setProjectExpanded,
16 setSidebarProjectScopeKey,
1617 setThreadChangedFilesExpanded,
1718 type UiState,
1819} from "./uiStateStore";
......@@ -21,6 +22,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
2122 return {
2223 projectExpandedById: {},
2324 projectOrder: [],
25 sidebarProjectScopeKey: null,
2426 threadLastVisitedAtById: {},
2527 threadChangedFilesExpandedById: {},
2628 defaultAdvertisedEndpointKey: null,
......@@ -144,6 +146,15 @@ describe("uiStateStore pure functions", () => {
144146 defaultAdvertisedEndpointKey: null,
145147 });
146148 });
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 });
147158});
148159
149160describe("parsePersistedState", () => {
......@@ -177,6 +188,7 @@ describe("parsePersistedState", () => {
177188 "environment:thread-1": "2026-02-25T12:35:00.000Z",
178189 },
179190 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
191 sidebarProjectScopeKey: null,
180192 threadChangedFilesExpandedById: {
181193 "environment:thread-1": {
182194 "turn-1": false,
......@@ -297,6 +309,7 @@ describe("uiStateStore persistence", () => {
297309 "environment:thread-1": "2026-02-25T12:35:00.000Z",
298310 },
299311 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
312 sidebarProjectScopeKey: null,
300313 threadChangedFilesExpansionVersion: 2,
301314 threadChangedFilesExpandedById: {
302315 "environment:thread-1": {
......@@ -310,6 +323,18 @@ describe("uiStateStore persistence", () => {
310323 });
311324 });
312325
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
313338 it("drops the temporary expanded-only migration fallback when rewriting state", () => {
314339 const migrated = parsePersistedState({
315340 expandedProjectCwds: ["/repo/a"],
apps/web/src/uiStateStore.ts+27-5
......@@ -26,6 +26,7 @@ export interface PersistedUiState {
2626 expandedProjectCwds?: string[];
2727 projectOrderCwds?: string[];
2828 defaultAdvertisedEndpointKey?: string | null;
29 sidebarProjectScopeKey?: string | null;
2930 threadChangedFilesExpansionVersion?: number;
3031 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
3132}
......@@ -33,6 +34,10 @@ export interface PersistedUiState {
3334export interface UiProjectState {
3435 projectExpandedById: Record<string, boolean>;
3536 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;
3641}
3742
3843export interface UiThreadState {
......@@ -49,6 +54,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState
4954const initialState: UiState = {
5055 projectExpandedById: {},
5156 projectOrder: [],
57 sidebarProjectScopeKey: null,
5258 threadLastVisitedAtById: {},
5359 threadChangedFilesExpandedById: {},
5460 defaultAdvertisedEndpointKey: null,
......@@ -84,6 +90,10 @@ function sanitizeBooleanRecord(value: unknown): Record<string, boolean> {
8490 );
8591}
8692
93function sanitizeOptionalKey(value: unknown): string | null {
94 return typeof value === "string" && value.length > 0 ? value : null;
95}
96
8797function sanitizeTimestampRecord(value: unknown): Record<string, string> {
8898 if (!value || typeof value !== "object") {
8999 return {};
......@@ -131,11 +141,8 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
131141 parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION
132142 ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById)
133143 : {},
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),
139146 };
140147}
141148
......@@ -206,6 +213,7 @@ export function persistState(state: UiState): void {
206213 projectOrder: state.projectOrder,
207214 threadLastVisitedAtById: state.threadLastVisitedAtById,
208215 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
216 sidebarProjectScopeKey: state.sidebarProjectScopeKey,
209217 threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
210218 threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
211219 } satisfies PersistedUiState),
......@@ -305,6 +313,17 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu
305313 };
306314}
307315
316export 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
308327export function resolveProjectExpanded(
309328 projectExpandedById: Readonly<Record<string, boolean>>,
310329 preferenceKeys: readonly string[],
......@@ -387,6 +406,7 @@ interface UiStateStore extends UiState {
387406 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
388407 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
389408 setDefaultAdvertisedEndpointKey: (key: string | null) => void;
409 setSidebarProjectScopeKey: (projectKey: string | null) => void;
390410 setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
391411 reorderProjects: (
392412 currentProjectOrder: readonly string[],
......@@ -405,6 +425,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
405425 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
406426 setDefaultAdvertisedEndpointKey: (key) =>
407427 set((state) => setDefaultAdvertisedEndpointKey(state, key)),
428 setSidebarProjectScopeKey: (projectKey) =>
429 set((state) => setSidebarProjectScopeKey(state, projectKey)),
408430 setProjectExpanded: (projectIds, expanded) =>
409431 set((state) => setProjectExpanded(state, projectIds, expanded)),
410432 reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>