diff --git a/docs/expo-router.md b/docs/expo-router.md index 2150fe8c9..abfe85b24 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -37,6 +37,11 @@ remembered workspace for that host after the remembered selection has hydrated and the workspace has not been proven missing. If there is no restorable workspace, it goes to global `/open-project`. +This restore is based on the last navigated workspace, not current connection +status. Do not redirect to another online host just because the remembered host +is still connecting or offline; the workspace screen owns that offline/loading +state. + This split is deliberate. The host layout must mount first so native local dynamic params exist before any nested workspace leaf is selected. diff --git a/packages/app/e2e/new-workspace-preselect.spec.ts b/packages/app/e2e/new-workspace-preselect.spec.ts new file mode 100644 index 000000000..db627576b --- /dev/null +++ b/packages/app/e2e/new-workspace-preselect.spec.ts @@ -0,0 +1,270 @@ +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { + expectNewWorkspaceProjectSelected, + openGlobalNewWorkspaceComposer, +} from "./helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { seedSavedSettingsHosts } from "./helpers/settings"; +import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection"; +import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes"; +import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; + +const OFFLINE_SERVER_IDS = [ + "srv_e2e_preselect_offline_1", + "srv_e2e_preselect_offline_2", + "srv_e2e_preselect_offline_3", +]; + +// New Workspace preselection is a form-context decision, not startup routing. +// Entry points from a workspace should carry the current project context, and a +// plain /new must not let a stale remembered offline host steal the initial host +// when there is exactly one online saved host. + +async function pressNewWorkspaceShortcut(page: import("@playwright/test").Page): Promise { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+n`); + await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 }); +} + +async function expectProjectPreselectedWithin( + page: import("@playwright/test").Page, + projectDisplayName: string, + timeout: number, +): Promise { + const projectPicker = page.getByRole("button", { name: "Workspace project" }); + await expect(projectPicker).toContainText(projectDisplayName, { timeout }); +} + +async function expectAnyProjectPreselectedWithin( + page: import("@playwright/test").Page, + timeout: number, +): Promise { + const projectPicker = page.getByRole("button", { name: "Workspace project" }); + await expect(projectPicker).toBeVisible({ timeout }); + await expect + .poll( + async () => { + const label = ((await projectPicker.textContent()) ?? "").trim(); + return label || "Choose project"; + }, + { timeout }, + ) + .not.toBe("Choose project"); +} + +async function openColdRestoredWorkspaceWithOfflineHostFirst( + page: import("@playwright/test").Page, + workspace: SeededWorkspace, +): Promise { + const connectedServerId = getServerId(); + await seedSavedSettingsHosts(page, [ + ...OFFLINE_SERVER_IDS.map((serverId, index) => ({ + serverId, + label: `Offline host ${index + 1}`, + endpoint: `127.0.0.1:${index + 1}`, + })), + { + serverId: connectedServerId, + label: "Connected host", + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + ]); + await page.evaluate( + ({ storageKey, serverId, workspaceId }) => { + localStorage.setItem(storageKey, JSON.stringify({ serverId, workspaceId })); + }, + { + storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY, + serverId: connectedServerId, + workspaceId: workspace.workspaceId, + }, + ); + + await page.goto("/"); + await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, workspace.workspaceId), { + timeout: 60_000, + }); + await waitForSidebarHydration(page); +} + +async function openNewWorkspaceWithStaleOfflineSelection( + page: import("@playwright/test").Page, +): Promise { + const connectedServerId = getServerId(); + await seedSavedSettingsHosts(page, [ + ...OFFLINE_SERVER_IDS.map((serverId, index) => ({ + serverId, + label: `Offline host ${index + 1}`, + endpoint: `127.0.0.1:${index + 1}`, + })), + { + serverId: connectedServerId, + label: "Connected host", + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + ]); + await page.evaluate( + ({ storageKey, serverId }) => { + localStorage.setItem( + storageKey, + JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }), + ); + }, + { + storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY, + serverId: OFFLINE_SERVER_IDS[0]!, + }, + ); + + await page.goto(buildNewWorkspaceRoute()); + await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 60_000 }); +} + +async function seedOfflineHostsWithStaleSelection( + page: import("@playwright/test").Page, +): Promise { + const connectedServerId = getServerId(); + await seedSavedSettingsHosts(page, [ + ...OFFLINE_SERVER_IDS.map((serverId, index) => ({ + serverId, + label: `Offline host ${index + 1}`, + endpoint: `127.0.0.1:${index + 1}`, + })), + { + serverId: connectedServerId, + label: "Connected host", + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + ]); + await page.evaluate( + ({ storageKey, serverId }) => { + localStorage.setItem( + storageKey, + JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }), + ); + }, + { + storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY, + serverId: OFFLINE_SERVER_IDS[0]!, + }, + ); +} + +test.describe("New workspace preselects the open workspace's project", () => { + test.describe.configure({ timeout: 240_000 }); + + let projectA: SeededWorkspace; + let projectB: SeededWorkspace; + + test.beforeEach(async () => { + projectA = await seedWorkspace({ repoPrefix: "preselect-a-" }); + projectB = await seedWorkspace({ repoPrefix: "preselect-b-" }); + }); + + test.afterEach(async () => { + await projectA?.cleanup(); + await projectB?.cleanup(); + }); + + test("Cmd+N preselects the project you are looking at", async ({ page }) => { + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: projectB.workspaceId, + }); + await pressNewWorkspaceShortcut(page); + await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); + + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: projectA.workspaceId, + }); + await pressNewWorkspaceShortcut(page); + await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); + }); + + test("New workspace button preselects the project you are looking at", async ({ page }) => { + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: projectB.workspaceId, + }); + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); + + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: projectA.workspaceId, + }); + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); + }); + + test("Cmd+N preselects the connected host project when an offline saved host is first", async ({ + page, + }) => { + await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB); + + await pressNewWorkspaceShortcut(page); + + await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", { + timeout: 8_000, + }); + await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000); + }); + + test("New workspace button preselects the connected host project when an offline saved host is first", async ({ + page, + }) => { + await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB); + + await openGlobalNewWorkspaceComposer(page); + + await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", { + timeout: 8_000, + }); + await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000); + }); + + test("plain /new ignores stale remembered offline hosts when only one saved host is connected", async ({ + page, + }) => { + await openNewWorkspaceWithStaleOfflineSelection(page); + + await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", { + timeout: 8_000, + }); + await expectAnyProjectPreselectedWithin(page, 8_000); + }); + + test("stale remembered offline host heals after visiting the connected workspace", async ({ + page, + }) => { + const connectedServerId = getServerId(); + await seedOfflineHostsWithStaleSelection(page); + + await page.goto(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId)); + await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId), { + timeout: 60_000, + }); + await waitForSidebarHydration(page); + + await openGlobalNewWorkspaceComposer(page); + + await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", { + timeout: 8_000, + }); + await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000); + }); +}); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 7cadaf5cc..00b027236 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -32,6 +32,8 @@ import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; +import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; +import { useHostFeature } from "@/runtime/host-features"; import { type SidebarProjectEntry, type SidebarStatusWorkspacePlacement, @@ -41,6 +43,8 @@ import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-worksp import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useWorkspace } from "@/stores/session-store-hooks"; import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, @@ -84,7 +88,6 @@ interface SidebarSharedProps { shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; handleRefresh: () => void; - handleNewWorkspaceNavigate: () => void; handleOpenProject: () => void; handleHome: () => void; handleSettings: () => void; @@ -175,10 +178,6 @@ export const LeftSidebar = memo(function LeftSidebar({ void openProjectPicker(); }, [openProjectPicker]); - const handleNewWorkspaceNavigate = useCallback(() => { - router.push(buildNewWorkspaceRoute()); - }, []); - const handleSettingsMobile = useCallback(() => { showMobileAgent(); router.push(buildSettingsRoute()); @@ -262,7 +261,6 @@ export const LeftSidebar = memo(function LeftSidebar({ insetsBottom={insets.bottom} isOpen={isOpen} closeSidebar={showMobileAgent} - handleNewWorkspaceNavigate={handleNewWorkspaceNavigate} handleOpenProject={handleOpenProjectMobile} handleHome={handleHomeMobile} handleSettings={handleSettingsMobile} @@ -278,7 +276,6 @@ export const LeftSidebar = memo(function LeftSidebar({ {...sharedProps} insetsTop={insets.top} isOpen={isOpen} - handleNewWorkspaceNavigate={handleNewWorkspaceNavigate} handleOpenProject={handleOpenProjectDesktop} handleHome={handleHomeDesktop} handleSettings={handleSettingsDesktop} @@ -419,6 +416,61 @@ function HeaderIconTooltipContent({ ); } +const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({ + label, + testID, + variant, + shortcutKeys, + onBeforeNavigate, +}: { + label: string; + testID: string; + variant: "header" | "compact"; + shortcutKeys: ShortcutKey[][] | null; + onBeforeNavigate?: () => void; +}) { + const activeWorkspaceSelection = useActiveWorkspaceSelection(); + const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null; + const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null; + const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId); + const supportsWorkspaceMultiplicity = useHostFeature( + activeWorkspaceServerId, + "workspaceMultiplicity", + ); + const canUseActiveWorkspaceContext = Boolean( + activeWorkspace && + (supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)), + ); + + const handlePress = useCallback(() => { + onBeforeNavigate?.(); + router.push( + activeWorkspaceServerId + ? buildNewWorkspaceRoute( + activeWorkspace && canUseActiveWorkspaceContext + ? { + serverId: activeWorkspaceServerId, + sourceDirectory: activeWorkspace.projectRootPath, + projectId: activeWorkspace.projectId, + } + : { serverId: activeWorkspaceServerId }, + ) + : buildNewWorkspaceRoute(), + ); + }, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]); + + return ( + + ); +}); + function SidebarFooter({ theme, handleOpenProject, @@ -499,7 +551,6 @@ function MobileSidebar({ toggleProjectCollapsed, handleRefresh, newWorkspaceKeys, - handleNewWorkspaceNavigate, handleOpenProject, handleHome, handleSettings, @@ -545,11 +596,6 @@ function MobileSidebar({ closeSidebar(); }, [closeSidebar]); - const handleNewWorkspace = useCallback(() => { - closeSidebar(); - handleNewWorkspaceNavigate(); - }, [closeSidebar, handleNewWorkspaceNavigate]); - const closeGesture = useMemo( () => Gesture.Pan() @@ -691,13 +737,12 @@ function MobileSidebar({ - {padding.top > 0 ? : null} - { - if (!serverId || !workspaceId) { - return null; - } - const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId); - if (!workspace || workspace.projectKind !== "git") { - return null; - } - return workspace; - }); - - const workingDir = activeGitWorkspace?.projectRootPath ?? null; - const projectId = activeGitWorkspace?.projectId ?? null; - const displayName = activeGitWorkspace - ? activeGitWorkspace.projectDisplayName || - projectDisplayNameFromProjectId(activeGitWorkspace.projectId) - : null; + const activeWorkspace = useWorkspace(serverId, workspaceId); + const activeGitWorkspace = activeWorkspace?.projectKind === "git" ? activeWorkspace : null; const handle = useCallback(() => { - if (!serverId || !workingDir) { + if (!serverId || !activeGitWorkspace) { return false; } router.navigate( buildNewWorkspaceRoute({ serverId, - sourceDirectory: workingDir, - displayName: displayName ?? undefined, - projectId: projectId ?? undefined, + sourceDirectory: activeGitWorkspace.projectRootPath, + projectId: activeGitWorkspace.projectId, }) as never, ); return true; - }, [serverId, workingDir, displayName, projectId]); + }, [activeGitWorkspace, serverId]); useKeyboardActionHandler({ handlerId: "worktree-new-active", actions: WORKTREE_NEW_ACTIONS, - enabled: serverId !== null && workingDir !== null, + enabled: serverId !== null && activeGitWorkspace !== null, priority: 0, handle, }); diff --git a/packages/app/src/hooks/use-global-new-workspace-action.ts b/packages/app/src/hooks/use-global-new-workspace-action.ts index abd526614..941584b4e 100644 --- a/packages/app/src/hooks/use-global-new-workspace-action.ts +++ b/packages/app/src/hooks/use-global-new-workspace-action.ts @@ -2,8 +2,11 @@ import { useCallback } from "react"; import { router } from "expo-router"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; +import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; +import { useHostFeature } from "@/runtime/host-features"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceRoute } from "@/utils/host-routes"; const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"]; @@ -11,15 +14,34 @@ const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"]; export function useGlobalNewWorkspaceAction() { const selection = useActiveWorkspaceSelection(); const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; const hosts = useHosts(); + const activeWorkspace = useWorkspace(serverId, workspaceId); + const supportsWorkspaceMultiplicity = useHostFeature(serverId, "workspaceMultiplicity"); + const canUseActiveWorkspaceContext = Boolean( + activeWorkspace && + (supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)), + ); const handle = useCallback(() => { if (hosts.length === 0) { return false; } - router.navigate(buildNewWorkspaceRoute(serverId ? { serverId } : undefined) as never); + router.navigate( + (serverId + ? buildNewWorkspaceRoute( + activeWorkspace && canUseActiveWorkspaceContext + ? { + serverId, + sourceDirectory: activeWorkspace.projectRootPath, + projectId: activeWorkspace.projectId, + } + : { serverId }, + ) + : buildNewWorkspaceRoute()) as never, + ); return true; - }, [hosts.length, serverId]); + }, [activeWorkspace, canUseActiveWorkspaceContext, hosts.length, serverId]); useKeyboardActionHandler({ handlerId: "workspace-new-global", diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index b9a60d5e8..3e82a0b07 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -262,6 +262,18 @@ describe("resolveStartupRoute", () => { ).toEqual({ kind: "redirect", href: "/h/server-1" }); }); + it("restores the last workspace host even when a different host is already online", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "server-offline" }, { serverId: "server-online" }], + anyOnlineHostServerId: "server-online", + workspaceSelection: { serverId: "server-offline", workspaceId: "workspace-a" }, + workspaceSelectionStatus: "unknown", + }), + ).toEqual({ kind: "redirect", href: "/h/server-offline" }); + }); + it("does not restore a saved workspace after workspace hydration proves it is missing", () => { expect( resolveStartupRoute({ diff --git a/packages/app/src/runtime/host-features.ts b/packages/app/src/runtime/host-features.ts index e2afff606..e513c3364 100644 --- a/packages/app/src/runtime/host-features.ts +++ b/packages/app/src/runtime/host-features.ts @@ -1,4 +1,7 @@ +import { useMemo } from "react"; +import { useShallow } from "zustand/shallow"; import type { DaemonServerInfo } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; export type HostFeatureName = keyof NonNullable; @@ -26,3 +29,25 @@ export function selectHostFeature( ): boolean { return hostSupportsFeature(state.sessions[serverId]?.serverInfo, feature); } + +export function useHostFeature( + serverId: string | null | undefined, + feature: HostFeatureName, +): boolean { + const normalizedServerId = serverId?.trim() ?? ""; + return useSessionStore((state) => selectHostFeature(state, normalizedServerId, feature)); +} + +export function useHostFeatureMap( + serverIds: readonly string[], + feature: HostFeatureName, +): ReadonlyMap { + const flags = useSessionStore( + useShallow((state) => serverIds.map((serverId) => selectHostFeature(state, serverId, feature))), + ); + + return useMemo( + () => new Map(serverIds.map((serverId, index) => [serverId, flags[index] === true] as const)), + [flags, serverIds], + ); +} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 1e5ef8a03..9423a9609 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2202,6 +2202,28 @@ export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeCon ); } +export function useHostRuntimeConnectionStatuses( + serverIds: readonly string[], +): ReadonlyMap { + const store = getHostRuntimeStore(); + const version = useSyncExternalStore( + (onStoreChange) => store.subscribeAll(onStoreChange), + () => store.getVersion(), + () => store.getVersion(), + ); + + return useMemo(() => { + // The aggregate version is the reactivity trigger; re-read snapshots on every host tick. + void version; + return new Map( + serverIds.map( + (serverId) => + [serverId, store.getSnapshot(serverId)?.connectionStatus ?? "connecting"] as const, + ), + ); + }, [serverIds, store, version]); +} + export function useHostRuntimeLastError(serverId: string): string | null { const store = getHostRuntimeStore(); return useSyncExternalStore( diff --git a/packages/app/src/screens/new-workspace-initial-context.test.ts b/packages/app/src/screens/new-workspace-initial-context.test.ts new file mode 100644 index 000000000..60a272b23 --- /dev/null +++ b/packages/app/src/screens/new-workspace-initial-context.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; +import type { HostProjectListItem } from "@/projects/host-projects"; +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import { + resolveNewWorkspaceAutomaticServerId, + resolveNewWorkspaceInitialServerId, +} from "./new-workspace-initial-context"; + +function projectFor(serverId: string, key = "project"): HostProjectListItem { + return { + projectKey: key, + projectName: key, + projectKind: "git", + iconWorkingDir: `/work/${key}`, + hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }], + workspaceKeys: [], + }; +} + +function statuses( + entries: Record, +): ReadonlyMap { + return new Map(Object.entries(entries)); +} + +function multiplicity(entries: Record = {}): ReadonlyMap { + return new Map(Object.entries(entries)); +} + +describe("resolveNewWorkspaceInitialServerId", () => { + it("prefers explicit route host context over online-host fallback", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online"], + routeServerId: "offline", + lastActiveProject: null, + projects: [projectFor("online")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("offline"); + }); + + it("prefers the sole online host over a stale offline project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online"], + routeServerId: null, + lastActiveProject: projectFor("offline"), + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online"); + + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("online")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online"); + }); + + it("uses the last active project when there is no sole online host", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "other"], + routeServerId: null, + lastActiveProject: projectFor("offline"), + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ offline: "offline", other: "offline" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("offline"); + }); + + it("prefers a connecting project host over a stale offline last active project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "connecting"], + routeServerId: null, + lastActiveProject: projectFor("offline", "remembered"), + projects: [projectFor("offline", "remembered"), projectFor("connecting", "current")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + connecting: "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("connecting"); + }); + + it("prefers the online last active project over another hydrated online project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["host-a", "host-b"], + routeServerId: null, + lastActiveProject: projectFor("host-b", "remembered"), + projects: [projectFor("host-a")], + hostConnectionStatusByServerId: statuses({ + "host-a": "online", + "host-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("host-b"); + }); + + it("falls back to the only online host even before projects have hydrated", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline-a", "online", "offline-b"], + routeServerId: null, + lastActiveProject: null, + projects: [], + hostConnectionStatusByServerId: statuses({ + "offline-a": "offline", + online: "online", + "offline-b": "offline", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online"); + }); + + it("prefers an online host over the only cached offline project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online-a", "online-b"], + routeServerId: null, + lastActiveProject: projectFor("offline"), + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + "online-a": "online", + "online-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online-a"); + }); + + it("prefers the first online project host over an empty online host", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["empty-online", "project-online-a", "project-online-b"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("project-online-a", "a"), projectFor("project-online-b", "b")], + hostConnectionStatusByServerId: statuses({ + "empty-online": "online", + "project-online-a": "online", + "project-online-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("project-online-a"); + }); + + it("uses the only host with selectable projects even before runtime status is online", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "connected"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("connected")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + connected: "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("connected"); + }); +}); + +describe("resolveNewWorkspaceAutomaticServerId", () => { + it("keeps a usable automatic host stable when the computed default changes", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["host-a", "host-b"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("host-a"), projectFor("host-b")], + hostConnectionStatusByServerId: statuses({ "host-a": "online", "host-b": "online" }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "host-a", + nextServerId: "host-b", + }), + ).toBe("host-a"); + }); + + it("switches to the remembered online host after it hydrates", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["host-a", "host-b"], + routeServerId: null, + lastActiveProject: projectFor("host-b", "remembered"), + projects: [projectFor("host-a"), projectFor("host-b", "remembered")], + hostConnectionStatusByServerId: statuses({ + "host-a": "online", + "host-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "host-a", + nextServerId: "host-b", + }), + ).toBe("host-b"); + }); + + it("switches from an offline automatic host to the online default", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["offline", "online"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "offline", + nextServerId: "online", + }), + ).toBe("online"); + }); + + it("switches from an offline automatic host to a connecting default with projects", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["offline", "connecting"], + routeServerId: null, + lastActiveProject: projectFor("offline", "remembered"), + projects: [projectFor("offline", "remembered"), projectFor("connecting", "current")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + connecting: "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "offline", + nextServerId: "connecting", + }), + ).toBe("connecting"); + }); + + it("switches to the default when the current automatic host has no selectable projects", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["empty", "with-project"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("with-project")], + hostConnectionStatusByServerId: statuses({ + empty: "connecting", + "with-project": "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "empty", + nextServerId: "with-project", + }), + ).toBe("with-project"); + }); + + it("does not switch from an online host to an offline cached project", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["online-empty", "offline-project"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("offline-project")], + hostConnectionStatusByServerId: statuses({ + "online-empty": "online", + "offline-project": "offline", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "online-empty", + nextServerId: "offline-project", + }), + ).toBe("online-empty"); + }); +}); diff --git a/packages/app/src/screens/new-workspace-initial-context.ts b/packages/app/src/screens/new-workspace-initial-context.ts new file mode 100644 index 000000000..4ed87931e --- /dev/null +++ b/packages/app/src/screens/new-workspace-initial-context.ts @@ -0,0 +1,254 @@ +import { + canCreateWorkspaceForHostProject, + type HostProjectListItem, +} from "@/projects/host-projects"; +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; + +export interface NewWorkspaceInitialServerInput { + allServerIds: readonly string[]; + routeServerId: string | null | undefined; + lastActiveProject: HostProjectListItem | null; + projects: readonly HostProjectListItem[]; + hostConnectionStatusByServerId: ReadonlyMap; + workspaceMultiplicityByServerId: ReadonlyMap; +} + +function knownServerId(serverIds: ReadonlySet, serverId: string | null | undefined) { + const normalized = serverId?.trim() ?? ""; + return normalized && serverIds.has(normalized) ? normalized : null; +} + +function supportsAllProjects( + workspaceMultiplicityByServerId: ReadonlyMap, + serverId: string, +) { + return workspaceMultiplicityByServerId.get(serverId) === true; +} + +function getProjectForServer(input: { + candidate: HostProjectListItem; + projects: readonly HostProjectListItem[]; + serverId: string; +}) { + return ( + input.projects.find( + (project) => + project.projectKey === input.candidate.projectKey && + project.hosts.some((host) => host.serverId === input.serverId), + ) ?? input.candidate + ); +} + +function canUseProjectForServer(input: { + project: HostProjectListItem; + projects: readonly HostProjectListItem[]; + serverId: string; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + const project = getProjectForServer({ + candidate: input.project, + projects: input.projects, + serverId: input.serverId, + }); + return canCreateWorkspaceForHostProject({ + project, + serverId: input.serverId, + allowAllProjects: supportsAllProjects(input.workspaceMultiplicityByServerId, input.serverId), + }); +} + +function findLastActiveProjectServerId(input: { + serverIds: ReadonlySet; + lastActiveProject: HostProjectListItem | null; + projects: readonly HostProjectListItem[]; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + if (!input.lastActiveProject) { + return null; + } + + for (const host of input.lastActiveProject.hosts) { + if (!input.serverIds.has(host.serverId)) { + continue; + } + if ( + canUseProjectForServer({ + project: input.lastActiveProject, + projects: input.projects, + serverId: host.serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }) + ) { + return host.serverId; + } + } + + return null; +} + +function hasSelectableProject(input: { + projects: readonly HostProjectListItem[]; + serverId: string; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + return input.projects.some((project) => + canUseProjectForServer({ + project, + projects: input.projects, + serverId: input.serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + ); +} + +function isOnline( + statuses: ReadonlyMap, + serverId: string, +): boolean { + return statuses.get(serverId) === "online"; +} + +function isKnownUnreachable( + statuses: ReadonlyMap, + serverId: string, +): boolean { + const status = statuses.get(serverId); + return status === "offline" || status === "error"; +} + +export function resolveNewWorkspaceInitialServerId(input: NewWorkspaceInitialServerInput): string { + const serverIds = new Set(input.allServerIds); + const routeServerId = knownServerId(serverIds, input.routeServerId); + if (routeServerId) { + return routeServerId; + } + + const onlineServerIds = input.allServerIds.filter((serverId) => + isOnline(input.hostConnectionStatusByServerId, serverId), + ); + const onlineServerIdsWithProjects = onlineServerIds.filter((serverId) => + hasSelectableProject({ + projects: input.projects, + serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + ); + const serverIdsWithProjects = input.allServerIds.filter((serverId) => + hasSelectableProject({ + projects: input.projects, + serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + ); + + const lastActiveProjectServerId = findLastActiveProjectServerId({ + serverIds, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + if ( + lastActiveProjectServerId && + isOnline(input.hostConnectionStatusByServerId, lastActiveProjectServerId) + ) { + return lastActiveProjectServerId; + } + + if (onlineServerIdsWithProjects.length > 0) { + return onlineServerIdsWithProjects[0] ?? ""; + } + if (onlineServerIds.length === 1) { + return onlineServerIds[0] ?? ""; + } + + if (onlineServerIds.length > 0) { + return onlineServerIds[0] ?? ""; + } + + const reachableServerIdsWithProjects = serverIdsWithProjects.filter( + (serverId) => !isKnownUnreachable(input.hostConnectionStatusByServerId, serverId), + ); + if (reachableServerIdsWithProjects.length === 1) { + return reachableServerIdsWithProjects[0] ?? ""; + } + + if (lastActiveProjectServerId) { + return lastActiveProjectServerId; + } + + if (serverIdsWithProjects.length === 1) { + return serverIdsWithProjects[0] ?? ""; + } + + return input.allServerIds[0] ?? ""; +} + +export function resolveNewWorkspaceAutomaticServerId( + input: NewWorkspaceInitialServerInput & { + currentServerId: string | null | undefined; + nextServerId: string | null | undefined; + }, +): string { + const serverIds = new Set(input.allServerIds); + const currentServerId = knownServerId(serverIds, input.currentServerId); + const nextServerId = knownServerId(serverIds, input.nextServerId) ?? input.allServerIds[0] ?? ""; + if (!currentServerId || currentServerId === nextServerId) { + return nextServerId; + } + + if ( + isOnline(input.hostConnectionStatusByServerId, nextServerId) && + !isOnline(input.hostConnectionStatusByServerId, currentServerId) + ) { + return nextServerId; + } + + const routeServerId = knownServerId(serverIds, input.routeServerId); + if (routeServerId === nextServerId) { + return nextServerId; + } + + const lastActiveProjectServerId = findLastActiveProjectServerId({ + serverIds, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + const hasOnlineServer = input.allServerIds.some((serverId) => + isOnline(input.hostConnectionStatusByServerId, serverId), + ); + if ( + lastActiveProjectServerId === nextServerId && + (isOnline(input.hostConnectionStatusByServerId, nextServerId) || !hasOnlineServer) + ) { + return nextServerId; + } + + const currentHasProject = hasSelectableProject({ + projects: input.projects, + serverId: currentServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + const nextHasProject = hasSelectableProject({ + projects: input.projects, + serverId: nextServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + if ( + isKnownUnreachable(input.hostConnectionStatusByServerId, currentServerId) && + nextHasProject && + !isKnownUnreachable(input.hostConnectionStatusByServerId, nextServerId) + ) { + return nextServerId; + } + if ( + !currentHasProject && + nextHasProject && + (!isOnline(input.hostConnectionStatusByServerId, currentServerId) || + isOnline(input.hostConnectionStatusByServerId, nextServerId)) + ) { + return nextServerId; + } + + return currentServerId; +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index e9a44fc2f..d7cf1161c 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -28,7 +28,14 @@ import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from " import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useGithubSearchQuery } from "@/git/use-github-search-query"; -import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; +import { + useHostRuntimeClient, + useHostRuntimeConnectionStatuses, + useHostRuntimeIsConnected, + useHosts, + type HostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; +import { useHostFeature, useHostFeatureMap } from "@/runtime/host-features"; import type { HostProfile } from "@/types/host-connection"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -57,7 +64,6 @@ import { resolveSelectedHostProject, useHostProjects, type HostProjectListItem, - type HostProjectRouteContext, } from "@/projects/host-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; @@ -78,6 +84,10 @@ import { type PickerItem, } from "./new-workspace-picker-item"; import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; +import { + resolveNewWorkspaceAutomaticServerId, + resolveNewWorkspaceInitialServerId, +} from "./new-workspace-initial-context"; function resolveCheckoutRequest( selectedItem: PickerItem | null, @@ -1160,29 +1170,95 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); } -function useNewWorkspaceHostSelector(initialServerId: string) { - const allHosts = useHosts(); - const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); - const lastWorkspaceSelection = useLastWorkspaceSelection(); - const normalizedInitialServerId = initialServerId.trim(); - const routeInitialServerId = allServerIds.includes(normalizedInitialServerId) - ? normalizedInitialServerId - : null; - const fallbackServerId = - lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId) - ? lastWorkspaceSelection.serverId - : (allServerIds[0] ?? ""); - const [manualServerId, setManualServerId] = useState(null); +function useNewWorkspaceHostSelector(input: { + initialServerId: string; + allServerIds: string[]; + projects: HostProjectListItem[]; + lastActiveProject: HostProjectListItem | null; + hostConnectionStatusByServerId: ReadonlyMap; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + const routeServerId = input.initialServerId.trim(); + const defaultServerId = useMemo( + () => + resolveNewWorkspaceInitialServerId({ + allServerIds: input.allServerIds, + routeServerId: input.initialServerId, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + hostConnectionStatusByServerId: input.hostConnectionStatusByServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + [ + input.allServerIds, + input.hostConnectionStatusByServerId, + input.initialServerId, + input.lastActiveProject, + input.projects, + input.workspaceMultiplicityByServerId, + ], + ); + const [automaticSelection, setAutomaticSelection] = useState(() => ({ + routeServerId, + serverId: defaultServerId, + })); + const [manualSelection, setManualSelection] = useState<{ + routeServerId: string; + serverId: string; + } | null>(null); const [hostPickerOpen, setHostPickerOpen] = useState(false); - const selectedServerId = - manualServerId && allServerIds.includes(manualServerId) - ? manualServerId - : (routeInitialServerId ?? fallbackServerId); - const handleSelectHost = useCallback((id: string) => { - setManualServerId(id); - setHostPickerOpen(false); - }, []); + useEffect(() => { + setAutomaticSelection((current) => { + const nextServerId = + current.routeServerId === routeServerId + ? resolveNewWorkspaceAutomaticServerId({ + allServerIds: input.allServerIds, + routeServerId: input.initialServerId, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + hostConnectionStatusByServerId: input.hostConnectionStatusByServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + currentServerId: current.serverId, + nextServerId: defaultServerId, + }) + : defaultServerId; + + if (current.routeServerId === routeServerId && current.serverId === nextServerId) { + return current; + } + + return { routeServerId, serverId: nextServerId }; + }); + }, [ + defaultServerId, + input.allServerIds, + input.hostConnectionStatusByServerId, + input.initialServerId, + input.lastActiveProject, + input.projects, + input.workspaceMultiplicityByServerId, + routeServerId, + ]); + + const automaticServerId = + automaticSelection.routeServerId === routeServerId && + input.allServerIds.includes(automaticSelection.serverId) + ? automaticSelection.serverId + : defaultServerId; + const selectedServerId = + manualSelection?.routeServerId === routeServerId && + input.allServerIds.includes(manualSelection.serverId) + ? manualSelection.serverId + : automaticServerId; + + const handleSelectHost = useCallback( + (id: string) => { + setManualSelection({ routeServerId, serverId: id }); + setHostPickerOpen(false); + }, + [routeServerId], + ); const handleHostPickerOpenChange = useCallback((open: boolean) => { setHostPickerOpen(open); @@ -1193,8 +1269,6 @@ function useNewWorkspaceHostSelector(initialServerId: string) { }, []); return { - allHosts, - allServerIds, selectedServerId, hostPickerOpen, handleSelectHost, @@ -1203,9 +1277,93 @@ function useNewWorkspaceHostSelector(initialServerId: string) { }; } -interface NewWorkspaceProjectPickerInput extends HostProjectRouteContext { +interface NewWorkspaceInitialContextState { + allHosts: HostProfile[]; selectedServerId: string; - allServerIds: string[]; + hostPickerOpen: boolean; + handleSelectHost: (id: string) => void; + handleHostPickerOpenChange: (open: boolean) => void; + openHostPicker: () => void; + projects: HostProjectListItem[]; + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; + routeDisplayName: string; +} + +function useNewWorkspaceInitialContext({ + serverId, + sourceDirectory: sourceDirectoryProp, + projectId, + displayName: displayNameProp, +}: NewWorkspaceScreenProps): NewWorkspaceInitialContextState { + const allHosts = useHosts(); + const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); + const projects = useHostProjects(allServerIds); + const routeDisplayName = displayNameProp?.trim() ?? ""; + const routeProject = useMemo( + () => + hostProjectFromRoute({ + serverId, + projectId, + displayName: routeDisplayName, + sourceDirectory: sourceDirectoryProp, + }), + [projectId, routeDisplayName, serverId, sourceDirectoryProp], + ); + const lastWorkspaceSelection = useLastWorkspaceSelection(); + const lastWorkspaceServerId = useMemo( + () => + lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId) + ? lastWorkspaceSelection.serverId + : null, + [allServerIds, lastWorkspaceSelection], + ); + const lastWorkspaceId = lastWorkspaceServerId ? lastWorkspaceSelection!.workspaceId : null; + const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId); + const lastActiveProject = useMemo( + () => + lastWorkspaceServerId + ? hostProjectFromWorkspace({ serverId: lastWorkspaceServerId, workspace: lastWorkspace }) + : null, + [lastWorkspace, lastWorkspaceServerId], + ); + const hostConnectionStatusByServerId = useHostRuntimeConnectionStatuses(allServerIds); + const workspaceMultiplicityByServerId = useHostFeatureMap(allServerIds, "workspaceMultiplicity"); + const { + selectedServerId, + hostPickerOpen, + handleSelectHost, + handleHostPickerOpenChange, + openHostPicker, + } = useNewWorkspaceHostSelector({ + initialServerId: serverId, + allServerIds, + projects, + lastActiveProject, + hostConnectionStatusByServerId, + workspaceMultiplicityByServerId, + }); + + return { + allHosts, + selectedServerId, + hostPickerOpen, + handleSelectHost, + handleHostPickerOpenChange, + openHostPicker, + projects, + routeProject, + lastActiveProject, + routeDisplayName, + }; +} + +interface NewWorkspaceProjectPickerInput { + selectedServerId: string; + projects: HostProjectListItem[]; + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; + displayName?: string; allowAllProjects: boolean; } @@ -1222,39 +1380,15 @@ interface NewWorkspaceProjectPickerState { } function useNewWorkspaceProjectPicker({ - serverId, selectedServerId, - allServerIds, - sourceDirectory, - projectId, + projects, + routeProject, + lastActiveProject, displayName: displayNameProp, allowAllProjects, }: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState { const [manualProjectKey, setManualProjectKey] = useState(null); const displayName = displayNameProp?.trim() ?? ""; - const projects = useHostProjects(allServerIds); - const lastWorkspaceSelection = useLastWorkspaceSelection(); - const lastWorkspaceServerId = useMemo( - () => - lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId) - ? lastWorkspaceSelection.serverId - : null, - [allServerIds, lastWorkspaceSelection], - ); - const lastWorkspaceId = lastWorkspaceServerId ? lastWorkspaceSelection!.workspaceId : null; - const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId); - - const routeProject = useMemo( - () => hostProjectFromRoute({ serverId, projectId, displayName, sourceDirectory }), - [displayName, projectId, serverId, sourceDirectory], - ); - const lastActiveProject = useMemo( - () => - lastWorkspaceServerId - ? hostProjectFromWorkspace({ serverId: lastWorkspaceServerId, workspace: lastWorkspace }) - : null, - [lastWorkspace, lastWorkspaceServerId], - ); const selectableProjects = useMemo( () => filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }), @@ -1556,18 +1690,23 @@ export function NewWorkspaceScreen({ const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const { allHosts, - allServerIds, selectedServerId, hostPickerOpen, handleSelectHost, handleHostPickerOpenChange, openHostPicker, - } = useNewWorkspaceHostSelector(serverId); + projects, + routeProject, + lastActiveProject, + routeDisplayName, + } = useNewWorkspaceInitialContext({ + serverId, + sourceDirectory: sourceDirectoryProp, + projectId, + displayName: displayNameProp, + }); // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 - const supportsWorkspaceMultiplicity = useSessionStore( - (state) => - state.sessions[selectedServerId]?.serverInfo?.features?.workspaceMultiplicity === true, - ); + const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState; write(value: string): Promise; diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index d0655e747..78c508add 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -3,6 +3,7 @@ import { useLocalSearchParams, usePathname } from "expo-router"; import { useEffect, useSyncExternalStore } from "react"; import { createLastWorkspaceSelectionStore, + LAST_WORKSPACE_SELECTION_STORAGE_KEY, type ActiveWorkspaceSelection, type LastWorkspaceSelectionStorage, } from "@/stores/last-workspace-selection"; @@ -23,8 +24,6 @@ interface NavigateToWorkspaceOptions { currentPathname?: string | null; } -const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection"; - const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),