Keep New Workspace on the current project (#1806)

* fix(app): keep New Workspace on the active project

New Workspace preselection was falling back to stale remembered host state when multiple hosts existed. Pass durable active-project context from workspace entry points and centralize initial host selection so stale offline hosts cannot steal the form while startup restore remains last-workspace only.

* fix(app): address New Workspace review feedback

* fix(app): stabilize New Workspace host selection

* fix(app): preserve New Workspace host fallback

* test(app): loosen stale-host project assertion

* fix(app): prefer reachable new workspace hosts

* fix(app): avoid stale offline workspace preselect

* fix(app): prefer online project hosts
This commit is contained in:
Mohamed Boudra
2026-06-29 20:44:18 +02:00
committed by GitHub
parent 0614618d2f
commit f94ec0e430
13 changed files with 1171 additions and 116 deletions
+5
View File
@@ -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 and the workspace has not been proven missing. If there is no restorable
workspace, it goes to global `/open-project`. 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 This split is deliberate. The host layout must mount first so native local
dynamic params exist before any nested workspace leaf is selected. dynamic params exist before any nested workspace leaf is selected.
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
});
});
+62 -20
View File
@@ -32,6 +32,8 @@ import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import { import {
type SidebarProjectEntry, type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement, type SidebarStatusWorkspacePlacement,
@@ -41,6 +43,8 @@ import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-worksp
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { import {
MAX_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH,
@@ -84,7 +88,6 @@ interface SidebarSharedProps {
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void; handleRefresh: () => void;
handleNewWorkspaceNavigate: () => void;
handleOpenProject: () => void; handleOpenProject: () => void;
handleHome: () => void; handleHome: () => void;
handleSettings: () => void; handleSettings: () => void;
@@ -175,10 +178,6 @@ export const LeftSidebar = memo(function LeftSidebar({
void openProjectPicker(); void openProjectPicker();
}, [openProjectPicker]); }, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
router.push(buildNewWorkspaceRoute());
}, []);
const handleSettingsMobile = useCallback(() => { const handleSettingsMobile = useCallback(() => {
showMobileAgent(); showMobileAgent();
router.push(buildSettingsRoute()); router.push(buildSettingsRoute());
@@ -262,7 +261,6 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsBottom={insets.bottom} insetsBottom={insets.bottom}
isOpen={isOpen} isOpen={isOpen}
closeSidebar={showMobileAgent} closeSidebar={showMobileAgent}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectMobile} handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile} handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile} handleSettings={handleSettingsMobile}
@@ -278,7 +276,6 @@ export const LeftSidebar = memo(function LeftSidebar({
{...sharedProps} {...sharedProps}
insetsTop={insets.top} insetsTop={insets.top}
isOpen={isOpen} isOpen={isOpen}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectDesktop} handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop} handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop} 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 (
<SidebarHeaderRow
icon={Plus}
label={label}
onPress={handlePress}
testID={testID}
variant={variant}
shortcutKeys={shortcutKeys}
/>
);
});
function SidebarFooter({ function SidebarFooter({
theme, theme,
handleOpenProject, handleOpenProject,
@@ -499,7 +551,6 @@ function MobileSidebar({
toggleProjectCollapsed, toggleProjectCollapsed,
handleRefresh, handleRefresh,
newWorkspaceKeys, newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject, handleOpenProject,
handleHome, handleHome,
handleSettings, handleSettings,
@@ -545,11 +596,6 @@ function MobileSidebar({
closeSidebar(); closeSidebar();
}, [closeSidebar]); }, [closeSidebar]);
const handleNewWorkspace = useCallback(() => {
closeSidebar();
handleNewWorkspaceNavigate();
}, [closeSidebar, handleNewWorkspaceNavigate]);
const closeGesture = useMemo( const closeGesture = useMemo(
() => () =>
Gesture.Pan() Gesture.Pan()
@@ -691,13 +737,12 @@ function MobileSidebar({
<Animated.View style={mobileSidebarStyle} pointerEvents="auto"> <Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeaderGroup}> <View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow <SidebarNewWorkspaceHeaderRow
icon={Plus}
label={labels.newWorkspace} label={labels.newWorkspace}
onPress={handleNewWorkspace}
testID="sidebar-global-new-workspace" testID="sidebar-global-new-workspace"
variant="compact" variant="compact"
shortcutKeys={newWorkspaceKeys} shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar}
/> />
<SidebarHeaderRow <SidebarHeaderRow
icon={History} icon={History}
@@ -778,7 +823,6 @@ function DesktopSidebar({
toggleProjectCollapsed, toggleProjectCollapsed,
handleRefresh, handleRefresh,
newWorkspaceKeys, newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject, handleOpenProject,
handleHome, handleHome,
handleSettings, handleSettings,
@@ -856,10 +900,8 @@ function DesktopSidebar({
<TitlebarDragRegion /> <TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null} {padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<View style={styles.sidebarHeaderGroup}> <View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow <SidebarNewWorkspaceHeaderRow
icon={Plus}
label={labels.newWorkspace} label={labels.newWorkspace}
onPress={handleNewWorkspaceNavigate}
testID="sidebar-global-new-workspace" testID="sidebar-global-new-workspace"
variant="compact" variant="compact"
shortcutKeys={newWorkspaceKeys} shortcutKeys={newWorkspaceKeys}
@@ -3,9 +3,8 @@ import { router } from "expo-router";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store"; import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceRoute } from "@/utils/host-routes"; import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"]; const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
@@ -13,44 +12,27 @@ export function useActiveWorktreeNewAction() {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null; const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null; const workspaceId = selection?.workspaceId ?? null;
const activeWorkspace = useWorkspace(serverId, workspaceId);
const activeGitWorkspace = useSessionStore((state) => { const activeGitWorkspace = activeWorkspace?.projectKind === "git" ? activeWorkspace : 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 handle = useCallback(() => { const handle = useCallback(() => {
if (!serverId || !workingDir) { if (!serverId || !activeGitWorkspace) {
return false; return false;
} }
router.navigate( router.navigate(
buildNewWorkspaceRoute({ buildNewWorkspaceRoute({
serverId, serverId,
sourceDirectory: workingDir, sourceDirectory: activeGitWorkspace.projectRootPath,
displayName: displayName ?? undefined, projectId: activeGitWorkspace.projectId,
projectId: projectId ?? undefined,
}) as never, }) as never,
); );
return true; return true;
}, [serverId, workingDir, displayName, projectId]); }, [activeGitWorkspace, serverId]);
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: "worktree-new-active", handlerId: "worktree-new-active",
actions: WORKTREE_NEW_ACTIONS, actions: WORKTREE_NEW_ACTIONS,
enabled: serverId !== null && workingDir !== null, enabled: serverId !== null && activeGitWorkspace !== null,
priority: 0, priority: 0,
handle, handle,
}); });
@@ -2,8 +2,11 @@ import { useCallback } from "react";
import { router } from "expo-router"; import { router } from "expo-router";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; 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 { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceRoute } from "@/utils/host-routes"; import { buildNewWorkspaceRoute } from "@/utils/host-routes";
const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"]; const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"];
@@ -11,15 +14,34 @@ const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"];
export function useGlobalNewWorkspaceAction() { export function useGlobalNewWorkspaceAction() {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null; const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const hosts = useHosts(); const hosts = useHosts();
const activeWorkspace = useWorkspace(serverId, workspaceId);
const supportsWorkspaceMultiplicity = useHostFeature(serverId, "workspaceMultiplicity");
const canUseActiveWorkspaceContext = Boolean(
activeWorkspace &&
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
);
const handle = useCallback(() => { const handle = useCallback(() => {
if (hosts.length === 0) { if (hosts.length === 0) {
return false; 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; return true;
}, [hosts.length, serverId]); }, [activeWorkspace, canUseActiveWorkspaceContext, hosts.length, serverId]);
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: "workspace-new-global", handlerId: "workspace-new-global",
@@ -262,6 +262,18 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "redirect", href: "/h/server-1" }); ).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", () => { it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
expect( expect(
resolveStartupRoute({ resolveStartupRoute({
+25
View File
@@ -1,4 +1,7 @@
import { useMemo } from "react";
import { useShallow } from "zustand/shallow";
import type { DaemonServerInfo } from "@/stores/session-store"; import type { DaemonServerInfo } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
export type HostFeatureName = keyof NonNullable<DaemonServerInfo["features"]>; export type HostFeatureName = keyof NonNullable<DaemonServerInfo["features"]>;
@@ -26,3 +29,25 @@ export function selectHostFeature(
): boolean { ): boolean {
return hostSupportsFeature(state.sessions[serverId]?.serverInfo, feature); 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<string, boolean> {
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],
);
}
+22
View File
@@ -2202,6 +2202,28 @@ export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeCon
); );
} }
export function useHostRuntimeConnectionStatuses(
serverIds: readonly string[],
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
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 { export function useHostRuntimeLastError(serverId: string): string | null {
const store = getHostRuntimeStore(); const store = getHostRuntimeStore();
return useSyncExternalStore( return useSyncExternalStore(
@@ -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<string, HostRuntimeConnectionStatus>,
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
return new Map(Object.entries(entries));
}
function multiplicity(entries: Record<string, boolean> = {}): ReadonlyMap<string, boolean> {
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");
});
});
@@ -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<string, HostRuntimeConnectionStatus>;
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
}
function knownServerId(serverIds: ReadonlySet<string>, serverId: string | null | undefined) {
const normalized = serverId?.trim() ?? "";
return normalized && serverIds.has(normalized) ? normalized : null;
}
function supportsAllProjects(
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>,
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<string, boolean>;
}) {
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<string>;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
}) {
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<string, boolean>;
}) {
return input.projects.some((project) =>
canUseProjectForServer({
project,
projects: input.projects,
serverId: input.serverId,
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
}),
);
}
function isOnline(
statuses: ReadonlyMap<string, HostRuntimeConnectionStatus>,
serverId: string,
): boolean {
return statuses.get(serverId) === "online";
}
function isKnownUnreachable(
statuses: ReadonlyMap<string, HostRuntimeConnectionStatus>,
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;
}
+203 -66
View File
@@ -28,7 +28,14 @@ import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query"; 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 type { HostProfile } from "@/types/host-connection";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -57,7 +64,6 @@ import {
resolveSelectedHostProject, resolveSelectedHostProject,
useHostProjects, useHostProjects,
type HostProjectListItem, type HostProjectListItem,
type HostProjectRouteContext,
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
@@ -78,6 +84,10 @@ import {
type PickerItem, type PickerItem,
} from "./new-workspace-picker-item"; } from "./new-workspace-picker-item";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
import {
resolveNewWorkspaceAutomaticServerId,
resolveNewWorkspaceInitialServerId,
} from "./new-workspace-initial-context";
function resolveCheckoutRequest( function resolveCheckoutRequest(
selectedItem: PickerItem | null, selectedItem: PickerItem | null,
@@ -1160,29 +1170,95 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
} }
function useNewWorkspaceHostSelector(initialServerId: string) { function useNewWorkspaceHostSelector(input: {
const allHosts = useHosts(); initialServerId: string;
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); allServerIds: string[];
const lastWorkspaceSelection = useLastWorkspaceSelection(); projects: HostProjectListItem[];
const normalizedInitialServerId = initialServerId.trim(); lastActiveProject: HostProjectListItem | null;
const routeInitialServerId = allServerIds.includes(normalizedInitialServerId) hostConnectionStatusByServerId: ReadonlyMap<string, HostRuntimeConnectionStatus>;
? normalizedInitialServerId workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
: null; }) {
const fallbackServerId = const routeServerId = input.initialServerId.trim();
lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId) const defaultServerId = useMemo(
? lastWorkspaceSelection.serverId () =>
: (allServerIds[0] ?? ""); resolveNewWorkspaceInitialServerId({
const [manualServerId, setManualServerId] = useState<string | null>(null); 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 [hostPickerOpen, setHostPickerOpen] = useState(false);
const selectedServerId =
manualServerId && allServerIds.includes(manualServerId)
? manualServerId
: (routeInitialServerId ?? fallbackServerId);
const handleSelectHost = useCallback((id: string) => { useEffect(() => {
setManualServerId(id); setAutomaticSelection((current) => {
setHostPickerOpen(false); 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) => { const handleHostPickerOpenChange = useCallback((open: boolean) => {
setHostPickerOpen(open); setHostPickerOpen(open);
@@ -1193,8 +1269,6 @@ function useNewWorkspaceHostSelector(initialServerId: string) {
}, []); }, []);
return { return {
allHosts,
allServerIds,
selectedServerId, selectedServerId,
hostPickerOpen, hostPickerOpen,
handleSelectHost, handleSelectHost,
@@ -1203,9 +1277,93 @@ function useNewWorkspaceHostSelector(initialServerId: string) {
}; };
} }
interface NewWorkspaceProjectPickerInput extends HostProjectRouteContext { interface NewWorkspaceInitialContextState {
allHosts: HostProfile[];
selectedServerId: string; 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; allowAllProjects: boolean;
} }
@@ -1222,39 +1380,15 @@ interface NewWorkspaceProjectPickerState {
} }
function useNewWorkspaceProjectPicker({ function useNewWorkspaceProjectPicker({
serverId,
selectedServerId, selectedServerId,
allServerIds, projects,
sourceDirectory, routeProject,
projectId, lastActiveProject,
displayName: displayNameProp, displayName: displayNameProp,
allowAllProjects, allowAllProjects,
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState { }: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null); const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
const displayName = displayNameProp?.trim() ?? ""; 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( const selectableProjects = useMemo(
() => () =>
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }), filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
@@ -1556,18 +1690,23 @@ export function NewWorkspaceScreen({
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const { const {
allHosts, allHosts,
allServerIds,
selectedServerId, selectedServerId,
hostPickerOpen, hostPickerOpen,
handleSelectHost, handleSelectHost,
handleHostPickerOpenChange, handleHostPickerOpenChange,
openHostPicker, 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 // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
const supportsWorkspaceMultiplicity = useSessionStore( const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity");
(state) =>
state.sessions[selectedServerId]?.serverInfo?.features?.workspaceMultiplicity === true,
);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType< const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor typeof normalizeWorkspaceDescriptor
@@ -1596,7 +1735,6 @@ export function NewWorkspaceScreen({
const client = useHostRuntimeClient(selectedServerId); const client = useHostRuntimeClient(selectedServerId);
const isConnected = useHostRuntimeIsConnected(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId);
const { const {
projects,
selectedProject, selectedProject,
selectedSourceDirectory, selectedSourceDirectory,
projectPickerOptions, projectPickerOptions,
@@ -1605,12 +1743,11 @@ export function NewWorkspaceScreen({
projectTriggerLabel, projectTriggerLabel,
handleSelectProjectOption: selectProjectOption, handleSelectProjectOption: selectProjectOption,
} = useNewWorkspaceProjectPicker({ } = useNewWorkspaceProjectPicker({
serverId,
selectedServerId, selectedServerId,
allServerIds, projects,
sourceDirectory: sourceDirectoryProp, routeProject,
projectId, lastActiveProject,
displayName: displayNameProp, displayName: routeDisplayName,
allowAllProjects: supportsWorkspaceMultiplicity, allowAllProjects: supportsWorkspaceMultiplicity,
}); });
@@ -3,6 +3,8 @@ export interface ActiveWorkspaceSelection {
workspaceId: string; workspaceId: string;
} }
export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
export interface LastWorkspaceSelectionStorage { export interface LastWorkspaceSelectionStorage {
read(): Promise<string | null>; read(): Promise<string | null>;
write(value: string): Promise<void>; write(value: string): Promise<void>;
@@ -3,6 +3,7 @@ import { useLocalSearchParams, usePathname } from "expo-router";
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { import {
createLastWorkspaceSelectionStore, createLastWorkspaceSelectionStore,
LAST_WORKSPACE_SELECTION_STORAGE_KEY,
type ActiveWorkspaceSelection, type ActiveWorkspaceSelection,
type LastWorkspaceSelectionStorage, type LastWorkspaceSelectionStorage,
} from "@/stores/last-workspace-selection"; } from "@/stores/last-workspace-selection";
@@ -23,8 +24,6 @@ interface NavigateToWorkspaceOptions {
currentPathname?: string | null; currentPathname?: string | null;
} }
const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),