From 11ba9d179fea496642d66a6980fe65742d13e582 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 9 Nov 2026 18:34:11 -0600 Subject: [PATCH 1/4] fix(web): remember the last used project for new threads --- .../desktop/SnapShotCoordinator.tsx | 27 +++++++++++- apps/web/src/hooks/useHandleNewThread.test.ts | 44 +++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 19 +++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index 3d7dae6e65989..4bf4693442c4d 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -13,7 +13,13 @@ import { type PersistedComposerImageAttachment, useComposerDraftStore, } from "../../composerDraftStore"; -import { useHandleNewThread } from "../../hooks/useHandleNewThread"; +import { + LAST_USED_PROJECT_STORAGE_KEY, + lastUsedProjectKeySchema, + useHandleNewThread, +} from "../../hooks/useHandleNewThread"; +import { scopedProjectKey } from "@t3tools/client-runtime/environment"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; import { useStableCallback } from "../../hooks/useStableCallback"; import { getClientSettings, useClientSettings } from "../../hooks/useSettings"; import { readThreadShell } from "../../state/entities"; @@ -230,6 +236,25 @@ export function SnapShotCoordinator() { routeDraftId, routeThreadRef, } = useHandleNewThread(); + const [lastUsedProjectKey, setLastUsedProjectKey] = useLocalStorage( + LAST_USED_PROJECT_STORAGE_KEY, + null, + lastUsedProjectKeySchema, + ); + const currentProject = activeThread ?? activeDraftThread; + const currentProjectKey = currentProject + ? scopedProjectKey({ + environmentId: currentProject.environmentId, + projectId: currentProject.projectId, + }) + : null; + const rememberProject = useEffectEvent((projectKey: string) => { + if (projectKey !== lastUsedProjectKey) setLastUsedProjectKey(projectKey); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Persist route changes without overwriting project choices from other tabs. + useEffect(() => { + if (currentProjectKey) rememberProject(currentProjectKey); + }, [currentProjectKey]); const captureSound = useClientSettings((settings) => settings.snapShotPlaySound ? settings.snapShotSound : null, ); diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6faf5bd76d73d..37a0f1b6f7533 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { RuntimeMode } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, RuntimeMode } from "@t3tools/contracts"; const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; @@ -145,7 +145,8 @@ vi.mock("@effect/atom-react", () => ({ : testServerConfigs(), })); vi.mock("@t3tools/client-runtime/environment", () => ({ - scopedProjectKey: () => "remote-project", + scopedProjectKey: (ref: { environmentId: string; projectId: string }) => + `${ref.environmentId}:${ref.projectId}`, scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); @@ -183,7 +184,9 @@ vi.mock("react", () => ({ useCallback: (callback: T) => callback, useMemo: (factory: () => T) => factory(), })); -vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); +vi.mock("../components/Sidebar.logic", () => ({ + orderItemsByPreferredIds: ({ items }: { items: unknown[] }) => items, +})); vi.mock("../composerDraftStore", () => { const useComposerDraftStore = Object.assign(() => null, { getState: () => testState.draftStore, @@ -247,7 +250,7 @@ vi.mock("../uiStateStore", () => ({ })); vi.mock("./useSettings", () => ({ getClientSettings: () => ({}) })); -import { useNewThreadHandler } from "./useHandleNewThread"; +import { resolveDefaultProjectRef, useNewThreadHandler } from "./useHandleNewThread"; describe.each([ ["new", null], @@ -365,3 +368,36 @@ describe("useNewThreadHandler", () => { expect(testState.router.navigate).toHaveBeenCalledOnce(); }); }); + +describe("default project selection", () => { + it("prefers the last used project in its environment and falls back when unavailable", () => { + const projects = [ + { id: "project", environmentId: "first", workspaceRoot: "/first" }, + { id: "project", environmentId: "last-used", workspaceRoot: "/last-used" }, + ].map((project) => ({ + ...project, + id: project.id as ProjectId, + environmentId: project.environmentId as EnvironmentId, + title: project.environmentId, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-11-09T00:00:00.000Z", + updatedAt: "2026-11-09T00:00:00.000Z", + })); + expect(resolveDefaultProjectRef(projects, [], "last-used:project")).toEqual({ + environmentId: "last-used", + projectId: "project", + }); + expect(resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project")).toEqual({ + environmentId: "first", + projectId: "project", + }); + expect( + resolveDefaultProjectRef( + [projects[0]!, { ...projects[1]!, archivedAt: "2026-11-09T00:00:00.000Z" }], + [], + "last-used:project", + ), + ).toEqual({ environmentId: "first", projectId: "project" }); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index a43825472f357..32be992a47cd0 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -42,11 +42,16 @@ import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { getClientSettings } from "./useSettings"; +import { useLocalStorage } from "./useLocalStorage"; +import * as Schema from "effect/Schema"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +export const LAST_USED_PROJECT_STORAGE_KEY = "t3code:last-used-project:v1"; +export const lastUsedProjectKeySchema = Schema.NullOr(Schema.String); + interface NewThreadWorkspaceOptions { branch?: string | null; worktreePath?: string | null; @@ -426,7 +431,14 @@ export function createNewThreadHandler(router: AnyRouter) { export function resolveDefaultProjectRef( projects: ReadonlyArray, projectOrder: readonly string[], + lastUsedProjectKey: string | null = null, ): ScopedProjectRef | null { + const lastUsed = projects.find( + (project) => + project.archivedAt == null && + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) === lastUsedProjectKey, + ); + if (lastUsed) return scopeProjectRef(lastUsed.environmentId, lastUsed.id); const [defaultProject] = orderItemsByPreferredIds({ items: projects.filter((project) => project.archivedAt == null), preferredIds: projectOrder, @@ -441,6 +453,11 @@ export function resolveDefaultProjectRef( export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); + const [lastUsedProjectKey] = useLocalStorage( + LAST_USED_PROJECT_STORAGE_KEY, + null, + lastUsedProjectKeySchema, + ); const routeTarget = useThreadRouteTarget(); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; @@ -455,7 +472,7 @@ export function useHandleNewThread() { ); const projects = useProjects(); const handleNewThread = useNewThreadHandler(); - const defaultProjectRef = resolveDefaultProjectRef(projects, projectOrder); + const defaultProjectRef = resolveDefaultProjectRef(projects, projectOrder, lastUsedProjectKey); return { activeDraftThread, From ceafdfddca25f21e8e672c305efe39fa54f640a8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 9 Nov 2026 19:12:41 -0600 Subject: [PATCH 2/4] fix(web): use the last project for new-thread shortcuts --- apps/web/src/hooks/useHandleNewThread.ts | 12 ++++++++++-- apps/web/src/shortcuts/chatRouteCommands.test.ts | 7 +++++++ apps/web/src/shortcuts/chatRouteCommands.ts | 7 ++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 32be992a47cd0..d0c601c57991b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -42,7 +42,7 @@ import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { getClientSettings } from "./useSettings"; -import { useLocalStorage } from "./useLocalStorage"; +import { getLocalStorageItem, useLocalStorage } from "./useLocalStorage"; import * as Schema from "effect/Schema"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; @@ -52,6 +52,14 @@ import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; export const LAST_USED_PROJECT_STORAGE_KEY = "t3code:last-used-project:v1"; export const lastUsedProjectKeySchema = Schema.NullOr(Schema.String); +export function readLastUsedProjectKey(): string | null { + try { + return getLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY, lastUsedProjectKeySchema); + } catch { + return null; + } +} + interface NewThreadWorkspaceOptions { branch?: string | null; worktreePath?: string | null; @@ -431,7 +439,7 @@ export function createNewThreadHandler(router: AnyRouter) { export function resolveDefaultProjectRef( projects: ReadonlyArray, projectOrder: readonly string[], - lastUsedProjectKey: string | null = null, + lastUsedProjectKey: string | null, ): ScopedProjectRef | null { const lastUsed = projects.find( (project) => diff --git a/apps/web/src/shortcuts/chatRouteCommands.test.ts b/apps/web/src/shortcuts/chatRouteCommands.test.ts index 7f11ea1135799..82a3b82708b14 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.test.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import * as commandPalette from "../commandPaletteBus"; import * as settings from "../hooks/useSettings"; +import * as storage from "../hooks/useLocalStorage"; import * as threadActions from "../lib/chatThreadActions"; import * as entities from "../state/entities"; import type { Project } from "../types"; @@ -38,6 +39,7 @@ describe("new thread commands", () => { using _projects = vi .spyOn(entities, "readProjects") .mockReturnValue([project("active"), project("idle")].slice(0, scenario.count)); + using storedProject = vi.spyOn(storage, "getLocalStorageItem").mockReturnValue("local:active"); using openPicker = vi.spyOn(commandPalette, "openCommandPalette").mockImplementation(() => {}); using startLocal = vi.spyOn(threadActions, "startNewThreadFromContext").mockResolvedValue(true); using register = vi.spyOn(dispatcher, "registerCommands").mockReturnValue(() => {}); @@ -50,6 +52,7 @@ describe("new thread commands", () => { stopPropagation: vi.fn(), } as unknown as KeyboardEvent; + storedProject.mockReturnValue("local:idle"); commands[scenario.command]!(event); if (scenario.picker) { @@ -58,6 +61,10 @@ describe("new thread commands", () => { } else { expect(openPicker).not.toHaveBeenCalled(); expect(startLocal).toHaveBeenCalledOnce(); + expect(startLocal.mock.calls[0]![0].defaultProjectRef).toEqual({ + environmentId: "local", + projectId: scenario.count === 1 ? "active" : "idle", + }); } }); }); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index 70848449b2efe..ec22e8eef3df8 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -8,7 +8,11 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { dispatchPreviewAction, type PreviewAction } from "../components/preview/previewActionBus"; import { isAppModalPage } from "../components/sidebar/mainAppLocation"; import { shouldCreateNewThreadInCurrentProject } from "../components/Sidebar.logic"; -import { createNewThreadHandler, resolveDefaultProjectRef } from "../hooks/useHandleNewThread"; +import { + createNewThreadHandler, + readLastUsedProjectKey, + resolveDefaultProjectRef, +} from "../hooks/useHandleNewThread"; import { createScratchProjectActions } from "../hooks/useScratchProject"; import { getClientSettings } from "../hooks/useSettings"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -73,6 +77,7 @@ export function installChatRouteCommands(router: AnyRouter): () => void { defaultProjectRef: resolveDefaultProjectRef( readProjects(), useUiStateStore.getState().projectOrder, + readLastUsedProjectKey(), ), handleNewThread, }; From 54a8821636220b79e24f03037b3428cb4573b572 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 9 Nov 2026 19:32:03 -0600 Subject: [PATCH 3/4] fix(web): wait for the last project before snapshot delivery --- .../src/components/Sidebar.browser.test.tsx | 1 + .../desktop/SnapShotCoordinator.tsx | 14 ++++++-- apps/web/src/hooks/useHandleNewThread.test.ts | 4 +++ apps/web/src/hooks/useHandleNewThread.ts | 15 ++++++++- .../src/shortcuts/chatRouteCommands.test.ts | 5 +++ apps/web/src/shortcuts/chatRouteCommands.ts | 5 ++- apps/web/src/state/projects.ts | 32 ++++++++++++++++++- 7 files changed, 70 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index 656012ae8a8e5..b948ed74b279b 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -61,6 +61,7 @@ describe("sidebar header new thread", () => { activeDraftThread: null, activeThread: null, defaultProjectRef: currentProjectRef, + defaultProjectFallbackReady: true, handleNewThread: startThread, routeDraftId: null, routeThreadRef: null, diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index 4bf4693442c4d..9150116af1f94 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -232,6 +232,7 @@ export function SnapShotCoordinator() { activeDraftThread, activeThread, defaultProjectRef, + defaultProjectFallbackReady, handleNewThread, routeDraftId, routeThreadRef, @@ -366,6 +367,7 @@ export function SnapShotCoordinator() { ? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef) : null; if (!target) { + if (!defaultProjectFallbackReady) continue; await dismissSnapShotAnimation(item.id); reportOnce( item.id, @@ -425,6 +427,7 @@ export function SnapShotCoordinator() { }; const drainPending = useEffectEvent(() => drain()); + const hasDefaultProject = defaultProjectRef !== null; const handleSnapShotEvent = useEffectEvent( (bridge: DesktopBridge, event: DesktopSnapShotEvent) => { if (!getClientSettings().snapShotEnabled) return; @@ -495,10 +498,15 @@ export function SnapShotCoordinator() { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to SnapShot events from the desktop bridge and drains pending ones while enabled useEffect(() => { const bridge = getDesktopSnapShotBridge(); - if (!bridge || !enabled) return; - void drainPending(); + if ( + !bridge || + !enabled || + (!defaultProjectFallbackReady && !hasDefaultProject && !lastRouteTarget) + ) + return; + void Promise.resolve(drainingRef.current).then(() => drainPending()); return bridge.onSnapShotEvent((event) => handleSnapShotEvent(bridge, event)); - }, [enabled]); + }, [enabled, defaultProjectFallbackReady, hasDefaultProject, lastRouteTarget]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches window blur, focus and visibilitychange listeners to dismiss or drain SnapShot animations useEffect(() => { diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 37a0f1b6f7533..6f318bce409ce 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -233,6 +233,7 @@ vi.mock("../state/entities", () => { useThread: () => null, }; }); +vi.mock("../state/projects", () => ({ defaultProjectFallbackReadyAtom: () => true })); vi.mock("../state/olderSettledShelf", () => ({ readUnloadedThreadCount: () => 0 })); vi.mock("../state/presentation", () => ({ environmentSummaries: { connectedEnvironmentIdsAtom: "connected-environment-ids" }, @@ -388,6 +389,9 @@ describe("default project selection", () => { environmentId: "last-used", projectId: "project", }); + expect( + resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project", false), + ).toBeNull(); expect(resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project")).toEqual({ environmentId: "first", projectId: "project", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index d0c601c57991b..44215edfc5be7 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -44,6 +44,8 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { getClientSettings } from "./useSettings"; import { getLocalStorageItem, useLocalStorage } from "./useLocalStorage"; import * as Schema from "effect/Schema"; +import { useAtomValue } from "@effect/atom-react"; +import { defaultProjectFallbackReadyAtom } from "../state/projects"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; @@ -440,6 +442,7 @@ export function resolveDefaultProjectRef( projects: ReadonlyArray, projectOrder: readonly string[], lastUsedProjectKey: string | null, + fallbackReady = true, ): ScopedProjectRef | null { const lastUsed = projects.find( (project) => @@ -447,6 +450,7 @@ export function resolveDefaultProjectRef( scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) === lastUsedProjectKey, ); if (lastUsed) return scopeProjectRef(lastUsed.environmentId, lastUsed.id); + if (!fallbackReady) return null; const [defaultProject] = orderItemsByPreferredIds({ items: projects.filter((project) => project.archivedAt == null), preferredIds: projectOrder, @@ -466,6 +470,9 @@ export function useHandleNewThread() { null, lastUsedProjectKeySchema, ); + const defaultProjectFallbackReady = useAtomValue( + defaultProjectFallbackReadyAtom(lastUsedProjectKey), + ); const routeTarget = useThreadRouteTarget(); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; @@ -480,12 +487,18 @@ export function useHandleNewThread() { ); const projects = useProjects(); const handleNewThread = useNewThreadHandler(); - const defaultProjectRef = resolveDefaultProjectRef(projects, projectOrder, lastUsedProjectKey); + const defaultProjectRef = resolveDefaultProjectRef( + projects, + projectOrder, + lastUsedProjectKey, + defaultProjectFallbackReady, + ); return { activeDraftThread, activeThread, defaultProjectRef, + defaultProjectFallbackReady, handleNewThread, routeDraftId, routeThreadRef, diff --git a/apps/web/src/shortcuts/chatRouteCommands.test.ts b/apps/web/src/shortcuts/chatRouteCommands.test.ts index 82a3b82708b14..a4c6b53279c84 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.test.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.test.ts @@ -7,6 +7,8 @@ import * as settings from "../hooks/useSettings"; import * as storage from "../hooks/useLocalStorage"; import * as threadActions from "../lib/chatThreadActions"; import * as entities from "../state/entities"; +import * as projectsState from "../state/projects"; +import { Atom } from "effect/reactivity"; import type { Project } from "../types"; import { installChatRouteCommands } from "./chatRouteCommands"; import * as dispatcher from "./dispatcher"; @@ -39,6 +41,9 @@ describe("new thread commands", () => { using _projects = vi .spyOn(entities, "readProjects") .mockReturnValue([project("active"), project("idle")].slice(0, scenario.count)); + using _ready = vi + .spyOn(projectsState, "defaultProjectFallbackReadyAtom") + .mockReturnValue(Atom.make(true)); using storedProject = vi.spyOn(storage, "getLocalStorageItem").mockReturnValue("local:active"); using openPicker = vi.spyOn(commandPalette, "openCommandPalette").mockImplementation(() => {}); using startLocal = vi.spyOn(threadActions, "startNewThreadFromContext").mockResolvedValue(true); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index ec22e8eef3df8..9b2e59f76d7e0 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -21,6 +21,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { readProjects, readThreadShell } from "../state/entities"; +import { defaultProjectFallbackReadyAtom } from "../state/projects"; import { environmentPresentations } from "../state/presentation"; import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; import { readEnvironmentScope } from "../state/session"; @@ -63,6 +64,7 @@ export function installChatRouteCommands(router: AnyRouter): () => void { : false; }; const readThreadContext = () => { + const lastUsedProjectKey = readLastUsedProjectKey(); const target = readRouteTarget(); const drafts = useComposerDraftStore.getState(); const activeThread = target?.kind === "server" ? readThreadShell(target.threadRef) : null; @@ -77,7 +79,8 @@ export function installChatRouteCommands(router: AnyRouter): () => void { defaultProjectRef: resolveDefaultProjectRef( readProjects(), useUiStateStore.getState().projectOrder, - readLastUsedProjectKey(), + lastUsedProjectKey, + appAtomRegistry.get(defaultProjectFallbackReadyAtom(lastUsedProjectKey)), ), handleNewThread, }; diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index 408a48cae38bd..349f63776c02c 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -5,7 +5,10 @@ import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { environmentSnapshotAtom } from "./shell"; +import { environmentShell, environmentSnapshotAtom, expectsPrimaryEnvironment } from "./shell"; +import { parseScopedProjectKey } from "@t3tools/client-runtime/environment"; +import { AVAILABLE_CONNECTION_STATE } from "@t3tools/client-runtime/connection"; +import { AsyncResult, Atom } from "effect/reactivity"; import { serverEnvironment } from "./server"; export const environmentProjects = createEnvironmentProjectAtoms({ @@ -22,3 +25,30 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti staleTimeMs: 5_000, idleTtlMs: 60_000, }); + +export const defaultProjectFallbackReadyAtom = Atom.family((projectKey: string | null) => + Atom.make((get) => { + const remembered = projectKey === null ? null : parseScopedProjectKey(projectKey); + if (!remembered) return true; + const catalog = get(environmentCatalog.catalogValueAtom); + if (!catalog.isReady) return false; + if ( + expectsPrimaryEnvironment() && + !Array.from(catalog.entries.values()).some( + (entry) => entry.target._tag === "PrimaryConnectionTarget", + ) + ) + return false; + if (!catalog.entries.get(remembered.environmentId)?.enabled) return true; + if (get(environmentShell.statusAtom(remembered.environmentId)) === "live") return true; + const connection = AsyncResult.getOrElse( + get(environmentCatalog.stateAtom(remembered.environmentId)), + () => AVAILABLE_CONNECTION_STATE, + ); + return ( + connection.phase === "offline" || + connection.phase === "blocked" || + (connection.phase === "backoff" && connection.attempt > 2) + ); + }).pipe(Atom.withLabel(`web-default-project-fallback-ready:${projectKey}`)), +); From b7e9f63e2a9dbbf5290353343486f8bc3d79aa67 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 9 Nov 2026 19:35:43 -0600 Subject: [PATCH 4/4] fix(web): allow project fallback for unsupported environments --- apps/web/src/state/projects.ts | 3 ++- apps/web/src/state/shell.test.ts | 37 ++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index 349f63776c02c..816ac88b8d0fc 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -39,7 +39,8 @@ export const defaultProjectFallbackReadyAtom = Atom.family((projectKey: string | ) ) return false; - if (!catalog.entries.get(remembered.environmentId)?.enabled) return true; + const entry = catalog.entries.get(remembered.environmentId); + if (!entry?.enabled || entry.unsupportedReason !== undefined) return true; if (get(environmentShell.statusAtom(remembered.environmentId)) === "live") return true; const connection = AsyncResult.getOrElse( get(environmentCatalog.stateAtom(remembered.environmentId)), diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts index 19a88cfbb19f9..530c96b9c8d29 100644 --- a/apps/web/src/state/shell.test.ts +++ b/apps/web/src/state/shell.test.ts @@ -1,4 +1,5 @@ import { + AVAILABLE_CONNECTION_STATE, BearerConnectionTarget, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; @@ -6,10 +7,16 @@ import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/conn import { EMPTY_SHELL_STATE, type EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; -import { createAllEnvironmentProjectSnapshotsReadyAtom, expectsPrimaryEnvironment } from "./shell"; +import { + createAllEnvironmentProjectSnapshotsReadyAtom, + expectsPrimaryEnvironment, + environmentShell, +} from "./shell"; +import { environmentCatalog } from "../connection/catalog"; +import { defaultProjectFallbackReadyAtom } from "./projects"; const LOCAL = EnvironmentId.make("local"); const REMOTE = EnvironmentId.make("remote"); @@ -148,3 +155,29 @@ it("expects a primary environment only on desktops with the local environment en expect(desktop(true)).toBe(true); expect(desktop(false)).toBe(false); }); + +it.each([undefined, "", "Server update required"])( + "settles fallback for an unsupported remembered environment: %s", + (unsupportedReason) => { + const catalog = catalogState([LOCAL, REMOTE]); + const entries = new Map(catalog.entries); + entries.set(REMOTE, { + ...entries.get(REMOTE)!, + ...(unsupportedReason === undefined ? {} : { unsupportedReason }), + }); + const registry = AtomRegistry.make({ + initialValues: [ + [environmentCatalog.catalogValueAtom, { ...catalog, entries }], + [environmentShell.statusAtom(REMOTE), "empty"], + [environmentCatalog.stateAtom(REMOTE), AsyncResult.success(AVAILABLE_CONNECTION_STATE)], + ], + }); + try { + expect(registry.get(defaultProjectFallbackReadyAtom("remote:project"))).toBe( + unsupportedReason !== undefined, + ); + } finally { + registry.dispose(); + } + }, +);