diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 26dc99dad..1940360e3 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -403,10 +403,14 @@ async function waitForPairingOfferFromDaemon(args: { ); } -interface DictationConfig { - openAiUsable: boolean; - localModelsDir: string | null; -} +const LOCAL_SPEECH_ENV_KEYS = [ + "PASEO_LOCAL_MODELS_DIR", + "PASEO_DICTATION_LOCAL_STT_MODEL", + "PASEO_VOICE_LOCAL_STT_MODEL", + "PASEO_VOICE_LOCAL_TTS_MODEL", + "PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", + "PASEO_VOICE_LOCAL_TTS_SPEED", +] as const; async function loadEnvTestFile(repoRoot: string): Promise { const envTestPath = path.join(repoRoot, ".env.test"); @@ -440,7 +444,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise { } } -async function resolveDictationConfig(): Promise { +async function logSpeechHarnessConfig(): Promise { const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY); const defaultLocalModelsDir = path.join( process.env.HOME ?? "", @@ -451,23 +455,20 @@ async function resolveDictationConfig(): Promise { const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir); - // Fork PRs run without secrets and usually without local models. Don't crash - // the whole Playwright run — disable dictation/voice and let tests that need - // them gate on PASEO_DICTATION_ENABLED. + // Default app E2E does not cover speech flows. Keep speech disabled here so + // unrelated tests never start background local-model downloads. if (!openAiUsable && !hasDefaultLocalModelsDir) { console.warn( - "[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " + + "[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " + "Tests that require dictation should gate on PASEO_DICTATION_ENABLED.", ); - return { openAiUsable: false, localModelsDir: null }; + return; } - const dictationProvider = openAiUsable ? "openai" : "local"; - const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null; + const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`; console.log( - `[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`, + `[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`, ); - return { openAiUsable, localModelsDir }; } interface RelayStreamState { @@ -656,39 +657,40 @@ interface DaemonSpawnArgs { paseoHome: string; fakeEditorBinDir: string; editorRecordPath: string; - dictation: DictationConfig; buffer: ReturnType; } function startDaemon(args: DaemonSpawnArgs): ChildProcess { const serverDir = path.resolve(__dirname, "../../..", "packages/server"); const tsxBin = execSync("which tsx").toString().trim(); - const { openAiUsable, localModelsDir } = args.dictation; + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + PASEO_HOME: args.paseoHome, + PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, + PASEO_SERVER_ID: "srv_e2e_test_daemon", + PASEO_LISTEN: `0.0.0.0:${args.port}`, + PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, + PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`, + // Default app E2E does not cover speech flows. Keep these disabled so + // unrelated tests never start background local-model downloads. + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + PASEO_DICTATION_STT_PROVIDER: "openai", + PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai", + PASEO_VOICE_STT_PROVIDER: "openai", + PASEO_VOICE_TTS_PROVIDER: "openai", + PASEO_NODE_ENV: "development", + NODE_ENV: "development", + }; + + for (const key of LOCAL_SPEECH_ENV_KEYS) { + delete env[key]; + } const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { cwd: serverDir, - env: { - ...process.env, - PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`, - PASEO_HOME: args.paseoHome, - PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, - PASEO_SERVER_ID: "srv_e2e_test_daemon", - PASEO_LISTEN: `0.0.0.0:${args.port}`, - PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, - PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`, - PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0", - PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0", - PASEO_NODE_ENV: "development", - ...(openAiUsable - ? { - PASEO_DICTATION_STT_PROVIDER: "openai", - PASEO_VOICE_STT_PROVIDER: "openai", - PASEO_VOICE_TTS_PROVIDER: "openai", - } - : {}), - ...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}), - NODE_ENV: "development", - }, + env, stdio: ["ignore", "pipe", "pipe"], detached: false, }); @@ -720,6 +722,15 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess { return child; } +async function removeTempTree(targetPath: string): Promise { + await rm(targetPath, { + recursive: true, + force: true, + maxRetries: 40, + retryDelay: 250, + }); +} + async function performCleanup(shouldRemovePaseoHome: boolean): Promise { await Promise.all([ stopProcess(daemonProcess), @@ -731,13 +742,13 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise { metroProcess = null; relayProcess = null; if (paseoHome && shouldRemovePaseoHome) { - await rm(paseoHome, { recursive: true, force: true }); + await removeTempTree(paseoHome); paseoHome = null; } else if (paseoHome) { console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); } if (fakeEditorBinDir) { - await rm(fakeEditorBinDir, { recursive: true, force: true }); + await removeTempTree(fakeEditorBinDir); fakeEditorBinDir = null; } } @@ -761,7 +772,7 @@ export default async function globalSetup() { const cleanup = () => performCleanup(shouldRemovePaseoHome); - const dictation = await resolveDictationConfig(); + await logSpeechHarnessConfig(); try { const relayPort = await startRelay(new Set([port, metroPort])); @@ -777,7 +788,6 @@ export default async function globalSetup() { paseoHome, fakeEditorBinDir, editorRecordPath, - dictation, buffer: daemonLineBuffer, }); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 8bdf9f75e..869bc6561 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -3,11 +3,12 @@ import { expect, type Page } from "@playwright/test"; import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; import { getE2EDaemonPort } from "./daemon-port"; import { getServerId } from "./server-id"; +import { expectAppRoute } from "./route-assertions"; import { waitForWorkspaceTabsVisible } from "./workspace-tabs"; import { buildHostAgentDetailRoute, - buildHostSessionsRoute, buildHostWorkspaceRoute, + buildSessionsRoute, } from "@/utils/host-routes"; export interface ArchiveTabAgent { @@ -219,9 +220,7 @@ export async function openSessions(page: Page): Promise { const sessionsButton = page.getByTestId("sidebar-sessions"); await expect(sessionsButton).toBeVisible({ timeout: 30_000 }); await sessionsButton.click(); - await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), { - timeout: 30_000, - }); + await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 }); await expect(page.getByText("History", { exact: true }).last()).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/e2e/helpers/route-assertions.ts b/packages/app/e2e/helpers/route-assertions.ts new file mode 100644 index 000000000..bca3416f4 --- /dev/null +++ b/packages/app/e2e/helpers/route-assertions.ts @@ -0,0 +1,17 @@ +import { expect, type Page } from "@playwright/test"; + +export async function expectAppRoute( + page: Page, + expectedRoute: string, + options?: { timeout?: number }, +): Promise { + await expect + .poll( + () => { + const current = new URL(page.url()); + return `${current.pathname}${current.search}`; + }, + { timeout: options?.timeout }, + ) + .toBe(expectedRoute); +} diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 254879456..40f128572 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -1,7 +1,13 @@ import { expect, type Page } from "@playwright/test"; import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry"; -import { escapeRegex } from "./regex"; import { getServerId } from "./server-id"; +import { expectAppRoute } from "./route-assertions"; +import { + buildProjectsSettingsRoute, + buildSettingsHostSectionRoute, + buildSettingsRoute, + buildSettingsSectionRoute, +} from "@/utils/host-routes"; const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once"; const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; @@ -33,12 +39,12 @@ export async function openSettingsSection(page: Page, section: SettingsSection): if (section === "projects") { await page.getByTestId("settings-projects").click(); - await expect(page).toHaveURL(/\/settings\/projects$/); + await expectAppRoute(page, buildProjectsSettingsRoute()); return; } await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click(); - await expect(page).toHaveURL(new RegExp(`/settings/${section}$`)); + await expectAppRoute(page, buildSettingsSectionRoute(section)); } export async function openSettingsHost(page: Page, serverId: string): Promise { @@ -55,9 +61,7 @@ export async function openSettingsHostSection( section: HostSection, ): Promise { await page.getByTestId(`settings-host-section-${section}`).click(); - await expect(page).toHaveURL( - new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`), - ); + await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, section)); } export async function expectSettingsHeader(page: Page, title: string): Promise { @@ -84,13 +88,13 @@ export async function toggleHostAdvanced(page: Page): Promise { await page.getByTestId("direct-host-advanced-toggle").click(); } -export async function openCompactSettings(page: Page): Promise { - await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 }); +export async function openCompactSettings(page: Page, expectedStartRoute: string): Promise { + await expectAppRoute(page, expectedStartRoute, { timeout: 15_000 }); await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first(); await expect(settingsButton).toBeVisible(); await settingsButton.click(); - await expect(page).toHaveURL(/\/settings$/); + await expectAppRoute(page, buildSettingsRoute()); await expect(page.getByTestId("settings-sidebar")).toBeVisible(); } @@ -149,7 +153,7 @@ export async function expectSettingsHostPickerLabel(page: Page, label: string): } export async function expectCompactSettingsList(page: Page): Promise { - await expect(page).toHaveURL(/\/settings$/); + await expectAppRoute(page, buildSettingsRoute()); await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await expect(page.getByText("Theme", { exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0); @@ -189,7 +193,7 @@ export async function removeCurrentHostFromSettings(page: Page): Promise { await page.getByTestId("host-page-remove-host-button").click(); await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible(); await page.getByTestId("remove-host-confirm").click(); - await expect(page).toHaveURL(/\/settings$/); + await expectAppRoute(page, buildSettingsRoute()); } export async function expectSettingsBackButton(page: Page): Promise { @@ -201,9 +205,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise { } export async function expectHostSettingsUrl(page: Page, serverId: string): Promise { - await expect(page).toHaveURL( - new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`), - ); + await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "connections")); } export async function verifyLegacyHostSettingsRedirect(page: Page): Promise { diff --git a/packages/app/e2e/helpers/startup-dsl.ts b/packages/app/e2e/helpers/startup-dsl.ts index c48e51cf9..96ad96f3c 100644 --- a/packages/app/e2e/helpers/startup-dsl.ts +++ b/packages/app/e2e/helpers/startup-dsl.ts @@ -156,14 +156,15 @@ class StartupAssertions { this.page = page; } - async expectsSavedHostShell(input: { label: string }): Promise { + async expectsSavedHostShell(input: { serverId: string; label: string }): Promise { await this.page.getByRole("button", { name: "Open menu", exact: true }).click(); - await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({ - timeout: 15_000, - }); - await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible(); - await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible(); - await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible(); + await this.page.getByTestId("sidebar-hosts-trigger").click(); + const hostRow = this.page.getByTestId(`sidebar-host-row-${input.serverId}`); + await expect(hostRow).toBeVisible({ timeout: 15_000 }); + await expect(hostRow).toContainText(input.label); + await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible(); + await expect(this.page.getByTestId("sidebar-home")).toBeVisible(); + await expect(this.page.getByTestId("sidebar-settings")).toBeVisible(); await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0); return this; } diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index b03d50056..62a322d97 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -1,7 +1,7 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { gotoHome } from "./app"; -import { escapeRegex } from "./regex"; +import { expectAppRoute } from "./route-assertions"; export async function openNewAgentComposer(page: Page): Promise { await gotoHome(page); @@ -59,9 +59,7 @@ export async function switchWorkspaceViaSidebar(input: { await row.click(); const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId); - await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), { - timeout: 30_000, - }); + await expectAppRoute(input.page, targetWorkspaceRoute, { timeout: 30_000 }); } /** diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/settings-navigation.spec.ts index cd8c9d692..70a8de6f4 100644 --- a/packages/app/e2e/settings-navigation.spec.ts +++ b/packages/app/e2e/settings-navigation.spec.ts @@ -1,5 +1,10 @@ import { test, expect } from "./fixtures"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { + buildHostWorkspaceRoute, + buildOpenProjectRoute, + buildSettingsRoute, + buildSettingsSectionRoute, +} from "@/utils/host-routes"; import { gotoAppShell, openSettings } from "./helpers/app"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { @@ -36,6 +41,7 @@ import { removeCurrentHostFromSettings, } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; +import { expectAppRoute } from "./helpers/route-assertions"; async function openWorkspace( page: import("@playwright/test").Page, @@ -121,7 +127,7 @@ test.describe("Settings — compact master-detail", () => { test("/settings renders only the sidebar list (no section content)", async ({ page }) => { await gotoAppShell(page); - await openCompactSettings(page); + await openCompactSettings(page, buildOpenProjectRoute()); await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]); await expectCompactSettingsList(page); @@ -133,10 +139,10 @@ test.describe("Settings — compact master-detail", () => { test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => { await gotoAppShell(page); - await openCompactSettings(page); + await openCompactSettings(page, buildOpenProjectRoute()); await openSettingsSection(page, "diagnostics"); - await expect(page).toHaveURL(/\/settings\/diagnostics$/); + await expectAppRoute(page, buildSettingsSectionRoute("diagnostics")); await expectDiagnosticsContent(page); await expectSettingsSidebarHidden(page); await expectSettingsBackButton(page); @@ -144,10 +150,10 @@ test.describe("Settings — compact master-detail", () => { test("back from a section detail returns to the /settings list", async ({ page }) => { await gotoAppShell(page); - await openCompactSettings(page); + await openCompactSettings(page, buildOpenProjectRoute()); await openSettingsSection(page, "about"); - await expect(page).toHaveURL(/\/settings\/about$/); + await expectAppRoute(page, buildSettingsSectionRoute("about")); await goBackInSettings(page); await expectCompactSettingsList(page); @@ -158,7 +164,7 @@ test.describe("Settings — compact master-detail", () => { page, }) => { await gotoAppShell(page); - await openCompactSettings(page); + await openCompactSettings(page, buildOpenProjectRoute()); await openCompactSettingsHost(page); await expectSettingsBackButton(page); @@ -167,11 +173,11 @@ test.describe("Settings — compact master-detail", () => { test("back from a host detail returns to the /settings list", async ({ page }) => { await gotoAppShell(page); - await openCompactSettings(page); + await openCompactSettings(page, buildOpenProjectRoute()); await openCompactSettingsHost(page); await goBackInSettings(page); - await expect(page).toHaveURL(/\/settings$/); + await expectAppRoute(page, buildSettingsRoute()); await expectSettingsSidebarVisible(page); }); @@ -188,11 +194,11 @@ test.describe("Settings — compact master-detail", () => { { serverId: secondaryServerId, label: secondaryHostLabel, endpoint }, ]); await gotoAppShell(page); - await openCompactSettings(page); + await openCompactSettings(page, buildOpenProjectRoute()); await selectSettingsHost(page, secondaryServerId); - await expect(page).toHaveURL(/\/settings$/); + await expectAppRoute(page, buildSettingsRoute()); await expectSettingsSidebarVisible(page); await expectSettingsHostPickerLabel(page, secondaryHostLabel); @@ -206,7 +212,7 @@ test.describe("Settings — compact master-detail", () => { const workspace = await withWorkspace({ prefix: "remove-host-compact-" }); await openWorkspace(page, workspace); - await openCompactSettings(page); + await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId)); await openSettingsHostSection(page, getServerId(), "host"); await removeCurrentHostFromSettings(page); await closeCompactSettings(page); diff --git a/packages/app/e2e/startup-loading.spec.ts b/packages/app/e2e/startup-loading.spec.ts index c79aef688..896b7fb7e 100644 --- a/packages/app/e2e/startup-loading.spec.ts +++ b/packages/app/e2e/startup-loading.spec.ts @@ -14,7 +14,7 @@ test.describe("Startup loading presentation", () => { }) .openRoot(); - await startup.expectsSavedHostShell({ label: "Dev" }); + await startup.expectsSavedHostShell({ serverId: "srv_unreachable_mobile", label: "Dev" }); await startup.expectsNoSavedHostErrorStatus(); await startup.expectsNoLocalServerStartupCopy(); }); diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 861d06908..9c27a92c7 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -33,6 +33,7 @@ import { import { clickSettingsBackToWorkspace } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; import { injectDesktopBridge } from "./helpers/desktop-updates"; +import { expectAppRoute } from "./helpers/route-assertions"; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; @@ -105,12 +106,8 @@ async function expectWorkspaceLocation( workspace: Awaited>; }, ): Promise { - await expect(page).toHaveURL( - buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId), - { - timeout: 30_000, - }, - ); + const workspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId); + await expectAppRoute(page, workspaceRoute, { timeout: 30_000 }); await expectWorkspaceHeader(page, { title: input.workspace.workspaceName, subtitle: input.workspace.projectDisplayName, diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 94061e25a..5f019d7cb 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -34,6 +34,7 @@ import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; +import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { @@ -79,6 +80,7 @@ import { polyfillCrypto } from "@/polyfills/crypto"; import { queryClient } from "@/query/query-client"; import { getHostRuntimeStore, + useHostRegistryLoaded, useHostMutations, useHostRuntimeClient, useHosts, @@ -88,11 +90,10 @@ import { applyAppearance } from "@/screens/settings/appearance/apply-appearance" import { usePanelStore } from "@/stores/panel-store"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; -import { resolveActiveHost } from "@/utils/active-host"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state"; import { - buildHostRootRoute, + buildOpenProjectRoute, parseHostAgentRouteFromPathname, parseServerIdFromPathname, parseWorkspaceOpenIntent, @@ -427,12 +428,8 @@ function AppContainer({ const isCompactLayout = useIsCompactFormFactor(); useCompactWebViewportZoomLock(isCompactLayout); - const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const pathname = usePathname(); - const activeServerId = useMemo( - () => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null, - [daemons, pathname], - ); + const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList; const toggleDesktopSidebars = useCallback(() => { const { desktop } = usePanelStore.getState(); @@ -494,12 +491,10 @@ function AppContainer({ + - + @@ -731,7 +726,7 @@ function OfferLinkListener({ if (cancelled) return; const serverId = (profile as { serverId?: unknown } | null)?.serverId; if (typeof serverId !== "string" || !serverId) return; - router.replace(buildHostRootRoute(serverId)); + router.replace(buildOpenProjectRoute()); return; }) .catch((error) => { @@ -761,48 +756,87 @@ interface OpenProjectEventPayload { path?: unknown; } -function OpenProjectListener() { - const hosts = useHosts(); - const serverId = hosts[0]?.serverId ?? null; - const client = useHostRuntimeClient(serverId ?? ""); - const openProject = useOpenProject(serverId); - const pendingPathRef = useRef(null); +interface PendingOpenProjectRequest { + id: number; + serverId: string; + path: string; +} - useEffect(() => { - let disposed = false; - let unlisten: (() => void) | null = null; - const maybeOpenProject = (inputPath: string) => { - const nextPath = inputPath.trim(); +let nextOpenProjectRequestId = 1; + +function OpenProjectListener() { + const chooseHost = useHostChooser(); + const hostRegistryLoaded = useHostRegistryLoaded(); + const [request, setRequest] = useState(null); + const [pendingPath, setPendingPath] = useState(null); + const openProject = useOpenProject(request?.serverId ?? null); + + const openPathOnChosenHost = useCallback( + (path: string) => { + const nextPath = path.trim(); if (!nextPath) { return; } - pendingPathRef.current = nextPath; - - if (!serverId || !client) { + if (!hostRegistryLoaded) { + setPendingPath(nextPath); return; } - const pathToOpen = pendingPathRef.current; - pendingPathRef.current = null; - if (!pathToOpen) { - return; + chooseHost({ + title: "Choose host", + onChooseHost: (serverId) => { + setRequest({ + id: nextOpenProjectRequestId++, + serverId, + path: nextPath, + }); + }, + }); + }, + [chooseHost, hostRegistryLoaded], + ); + + useEffect(() => { + if (!hostRegistryLoaded || !pendingPath) { + return; + } + const nextPath = pendingPath; + setPendingPath(null); + openPathOnChosenHost(nextPath); + }, [hostRegistryLoaded, openPathOnChosenHost, pendingPath]); + + useEffect(() => { + if (!request) { + return; + } + let cancelled = false; + void openProject(request.path).then((result) => { + if (cancelled) { + return null; } - void openProject(pathToOpen).catch(() => undefined); + if (!result.ok) { + setRequest((current) => (current?.id === request.id ? null : current)); + return null; + } + + setRequest((current) => (current?.id === request.id ? null : current)); + return null; + }); + return () => { + cancelled = true; }; + }, [openProject, request]); - // Pull any path that was passed on cold start (before the listener existed). - // Store in the ref even if this effect instance is disposed — the next - // effect run picks it up via maybeOpenProject(pendingPathRef.current). + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | null = null; void getDesktopHost() ?.getPendingOpenProject?.() ?.then((pending) => { - if (pending) { - pendingPathRef.current = pending; - } if (!disposed && pending) { - maybeOpenProject(pending); + openPathOnChosenHost(pending); } return; }) @@ -814,7 +848,7 @@ function OpenProjectListener() { return; } const nextPath = typeof payload?.path === "string" ? payload.path.trim() : ""; - maybeOpenProject(nextPath); + openPathOnChosenHost(nextPath); }) .then((dispose) => { if (disposed) { @@ -826,13 +860,11 @@ function OpenProjectListener() { }) .catch(() => undefined); - maybeOpenProject(pendingPathRef.current ?? ""); - return () => { disposed = true; unlisten?.(); }; - }, [client, openProject, serverId]); + }, [openPathOnChosenHost]); return null; } @@ -842,9 +874,11 @@ function AppWithSidebar({ children }: { children: ReactNode }) { const params = useGlobalSearchParams<{ open?: string | string[] }>(); const hosts = useHosts(); const storeReady = useStoreReady(); - const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); + const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); + const routeHasKnownHost = + routeServerId !== null && hosts.some((host) => host.serverId === routeServerId); const shouldShowAppChrome = - storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId); + storeReady && (pathname === "/open-project" || pathname === "/sessions" || routeHasKnownHost); // Parse selectedAgentKey directly from pathname // useLocalSearchParams doesn't update when navigating between same-pattern routes @@ -899,6 +933,8 @@ function RootStack() { + + diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 46ba5d8fb..5bee5c4ad 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,39 +1,10 @@ -import { Redirect } from "expo-router"; -import { useHostRouteServerId } from "@/navigation/host-route-context"; -import { - resolveHostIndexRoute, - resolveWorkspaceSelectionStatus, -} from "@/navigation/host-runtime-bootstrap"; -import { StartupSplashScreen } from "@/screens/startup-splash-screen"; -import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; -import { - useIsLastWorkspaceSelectionHydrated, - useLastWorkspaceSelection, -} from "@/stores/navigation-active-workspace-store"; +import { Redirect, useLocalSearchParams } from "expo-router"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; export default function HostIndexRoute() { - const serverId = useHostRouteServerId(); - const workspaceSelection = useLastWorkspaceSelection(); - const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); - const workspaceSelectionWorkspaceId = - workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null; - const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); - const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId); - - if (!serverId || !isWorkspaceSelectionLoaded) { - return ; - } - - return ( - - ); + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + if (!serverId) return null; + // COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11. + return ; } diff --git a/packages/app/src/app/h/[serverId]/open-project.tsx b/packages/app/src/app/h/[serverId]/open-project.tsx index cbb1bc713..20e2d2696 100644 --- a/packages/app/src/app/h/[serverId]/open-project.tsx +++ b/packages/app/src/app/h/[serverId]/open-project.tsx @@ -1,18 +1,12 @@ -import { useLocalSearchParams } from "expo-router"; +import { Redirect } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { OpenProjectScreen } from "@/screens/open-project-screen"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; export default function HostOpenProjectRoute() { + // COMPAT(hostOpenProjectRoute): added 2026-06-11, remove after 2026-12-11. return ( - + ); } - -function HostOpenProjectRouteContent() { - const params = useLocalSearchParams<{ serverId?: string }>(); - const serverId = typeof params.serverId === "string" ? params.serverId : ""; - - return ; -} diff --git a/packages/app/src/app/h/[serverId]/sessions.tsx b/packages/app/src/app/h/[serverId]/sessions.tsx index 8dd5cb87a..2cb955b83 100644 --- a/packages/app/src/app/h/[serverId]/sessions.tsx +++ b/packages/app/src/app/h/[serverId]/sessions.tsx @@ -1,18 +1,7 @@ -import { useLocalSearchParams } from "expo-router"; -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { SessionsScreen } from "@/screens/sessions-screen"; +import { Redirect } from "expo-router"; +import { buildSessionsRoute } from "@/utils/host-routes"; -export default function HostAgentsRoute() { - return ( - - - - ); -} - -function HostAgentsRouteContent() { - const params = useLocalSearchParams<{ serverId?: string }>(); - const serverId = typeof params.serverId === "string" ? params.serverId : ""; - - return ; +export default function HostSessionsRoute() { + // COMPAT(hostSessionsRoute): added 2026-06-11, remove after 2026-12-11. + return ; } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 3d3bced9a..5fd1f94b9 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -24,6 +24,10 @@ import { parseWorkspaceOpenIntent, type WorkspaceOpenIntent, } from "@/utils/host-routes"; +import { + replaceBrowserRouteWithCanonicalHostWorkspaceRoute, + stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit, +} from "@/utils/host-route-browser"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { isWeb } from "@/constants/platform"; @@ -63,7 +67,7 @@ function stripOpenSearchParamFromBrowserUrl() { return; } url.searchParams.delete("open"); - window.history.replaceState(null, "", url.toString()); + replaceBrowserRouteWithCanonicalHostWorkspaceRoute(`${url.pathname}${url.search}${url.hash}`); } function clearConsumedOpenIntent(input: { @@ -102,6 +106,13 @@ function HostWorkspaceRouteContent() { ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") : ""; const openValue = getParamValue(globalParams.open); + useEffect(() => { + if (!serverId || !workspaceId) { + return; + } + stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit(); + }, [serverId, workspaceId]); + useEffect(() => { if (!openValue) { return; diff --git a/packages/app/src/app/open-project.tsx b/packages/app/src/app/open-project.tsx new file mode 100644 index 000000000..267905d2e --- /dev/null +++ b/packages/app/src/app/open-project.tsx @@ -0,0 +1,10 @@ +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { OpenProjectScreen } from "@/screens/open-project-screen"; + +export default function OpenProjectRoute() { + return ( + + + + ); +} diff --git a/packages/app/src/app/sessions.tsx b/packages/app/src/app/sessions.tsx new file mode 100644 index 000000000..b0ee1dc42 --- /dev/null +++ b/packages/app/src/app/sessions.tsx @@ -0,0 +1,10 @@ +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { SessionsScreen } from "@/screens/sessions-screen"; + +export default function SessionsRoute() { + return ( + + + + ); +} diff --git a/packages/app/src/app/settings/[section].tsx b/packages/app/src/app/settings/[section].tsx index e84d71434..19340ad70 100644 --- a/packages/app/src/app/settings/[section].tsx +++ b/packages/app/src/app/settings/[section].tsx @@ -4,10 +4,11 @@ import SettingsScreen from "@/screens/settings-screen"; import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes"; export default function SettingsSectionRoute() { - const params = useLocalSearchParams<{ section?: string }>(); + const params = useLocalSearchParams<{ section?: string; addHost?: string }>(); const rawSection = typeof params.section === "string" ? params.section : ""; const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general"; + const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null; const view = useMemo(() => ({ kind: "section" as const, section }), [section]); - return ; + return ; } diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 348d5decd..097fd1574 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -33,6 +33,7 @@ interface AgentListProps { onAgentSelect?: () => void; listFooterComponent?: ReactElement | null; showAttentionIndicator?: boolean; + showHostColumn?: boolean; } type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older"; @@ -152,11 +153,62 @@ function WorkspaceTitlePrefix({ ); } +function SessionRowBadges({ + agent, + archivedIcon, + pendingPermissionCount, + showDesktopAttention, +}: { + agent: AggregatedAgent; + archivedIcon: ReactElement; + pendingPermissionCount: number; + showDesktopAttention: boolean; +}) { + const { t } = useTranslation(); + return ( + <> + {agent.archivedAt ? ( + + ) : null} + {pendingPermissionCount > 0 ? ( + + ) : null} + {showDesktopAttention ? ( + + ) : null} + + ); +} + +function SessionRowTrailingAttention({ + isMobile, + showAttentionIndicator, + requiresAttention, +}: { + isMobile: boolean; + showAttentionIndicator: boolean; + requiresAttention: boolean | undefined; +}) { + const { t } = useTranslation(); + if (!isMobile || !showAttentionIndicator || !requiresAttention) { + return null; + } + return ( + + + + ); +} + function SessionRow({ agent, isMobile, selectedAgentId, showAttentionIndicator, + showHostColumn, onPress, onLongPress, }: { @@ -164,6 +216,7 @@ function SessionRow({ isMobile: boolean; selectedAgentId?: string; showAttentionIndicator: boolean; + showHostColumn: boolean; onPress: (agent: AggregatedAgent) => void; onLongPress: (agent: AggregatedAgent) => void; }) { @@ -200,6 +253,8 @@ function SessionRow({ () => , [theme.fontSize.xs, theme.colors.foregroundMuted], ); + const showDesktopAttention = + !isMobile && showAttentionIndicator && Boolean(agent.requiresAttention); return ( {agent.title || t("agentList.fallbackTitle")} - {agent.archivedAt ? ( - - ) : null} - {pendingPermissionCount > 0 ? ( - - ) : null} - {!isMobile && showAttentionIndicator && agent.requiresAttention ? ( - - ) : null} + - {isMobile && ( + {isMobile ? ( · {timeAgo} + {showHostColumn && agent.serverLabel ? ( + <> + · + + {agent.serverLabel} + + + ) : null} - )} + ) : null} - {!isMobile && ( + {!isMobile ? ( {projectName} + {showHostColumn ? ( + + {agent.serverLabel} + + ) : null} - )} - {isMobile && showAttentionIndicator && agent.requiresAttention ? ( - - - ) : null} + ); } @@ -304,6 +366,7 @@ export function AgentList({ onAgentSelect, listFooterComponent, showAttentionIndicator = true, + showHostColumn = false, }: AgentListProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -428,12 +491,21 @@ export function AgentList({ isMobile={isMobile} selectedAgentId={selectedAgentId} showAttentionIndicator={showAttentionIndicator} + showHostColumn={showHostColumn} onPress={handleAgentPress} onLongPress={handleAgentLongPress} /> ); }, - [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t], + [ + handleAgentLongPress, + handleAgentPress, + isMobile, + selectedAgentId, + showAttentionIndicator, + showHostColumn, + t, + ], ); const keyExtractor = useCallback((item: FlatListItem) => item.key, []); @@ -640,6 +712,14 @@ const styles = StyleSheet.create((theme) => ({ width: 72, textAlign: "right" as const, }, + columnMetaHost: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 0, + width: 120, + marginLeft: theme.spacing[4], + textAlign: "right" as const, + }, badge: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/host-status-dot.tsx b/packages/app/src/components/host-status-dot.tsx new file mode 100644 index 000000000..e56c24002 --- /dev/null +++ b/packages/app/src/components/host-status-dot.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + type HostRuntimeConnectionStatus, + useHostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; + +function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) { + if (status === "online") return theme.colors.palette.green[400]; + if (status === "connecting") return theme.colors.palette.amber[500]; + return theme.colors.palette.red[500]; +} + +export function HostStatusDot({ serverId }: { serverId: string }) { + const { theme } = useUnistyles(); + const status = useHostRuntimeConnectionStatus(serverId); + const backgroundColor = hostStatusDotColor(status, theme); + const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]); + + return ; +} + +const styles = StyleSheet.create(() => ({ + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, +})); diff --git a/packages/app/src/components/hosts/host-picker-constants.ts b/packages/app/src/components/hosts/host-picker-constants.ts new file mode 100644 index 000000000..d28760019 --- /dev/null +++ b/packages/app/src/components/hosts/host-picker-constants.ts @@ -0,0 +1,19 @@ +export const ADD_HOST_OPTION_ID = "__add_host__"; +export const ALL_HOSTS_OPTION_ID = "__all_hosts__"; + +export function getHostPickerLabel( + hosts: Array<{ label: string; serverId: string }>, + value: string, + config?: { includeAllHost?: boolean; includeAddHost?: boolean }, +): string { + if (config?.includeAllHost && value === ALL_HOSTS_OPTION_ID) { + return "All hosts"; + } + if (config?.includeAddHost && value === ADD_HOST_OPTION_ID) { + return "Add host"; + } + return ( + hosts.find((host) => host.serverId === value)?.label ?? + (config?.includeAllHost ? "All hosts" : "Host") + ); +} diff --git a/packages/app/src/components/hosts/host-picker.test.tsx b/packages/app/src/components/hosts/host-picker.test.tsx new file mode 100644 index 000000000..830d38e5b --- /dev/null +++ b/packages/app/src/components/hosts/host-picker.test.tsx @@ -0,0 +1,19 @@ +import { expect, it } from "vitest"; +import { + ADD_HOST_OPTION_ID, + ALL_HOSTS_OPTION_ID, + getHostPickerLabel, +} from "./host-picker-constants"; + +const hosts = [{ serverId: "host-a", label: "Host A" }]; + +it.each([ + ["host-a", undefined, "Host A"], + ["missing", undefined, "Host"], + [ALL_HOSTS_OPTION_ID, { includeAllHost: true }, "All hosts"], + ["missing", { includeAllHost: true }, "All hosts"], + [ALL_HOSTS_OPTION_ID, undefined, "Host"], + [ADD_HOST_OPTION_ID, { includeAddHost: true }, "Add host"], +])("resolves %s", (value, config, expected) => { + expect(getHostPickerLabel(hosts, value, config)).toBe(expected); +}); diff --git a/packages/app/src/components/hosts/host-picker.tsx b/packages/app/src/components/hosts/host-picker.tsx new file mode 100644 index 000000000..5eac4e84f --- /dev/null +++ b/packages/app/src/components/hosts/host-picker.tsx @@ -0,0 +1,302 @@ +import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; +import type { GestureResponderEvent } from "react-native"; +import { Plus, Server, Settings } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { HostStatusDot } from "@/components/host-status-dot"; +import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox"; +import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { orderHostsLocalFirst } from "@/types/host-connection"; +import { + ADD_HOST_OPTION_ID, + ALL_HOSTS_OPTION_ID, + getHostPickerLabel, +} from "./host-picker-constants"; + +export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel }; + +const SEARCHABLE_THRESHOLD = 10; +type RenderHostOption = NonNullable; +interface HostPickerHost { + serverId: string; + label: string; +} + +export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElement { + return ( + + + + ); +} + +export interface HostPickerOptionProps { + serverId: string; + label: string; + isLocal: boolean; + selected?: boolean; + active: boolean; + onPress: () => void; + onOpenHostSettings?: (serverId: string) => void; + localMarkerTestID?: string; + testID?: string; +} + +export function HostPickerOption({ + serverId, + label, + isLocal, + selected, + active, + onPress, + onOpenHostSettings, + localMarkerTestID, + testID, +}: HostPickerOptionProps): ReactElement { + const { theme } = useUnistyles(); + const leadingSlot = useMemo(() => , [serverId]); + const handleSettingsPress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onOpenHostSettings?.(serverId); + }, + [onOpenHostSettings, serverId], + ); + const trailingSlot = useMemo(() => { + if (!isLocal && !onOpenHostSettings) return undefined; + return ( + <> + {isLocal ? ( + + Local + + ) : null} + {onOpenHostSettings ? ( + + + + ) : null} + + ); + }, [ + handleSettingsPress, + isLocal, + label, + localMarkerTestID, + onOpenHostSettings, + theme.colors.foregroundMuted, + theme.iconSize.sm, + ]); + + return ( + + ); +} + +function SystemHostPickerOption({ + active, + selected, + onPress, + kind, + testID, +}: { + active: boolean; + selected?: boolean; + onPress: () => void; + kind: "add" | "all"; + testID?: string; +}): ReactElement { + const { theme } = useUnistyles(); + const Icon = kind === "add" ? Plus : Server; + const label = kind === "add" ? "Add host" : "All hosts"; + const leadingSlot = useMemo( + () => , + [Icon, theme.colors.foregroundMuted, theme.iconSize.sm], + ); + + return ( + + ); +} + +export interface HostPickerProps { + hosts: HostPickerHost[]; + value: string; + onSelect: (id: string) => void; + open: boolean; + onOpenChange: (open: boolean) => void; + anchorRef: React.RefObject; + includeAllHost?: boolean; + includeAddHost?: boolean; + onAddHost?: () => void; + showLocalMarker?: boolean; + onOpenHostSettings?: (serverId: string) => void; + searchable?: boolean; + title?: string; + desktopPlacement?: "top-start" | "bottom-start"; + desktopMinWidth?: number; + addHostTestID?: string; + hostOptionTestID?: (serverId: string) => string; + hostLocalMarkerTestID?: (serverId: string) => string; + children: ReactNode; +} + +export function HostPicker({ + hosts, + value, + onSelect, + open, + onOpenChange, + anchorRef, + includeAllHost, + includeAddHost, + onAddHost, + showLocalMarker, + onOpenHostSettings, + searchable, + title, + desktopPlacement = "top-start", + desktopMinWidth, + addHostTestID, + hostOptionTestID, + hostLocalMarkerTestID, + children, +}: HostPickerProps): ReactElement { + const localServerId = useLocalDaemonServerId(); + const orderedHosts = useMemo( + () => orderHostsLocalFirst(hosts, localServerId), + [hosts, localServerId], + ); + + const options = useMemo(() => { + const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label })); + if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" }); + if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" }); + return hostOptions; + }, [orderedHosts, includeAllHost, includeAddHost]); + + const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD; + + const handleSelect = useCallback( + (id: string) => { + if (id === ADD_HOST_OPTION_ID) { + onAddHost?.(); + } else { + onSelect(id); + } + onOpenChange(false); + }, + [onAddHost, onOpenChange, onSelect], + ); + + const handleOpenHostSettings = useCallback( + (serverId: string) => { + onOpenHostSettings?.(serverId); + onOpenChange(false); + }, + [onOpenHostSettings, onOpenChange], + ); + + const renderOption = useCallback( + ({ option, selected, active, onPress }) => { + if (option.id === ADD_HOST_OPTION_ID) { + return ( + + ); + } + if (option.id === ALL_HOSTS_OPTION_ID) { + return ( + + ); + } + return ( + + ); + }, + [ + addHostTestID, + hostLocalMarkerTestID, + hostOptionTestID, + localServerId, + onOpenHostSettings, + showLocalMarker, + handleOpenHostSettings, + ], + ); + + return ( + <> + {children} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + statusDotSlot: { + width: theme.iconSize.sm, + height: theme.iconSize.sm, + alignItems: "center", + justifyContent: "center", + }, + localMarker: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + marginLeft: theme.spacing[1], + }, +})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 55675e096..d2ab2213b 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,18 +1,7 @@ import { router, usePathname } from "expo-router"; -import { FolderPlus, History, Home, Plus, Search, Settings, X } from "lucide-react-native"; +import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import { - type Dispatch, - memo, - type ReactElement, - type RefObject, - type SetStateAction, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { Pressable, StyleSheet as RNStyleSheet, @@ -20,8 +9,6 @@ import { useWindowDimensions, View, type PressableStateCallbackType, - type StyleProp, - type ViewStyle, } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { @@ -34,40 +21,42 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu"; -import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useHostChooser } from "@/hosts/host-chooser"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; import { type SidebarProjectEntry, + type SidebarStatusWorkspacePlacement, useSidebarWorkspacesList, } from "@/hooks/use-sidebar-workspaces-list"; +import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces"; import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; +import { useHosts } from "@/runtime/host-runtime"; import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, selectIsAgentListOpen, usePanelStore, } from "@/stores/panel-store"; -import { resolveActiveHost } from "@/utils/active-host"; -import { formatConnectionStatus } from "@/utils/daemons"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state"; import { - buildHostOpenProjectRoute, + buildOpenProjectRoute, buildHostNewWorkspaceRoute, - buildHostSessionsRoute, + buildSessionsRoute, + buildSettingsAddHostRoute, + buildSettingsHostSectionRoute, buildSettingsRoute, - mapPathnameToServer, } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; @@ -85,14 +74,9 @@ interface LeftSidebarProps { interface SidebarSharedProps { theme: SidebarTheme; - activeServerId: string | null; - activeHostLabel: string; - activeHostStatusColor: string; - hostOptions: ComboboxOption[]; - hostTriggerRef: RefObject; - isHostPickerOpen: boolean; - setIsHostPickerOpen: Dispatch>; + statusWorkspacePlacements: SidebarStatusWorkspacePlacement[]; projects: SidebarProjectEntry[]; + projectNamesByKey: Map; isInitialLoad: boolean; isRevalidating: boolean; isManualRefresh: boolean; @@ -101,19 +85,14 @@ interface SidebarSharedProps { shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; handleRefresh: () => void; - handleHostSelect: (nextServerId: string) => void; handleNewWorkspaceNavigate: () => void; handleOpenProject: () => void; handleHome: () => void; handleSettings: () => void; labels: SidebarLabels; - renderHostOption: (input: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ReactElement; newWorkspaceKeys: ShortcutKey[][] | null; + handleAddHost: () => void; + handleOpenHostSettings: (serverId: string) => void; } interface SidebarLabels { @@ -154,70 +133,24 @@ export const LeftSidebar = memo(function LeftSidebar({ selectIsAgentListOpen(state, { isCompact: isCompactLayout }), ); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const pathname = usePathname(); - const daemons = useHosts(); - const activeDaemon = useMemo( - () => resolveActiveHost({ hosts: daemons, pathname }), - [daemons, pathname], - ); - const activeServerId = activeDaemon?.serverId ?? null; - const activeHostLabel = useMemo(() => { - if (!activeDaemon) return t("sidebar.host.noHost"); - const trimmed = activeDaemon.label?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId; - }, [activeDaemon, t]); - const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? ""); - const activeHostStatus = activeServerId - ? (activeHostSnapshot?.connectionStatus ?? "connecting") - : "idle"; - let activeHostStatusColor: string; - if (activeHostStatus === "online") activeHostStatusColor = theme.colors.palette.green[400]; - else if (activeHostStatus === "connecting") - activeHostStatusColor = theme.colors.palette.amber[500]; - else activeHostStatusColor = theme.colors.palette.red[500]; - const hostOptions = useMemo( - () => - daemons.map((daemon) => ({ - id: daemon.serverId, - label: daemon.label?.trim() || daemon.serverId, - })), - [daemons], - ); - const renderHostOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ( - - ), - [], - ); - const hostTriggerRef = useRef(null); - const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); - const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ - serverId: activeServerId, + const { + workspacePlacements, + projects, + projectNamesByKey, + isInitialLoad, + isRevalidating, + refreshAll, + } = useSidebarWorkspacesList({ enabled: isCompactLayout || isOpen, }); + const statusWorkspacePlacements = useStatusModeWorkspacePlacements({ + placements: workspacePlacements, + }); const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = useSidebarShortcutModel({ projects }); - const groupMode = useSidebarViewStore((state) => - activeServerId ? state.getGroupMode(activeServerId) : "project", - ); + const groupMode = useSidebarViewStore((state) => state.groupMode); const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -232,7 +165,8 @@ export const LeftSidebar = memo(function LeftSidebar({ } }, [isRevalidating, isManualRefresh]); - const openProjectPicker = useOpenProjectPicker(activeServerId); + const openProjectPicker = useOpenProjectPicker(); + const chooseHost = useHostChooser(); const handleOpenProjectMobile = useCallback(() => { showMobileAgent(); @@ -244,9 +178,13 @@ export const LeftSidebar = memo(function LeftSidebar({ }, [openProjectPicker]); const handleNewWorkspaceNavigate = useCallback(() => { - if (!activeServerId) return; - router.navigate(buildHostNewWorkspaceRoute(activeServerId)); - }, [activeServerId]); + chooseHost({ + title: "Choose host", + onChooseHost: (serverId) => { + router.push(buildHostNewWorkspaceRoute(serverId)); + }, + }); + }, [chooseHost]); const handleSettingsMobile = useCallback(() => { showMobileAgent(); @@ -257,38 +195,41 @@ export const LeftSidebar = memo(function LeftSidebar({ router.push(buildSettingsRoute()); }, []); - const handleHomeMobile = useCallback(() => { - if (!activeServerId) return; + const handleAddHostMobile = useCallback(() => { showMobileAgent(); - router.push(buildHostOpenProjectRoute(activeServerId)); - }, [activeServerId, showMobileAgent]); + router.push(buildSettingsAddHostRoute(Date.now())); + }, [showMobileAgent]); - const handleHomeDesktop = useCallback(() => { - if (!activeServerId) return; - router.push(buildHostOpenProjectRoute(activeServerId)); - }, [activeServerId]); + const handleAddHostDesktop = useCallback(() => { + router.push(buildSettingsAddHostRoute(Date.now())); + }, []); - const handleViewMoreNavigate = useCallback(() => { - if (!activeServerId) { - return; - } - router.push(buildHostSessionsRoute(activeServerId)); - }, [activeServerId]); - - const handleHostSelect = useCallback( - (nextServerId: string) => { - if (!nextServerId) { - return; - } - const nextPath = mapPathnameToServer(pathname, nextServerId); - setIsHostPickerOpen(false); - router.push(nextPath); + const handleOpenHostSettingsMobile = useCallback( + (serverId: string) => { + showMobileAgent(); + router.push(buildSettingsHostSectionRoute(serverId, "connections")); }, - [pathname], + [showMobileAgent], ); - const newWorkspaceKeys = useShortcutKeys("new-workspace"); + const handleOpenHostSettingsDesktop = useCallback((serverId: string) => { + router.push(buildSettingsHostSectionRoute(serverId, "connections")); + }, []); + const handleHomeMobile = useCallback(() => { + showMobileAgent(); + router.push(buildOpenProjectRoute()); + }, [showMobileAgent]); + + const handleHomeDesktop = useCallback(() => { + router.push(buildOpenProjectRoute()); + }, []); + + const handleViewMoreNavigate = useCallback(() => { + router.push(buildSessionsRoute()); + }, []); + + const newWorkspaceKeys = useShortcutKeys("new-workspace"); const labels = useMemo( (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), @@ -305,14 +246,9 @@ export const LeftSidebar = memo(function LeftSidebar({ const sharedProps = { theme, - activeServerId, - activeHostLabel, - activeHostStatusColor, - hostOptions, - hostTriggerRef, - isHostPickerOpen, - setIsHostPickerOpen, + statusWorkspacePlacements, projects, + projectNamesByKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -321,8 +257,6 @@ export const LeftSidebar = memo(function LeftSidebar({ shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, - handleHostSelect, - renderHostOption, labels, newWorkspaceKeys, }; @@ -339,6 +273,8 @@ export const LeftSidebar = memo(function LeftSidebar({ handleOpenProject={handleOpenProjectMobile} handleHome={handleHomeMobile} handleSettings={handleSettingsMobile} + handleAddHost={handleAddHostMobile} + handleOpenHostSettings={handleOpenHostSettingsMobile} handleViewMoreNavigate={handleViewMoreNavigate} /> ); @@ -353,91 +289,41 @@ export const LeftSidebar = memo(function LeftSidebar({ handleOpenProject={handleOpenProjectDesktop} handleHome={handleHomeDesktop} handleSettings={handleSettingsDesktop} + handleAddHost={handleAddHostDesktop} + handleOpenHostSettings={handleOpenHostSettingsDesktop} handleViewMore={handleViewMoreNavigate} /> ); }); -interface HostPickerTriggerProps { - triggerRef: React.Ref; - setIsHostPickerOpen: Dispatch>; - hostOptionsEmpty: boolean; - hostStatusDotStyle: StyleProp; - activeHostLabel: string; +function sidebarHostOptionTestID(serverId: string): string { + return `sidebar-host-row-${serverId}`; } -function HostPickerTrigger({ - triggerRef, - setIsHostPickerOpen, - hostOptionsEmpty, - hostStatusDotStyle, - activeHostLabel, -}: HostPickerTriggerProps) { - const pressableStyle = useCallback( - ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.hostTrigger, - hovered && styles.hostTriggerHovered, - ], - [], - ); - const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]); - return ( - - - - {activeHostLabel} - - - ); -} - -function HostSwitchOption({ - serverId, - label, - selected, - active, - onPress, -}: { - serverId: string; - label: string; - selected: boolean; - active: boolean; - onPress: () => void; -}) { - const snapshot = useHostRuntimeSnapshot(serverId); - const connectionStatus = snapshot?.connectionStatus ?? "connecting"; - - return ( - - ); +function sidebarHostLocalMarkerTestID(serverId: string): string { + return `sidebar-host-local-marker-${serverId}`; } function FooterIconButton({ + buttonRef, onPress, testID, accessibilityLabel, icon: Icon, + iconSize, theme, }: { onPress: () => void; testID: string; accessibilityLabel: string; icon: typeof FolderPlus; + iconSize?: number; theme: SidebarTheme; + buttonRef?: RefObject; }) { return ( {({ hovered }) => ( )} @@ -457,6 +343,59 @@ function FooterIconButton({ ); } +function SidebarHostPicker({ + theme, + onAddHost, + onOpenHostSettings, +}: { + theme: SidebarTheme; + onAddHost: () => void; + onOpenHostSettings: (serverId: string) => void; +}) { + const hosts = useHosts(); + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const handleSelect = useCallback( + (id: string) => { + onOpenHostSettings(id); + }, + [onOpenHostSettings], + ); + + const handleOpen = useCallback(() => setIsOpen(true), []); + + return ( + + + + ); +} + function AddProjectTooltipContent({ newAgentKeys, label, @@ -489,30 +428,14 @@ function HeaderIconTooltipContent({ function SidebarFooter({ theme, - activeServerId, - activeHostLabel, - hostStatusDotStyle, - hostOptions, - hostTriggerRef, - isHostPickerOpen, - setIsHostPickerOpen, - handleHostSelect, - renderHostOption, handleOpenProject, handleHome, handleSettings, labels, + handleAddHost, + handleOpenHostSettings, }: { theme: SidebarTheme; - activeServerId: string | null; - activeHostLabel: string; - hostStatusDotStyle: StyleProp; - hostOptions: ComboboxOption[]; - hostTriggerRef: RefObject; - isHostPickerOpen: boolean; - setIsHostPickerOpen: Dispatch>; - handleHostSelect: (nextServerId: string) => void; - renderHostOption: SidebarSharedProps["renderHostOption"]; handleOpenProject: () => void; handleHome: () => void; handleSettings: () => void; @@ -523,20 +446,19 @@ function SidebarFooter({ switchHost: string; searchHosts: string; }; + handleAddHost: () => void; + handleOpenHostSettings: (serverId: string) => void; }) { const newAgentKeys = useShortcutKeys("new-agent"); + return ( - - - + - ); } function MobileSidebar({ theme, - activeServerId, - activeHostLabel, - activeHostStatusColor, - hostOptions, - hostTriggerRef, - isHostPickerOpen, - setIsHostPickerOpen, + statusWorkspacePlacements, projects, + projectNamesByKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -601,14 +505,14 @@ function MobileSidebar({ shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, - handleHostSelect, - renderHostOption, newWorkspaceKeys, handleNewWorkspaceNavigate, handleOpenProject, handleHome, handleSettings, labels, + handleAddHost, + handleOpenHostSettings, insetsTop, insetsBottom, isOpen, @@ -638,21 +542,11 @@ function MobileSidebar({ }, [closeSidebar, gestureAnimatingRef]); const handleViewMore = useCallback(() => { - if (!activeServerId) { - return; - } translateX.value = -windowWidth; backdropOpacity.value = 0; closeSidebar(); handleViewMoreNavigate(); - }, [ - activeServerId, - backdropOpacity, - closeSidebar, - handleViewMoreNavigate, - translateX, - windowWidth, - ]); + }, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]); const handleWorkspacePress = useCallback(() => { closeSidebar(); @@ -752,11 +646,6 @@ function MobileSidebar({ [windowWidth, insetsTop, insetsBottom], ); - const hostStatusDotStyle = useMemo( - () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], - [activeHostStatusColor], - ); - const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); @@ -826,7 +715,7 @@ function MobileSidebar({ variant="compact" /> - + ) : ( @@ -890,14 +773,9 @@ function MobileSidebar({ function DesktopSidebar({ theme, - activeServerId, - activeHostLabel, - activeHostStatusColor, - hostOptions, - hostTriggerRef, - isHostPickerOpen, - setIsHostPickerOpen, + statusWorkspacePlacements, projects, + projectNamesByKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -906,14 +784,14 @@ function DesktopSidebar({ shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, - handleHostSelect, - renderHostOption, newWorkspaceKeys, handleNewWorkspaceNavigate, handleOpenProject, handleHome, handleSettings, labels, + handleAddHost, + handleOpenHostSettings, insetsTop, isOpen, handleViewMore, @@ -924,10 +802,6 @@ function DesktopSidebar({ const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); - const hostStatusDotStyle = useMemo( - () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], - [activeHostStatusColor], - ); const startWidthRef = useRef(sidebarWidth); const resizeWidth = useSharedValue(sidebarWidth); @@ -1007,18 +881,19 @@ function DesktopSidebar({ /> - + {isInitialLoad ? ( ) : ( {/* Resize handle - absolutely positioned over right border */} @@ -1053,7 +921,7 @@ function DesktopSidebar({ ); } -function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) { +function WorkspacesSectionHeader() { const { theme } = useUnistyles(); const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const commandCenterKeys = useShortcutKeys("toggle-command-center"); @@ -1096,7 +964,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) { - + @@ -1208,45 +1076,15 @@ const styles = StyleSheet.create((theme) => ({ sidebarDragArea: { position: "relative", }, - hostTrigger: { - flexDirection: "row", - alignItems: "center", - justifyContent: "flex-start", - gap: theme.spacing[2], - minWidth: 0, - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, - }, - hostTriggerHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, - hostStatusDot: { - width: 8, - height: 8, - borderRadius: theme.borderRadius.full, - }, - hostTriggerText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - flexShrink: 1, - minWidth: 0, - }, sidebarFooter: { flexDirection: "row", alignItems: "center", - justifyContent: "space-between", + justifyContent: "flex-start", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderTopWidth: 1, borderTopColor: theme.colors.border, }, - footerHostSlot: { - flexGrow: 0, - flexShrink: 1, - minWidth: 0, - marginRight: theme.spacing[2], - }, footerIconRow: { flexDirection: "row", alignItems: "center", @@ -1261,34 +1099,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], }, - hostPickerList: { - gap: theme.spacing[2], - }, - hostPickerOption: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - hostPickerOptionText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - hostPickerCancel: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface0, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - alignItems: "center", - }, - hostPickerCancelText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index 5a5b0616d..b400d73da 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -8,57 +8,29 @@ import { View, type PressableStateCallbackType, } from "react-native"; -import { Folder, FolderPlus } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { shortenPath } from "@/utils/shorten-path"; -import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { Folder } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { getOpenProjectFailureReason, type OpenProjectFailureReason } from "@/hooks/open-project"; import { useOpenProject } from "@/hooks/use-open-project"; -import type { OpenProjectFailure, OpenProjectResult } from "@/hooks/open-project"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useProjectPickerStore } from "@/stores/project-picker-store"; +import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; +import { shortenPath } from "@/utils/shorten-path"; import { isNative } from "@/constants/platform"; -import { useActiveServerId } from "@/hooks/use-active-server-id"; -import { - buildProjectPickerOptions, - isOpenableProjectPath, - type ProjectPickerOption, -} from "./project-picker-options"; +import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options"; interface PathRowProps { option: ProjectPickerOption; active: boolean; - openPathLabel: string; onSelect: (path: string) => void; } -type ProjectPickerErrorCode = NonNullable; - -function getProjectPickerErrorMessage( - result: OpenProjectResult | undefined, - thrownError: Error | null, - translateErrorCode: (errorCode: ProjectPickerErrorCode) => string, - translateOpenFailed: () => string, -): string | null { - if (result?.ok === false) { - if (result.errorCode) { - return translateErrorCode(result.errorCode); - } - return result.error; - } - if (thrownError) { - return thrownError.message || translateOpenFailed(); - } - return null; -} - -function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) { +function PathRow({ option, active, onSelect }: PathRowProps) { const { theme } = useUnistyles(); - const Icon = option.kind === "path" ? FolderPlus : Folder; + const { t } = useTranslation(); const path = option.path; - const displayPath = shortenPath(path); - const label = option.kind === "path" ? `${openPathLabel}: ${displayPath}` : displayPath; const handlePress = useCallback(() => { onSelect(path); }, [onSelect, path]); @@ -75,15 +47,22 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) { () => [styles.rowText, { color: theme.colors.foreground }], [theme.colors.foreground], ); + const rowActionTextStyle = useMemo( + () => [styles.rowActionText, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); return ( - + - {label} + {shortenPath(path)} + {option.kind === "path" ? ( + {t("projectPicker.openPath")} + ) : null} ); @@ -92,10 +71,10 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) { export function ProjectPickerModal() { const { theme } = useUnistyles(); const { t } = useTranslation(); - const serverId = useActiveServerId(); - - const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); - const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); + const request = useProjectPickerStore((state) => state.request); + const close = useProjectPickerStore((state) => state.close); + const serverId = request?.serverId ?? null; + const open = request !== null; const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); @@ -104,25 +83,10 @@ export function ProjectPickerModal() { const inputRef = useRef(null); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [openErrorReason, setOpenErrorReason] = useState(null); const openProject = useOpenProject(serverId); - const openProjectMutation = useMutation({ - mutationFn: (path: string) => openProject(path), - onSuccess: (result) => { - if (result.ok) { - setOpen(false); - } - }, - }); - const { mutate: submitPath, reset: resetSubmit, isPending: isSubmitting } = openProjectMutation; - const submitResult = openProjectMutation.data; - const errorMessage = getProjectPickerErrorMessage( - submitResult, - openProjectMutation.error, - (errorCode) => t(`projectPicker.errors.${errorCode}`), - () => t("projectPicker.errors.open_failed"), - ); - const directorySuggestionsQuery = useQuery({ queryKey: ["project-picker-directory-suggestions", serverId, query], queryFn: async () => { @@ -142,56 +106,72 @@ export function ProjectPickerModal() { retry: false, }); - const options = useMemo(() => { - return buildProjectPickerOptions({ - recommendedPaths, - serverPaths: directorySuggestionsQuery.data ?? [], - query, - }); - }, [query, directorySuggestionsQuery.data, recommendedPaths]); - - const handleClose = useCallback(() => { - setOpen(false); - }, [setOpen]); - - const handleSelectPath = useCallback( - (path: string) => { - const trimmed = path.trim(); - if (!trimmed || !client || !serverId) return; - submitPath(trimmed); - }, - [client, serverId, submitPath], + const options = useMemo( + () => + buildProjectPickerOptions({ + recommendedPaths, + serverPaths: directorySuggestionsQuery.data ?? [], + query, + }), + [directorySuggestionsQuery.data, query, recommendedPaths], ); - const handleSubmitCustom = useCallback(() => { - const trimmed = query.trim(); - if (!isOpenableProjectPath(trimmed)) return; - handleSelectPath(trimmed); - }, [handleSelectPath, query]); - - const handleChangeQuery = useCallback( - (text: string) => { - setQuery(text); - setActiveIndex(0); - resetSubmit(); - }, - [resetSubmit], - ); - - // Reset state when opening/closing - useEffect(() => { - resetSubmit(); - if (!open) { - return; + const openErrorMessage = useMemo(() => { + if (!openErrorReason) { + return null; } - setQuery(""); + return t(`projectPicker.errors.${openErrorReason}`); + }, [openErrorReason, t]); + + const handleClose = useCallback(() => { + close(); + }, [close]); + + const handleSelectPath = useCallback( + async (path: string) => { + const trimmed = path.trim(); + if (!trimmed || !client || !serverId) return; + + setOpenErrorReason(null); + setIsSubmitting(true); + try { + const result = await openProject(trimmed); + if (result.ok) { + close(); + return; + } + + setOpenErrorReason(getOpenProjectFailureReason(result)); + } finally { + setIsSubmitting(false); + } + }, + [client, close, openProject, serverId], + ); + + const submitActiveOption = useCallback(() => { + const option = options[activeIndex]; + if (!option) return; + void handleSelectPath(option.path); + }, [activeIndex, handleSelectPath, options]); + + const handleChangeQuery = useCallback((text: string) => { + setQuery(text); setActiveIndex(0); - const id = setTimeout(() => inputRef.current?.focus(), 0); - return () => clearTimeout(id); - }, [open, resetSubmit]); + setOpenErrorReason(null); + }, []); + + useEffect(() => { + if (open) { + setQuery(""); + setActiveIndex(0); + setOpenErrorReason(null); + const id = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(id); + } + }, [open]); - // Clamp active index useEffect(() => { if (!open) return; if (activeIndex >= options.length) { @@ -199,7 +179,6 @@ export function ProjectPickerModal() { } }, [activeIndex, options.length, open]); - // Keyboard navigation useEffect(() => { if (!open || isNative) return; @@ -209,17 +188,13 @@ export function ProjectPickerModal() { if (key === "Escape") { event.preventDefault(); - handleClose(); + close(); return; } if (key === "Enter") { event.preventDefault(); - if (options.length > 0 && activeIndex < options.length) { - handleSelectPath(options[activeIndex].path); - } else if (query.trim()) { - handleSubmitCustom(); - } + submitActiveOption(); return; } @@ -238,7 +213,7 @@ export function ProjectPickerModal() { window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [activeIndex, handleClose, handleSelectPath, handleSubmitCustom, open, options, query]); + }, [close, open, options.length, submitActiveOption]); const panelStyle = useMemo( () => [ @@ -258,14 +233,14 @@ export function ProjectPickerModal() { () => [styles.input, { color: theme.colors.foreground }], [theme.colors.foreground], ); - const errorTextStyle = useMemo( - () => [styles.errorText, { color: theme.colors.destructive }], - [theme.colors.destructive], - ); const emptyTextStyle = useMemo( () => [styles.emptyText, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); + const errorTextStyle = useMemo( + () => [styles.emptyText, { color: theme.colors.destructive }], + [theme.colors.destructive], + ); if (!serverId) return null; @@ -288,9 +263,8 @@ export function ProjectPickerModal() { autoFocus editable={!isSubmitting} returnKeyType="go" - onSubmitEditing={handleSubmitCustom} + onSubmitEditing={submitActiveOption} /> - {errorMessage ? {errorMessage} : null} {isSubmitting ? {t("projectPicker.opening")} : null} - {!isSubmitting && options.length === 0 ? ( + {!isSubmitting && openErrorMessage ? ( + {openErrorMessage} + ) : null} + {!isSubmitting && options.length === 0 && !query.trim() ? ( {t("projectPicker.empty")} ) : null} - {!isSubmitting && options.length > 0 ? ( + {!isSubmitting && !(options.length === 0 && !query.trim()) ? ( <> {options.map((option, index) => ( ))} @@ -353,11 +329,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], outlineStyle: "none", } as object, - errorText: { - marginTop: theme.spacing[2], - fontSize: theme.fontSize.sm, - lineHeight: 18, - }, results: { flexGrow: 0, }, @@ -383,8 +354,13 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: "400", lineHeight: 20, + flex: 1, flexShrink: 1, }, + rowActionText: { + fontSize: theme.fontSize.xs, + fontWeight: "500", + }, emptyText: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[4], diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index f475772d4..72700a62d 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -152,29 +152,27 @@ function makeHost(): HostProfile { }; } +function setHostProfiles(hosts: HostProfile[]): void { + ( + getHostRuntimeStore() as unknown as { + setHostsAndSync: (hosts: HostProfile[]) => void; + } + ).setHostsAndSync(hosts); +} + function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { - getHostRuntimeStore().syncHosts([makeHost()]); + setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); useSessionStore .getState() .setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ - projectOrderByServerId: { - [SERVER_ID]: ["project-a", "project-b"], - }, - workspaceOrderByServerAndProject: { - [`${SERVER_ID}::project-a`]: [ - `${SERVER_ID}:a-main`, - `${SERVER_ID}:a-one`, - `${SERVER_ID}:a-two`, - ], - [`${SERVER_ID}::project-b`]: [ - `${SERVER_ID}:b-main`, - `${SERVER_ID}:b-one`, - `${SERVER_ID}:b-two`, - ], + projectOrder: ["project-a", "project-b"], + workspaceOrderByProject: { + ["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`], + ["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`], }, }); }); @@ -258,7 +256,7 @@ function WorkspaceSelectionProbe({ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { counts.frame += 1; - const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID }); + const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID }); return ( <> @@ -345,11 +343,11 @@ describe("sidebar workspace render isolation", () => { container = null; act(() => { pathnameState.value = "/"; - getHostRuntimeStore().syncHosts([]); + setHostProfiles([]); useSessionStore.getState().clearSession(SERVER_ID); useSidebarOrderStore.setState({ - projectOrderByServerId: {}, - workspaceOrderByServerAndProject: {}, + projectOrder: [], + workspaceOrderByProject: {}, }); }); }); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 8c433cade..75e123535 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2,13 +2,18 @@ import { View, Text, Pressable, + Platform, ActivityIndicator, + StatusBar, ScrollView, type GestureResponderEvent, type PressableStateCallbackType, type ViewStyle, } from "react-native"; +import * as Haptics from "expo-haptics"; +import { useMutation } from "@tanstack/react-query"; import { ProjectIconView } from "@/components/project-icon-view"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; import { memo, useCallback, @@ -30,21 +35,27 @@ import { import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; +import * as Clipboard from "expo-clipboard"; +import { DiffStat } from "@/components/diff-stat"; import { + Archive, CircleAlert, + CircleCheck, ChevronDown, ChevronRight, + Copy, ExternalLink, GitPullRequest, Settings, MoreVertical, + Pencil, Plus, Trash2, } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { @@ -55,10 +66,11 @@ import { import { useSidebarWorkspaceEntry, type SidebarProjectEntry, + type SidebarStatusWorkspacePlacement, type SidebarWorkspaceEntry, + type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; -import { useSessionStore } from "@/stores/session-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { @@ -69,27 +81,45 @@ import { } from "@/components/ui/dropdown-menu"; import { SyncedLoader } from "@/components/synced-loader"; import { useToast } from "@/contexts/toast-context"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; -import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row"; -import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; +import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import { confirmDialog } from "@/utils/confirm-dialog"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; -import { SidebarWorkspaceShortcutBadge } from "@/components/sidebar/sidebar-workspace-row-content"; import { - useProjectNamesMap, - useStatusModeWorkspaceEntries, -} from "@/hooks/use-status-mode-workspaces"; + SidebarWorkspaceRowFrame, + SidebarWorkspaceRowContent, + SidebarWorkspaceShortcutBadge, + SidebarWorkspaceTrailingActionBase, + SidebarWorkspaceTrailingActionOverlay, + SidebarWorkspaceTrailingActionSlot, +} from "@/components/sidebar/sidebar-workspace-row-content"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; +import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; import type { PrHint } from "@/git/use-pr-status-query"; -import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; +import { + buildSidebarProjectRowModel, + resolveSidebarProjectIconTarget, + type SidebarProjectHostTarget, +} from "@/utils/sidebar-project-row-model"; +import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; +import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; +import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; +import { + getCurrentProjectRemoveReadiness, + removeProjectFromHosts, +} from "@/projects/project-remove"; import { isWeb as platformIsWeb, isNative as platformIsNative, @@ -97,10 +127,11 @@ import { } from "@/constants/platform"; import { getDesktopHost } from "@/desktop/host"; -const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; +const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; +const WORKSPACE_STATUS_DOT_WIDTH = 14; const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; const DEFAULT_STATUS_DOT_OFFSET = 0; @@ -109,11 +140,15 @@ const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); +const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedPlus = withUnistyles(Plus); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); +const ThemedCopy = withUnistyles(Copy); +const ThemedArchive = withUnistyles(Archive); +const ThemedPencil = withUnistyles(Pencil); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, @@ -167,43 +202,33 @@ function isWorkspaceSelected(input: { function isProjectSelectedByRoute(input: { selection: ActiveWorkspaceSelection | null; project: SidebarProjectEntry; - serverId: string | null; enabled: boolean; }): boolean { return ( input.enabled && - input.selection?.serverId === input.serverId && input.project.workspaces.some( - (workspace) => workspace.workspaceId === input.selection?.workspaceId, + (workspace) => + workspace.serverId === input.selection?.serverId && + workspace.workspaceId === input.selection.workspaceId, ) ); } -function navigateToNewWorkspaceForProject(input: { - serverId: string | null; - project: SidebarProjectEntry; - displayName: string; - onWorkspacePress?: () => void; -}) { - if (!input.serverId) { - return; - } - input.onWorkspacePress?.(); - router.navigate( - buildHostNewWorkspaceRoute(input.serverId, input.project.iconWorkingDir, { - displayName: input.displayName, - projectId: input.project.projectKey, - }) as Href, - ); -} - function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string { return selection ? `${selection.serverId}:${selection.workspaceId}` : ""; } +function selectionForSelectedWorkspace( + selected: boolean, + workspace: SidebarWorkspaceEntry, +): ActiveWorkspaceSelection | null { + return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null; +} + interface SidebarWorkspaceListProps { + statusWorkspacePlacements: SidebarStatusWorkspacePlacement[]; projects: SidebarProjectEntry[]; - serverId: string | null; + projectNamesByKey: Map; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectKey: string) => void; shortcutIndexByWorkspaceKey: Map; @@ -225,8 +250,7 @@ interface ProjectHeaderRowProps { selected?: boolean; chevron: "expand" | "collapse" | null; onPress: () => void; - serverId: string | null; - canCreateWorktree: boolean; + worktreeTarget: SidebarProjectHostTarget | null; isProjectActive?: boolean; onWorkspacePress?: () => void; onWorktreeCreated?: (workspaceId: string) => void; @@ -241,6 +265,40 @@ interface ProjectHeaderRowProps { dragHandleProps?: DraggableListDragHandleProps; } +interface WorkspaceRowInnerProps { + workspace: SidebarWorkspaceEntry; + subtitle?: string | null; + selected: boolean; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onPress: () => void; + drag: () => void; + isDragging: boolean; + isArchiving: boolean; + isCreating?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + menuController: ReturnType | null; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + onArchive?: () => void; + onCopyBranchName?: () => void; + onCopyPath?: () => void; + onRename?: () => void; + onMarkAsRead?: () => void; + archiveShortcutKeys?: ShortcutKey[][] | null; +} + +function getWorkspaceArchiveStatus( + isWorktree: boolean, + archiveStatus: "idle" | "pending" | "success", + isArchivingWorkspace: boolean, +): "idle" | "pending" | "success" { + if (isWorktree) return archiveStatus; + if (isArchivingWorkspace) return "pending"; + return "idle"; +} + export function PrBadge({ hint }: { hint: PrHint }) { const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); @@ -298,6 +356,29 @@ function projectKebabStyle({ return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered]; } +function workspaceKebabStyle({ + hovered = false, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.kebabButton, hovered && styles.kebabButtonHovered]; +} + +function getProjectWorkspaceRowStyle({ + isDragging, + selected, + isHovered, +}: { + isDragging: boolean; + selected: boolean; + isHovered: boolean; +}) { + return [ + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.workspaceRowHovered, + ]; +} + function noop() {} const prBadgeStyles = StyleSheet.create((theme) => ({ @@ -408,7 +489,7 @@ function ProjectLeadingVisual({ function ProjectRowTrailingActions({ project, displayName, - canCreateWorktree, + worktreeTarget, isHovered, isMobileBreakpoint, isProjectActive, @@ -418,7 +499,7 @@ function ProjectRowTrailingActions({ }: { project: SidebarProjectEntry; displayName: string; - canCreateWorktree: boolean; + worktreeTarget: SidebarProjectHostTarget | null; isHovered: boolean; isMobileBreakpoint: boolean; isProjectActive: boolean; @@ -429,7 +510,7 @@ function ProjectRowTrailingActions({ const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint; return ( - {canCreateWorktree ? ( + {worktreeTarget ? ( ; const settingsLeadingIcon = ; +const copyLeadingIcon = ; +const markAsReadLeadingIcon = ( + +); +const archiveLeadingIcon = ; +const renameLeadingIcon = ; const openInNewWindowLeadingIcon = ( ); @@ -546,6 +633,175 @@ function ProjectKebabMenu({ ); } +function WorkspaceRowRightGroup({ + workspace, + isHovered, + isTouchPlatform, + isCreating, + showShortcutBadge, + shortcutNumber, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, + onArchive, + onMarkAsRead, + onCopyBranchName, + onCopyPath, + onRename, +}: { + workspace: SidebarWorkspaceEntry; + isHovered: boolean; + isTouchPlatform: boolean; + isCreating: boolean; + showShortcutBadge: boolean; + shortcutNumber: number | null; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + archiveShortcutKeys?: ShortcutKey[][] | null; + onArchive?: () => void; + onMarkAsRead?: () => void; + onCopyBranchName?: () => void; + onCopyPath?: () => void; + onRename?: () => void; +}) { + const { t } = useTranslation(); + const showShortcut = showShortcutBadge && shortcutNumber !== null; + const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); + const showKebabInSlot = showKebab && !showShortcut; + const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); + + return ( + <> + {isCreating ? ( + {t("sidebar.workspace.status.creating")} + ) : null} + {shouldRenderActionSlot ? ( + + + {workspace.diffStat ? ( + + ) : null} + + + {onArchive ? ( + + ) : null} + + + ) : null} + + ); +} + +function WorkspaceKebabMenu({ + workspaceKey, + onCopyPath, + onCopyBranchName, + onRename, + onMarkAsRead, + onArchive, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, +}: { + workspaceKey: string; + onCopyPath?: () => void; + onCopyBranchName?: () => void; + onRename?: () => void; + onMarkAsRead?: () => void; + onArchive: () => void; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + archiveShortcutKeys?: ShortcutKey[][] | null; +}) { + const { t } = useTranslation(); + const archiveTrailing = useMemo( + () => (archiveShortcutKeys ? : null), + [archiveShortcutKeys], + ); + return ( + + + {renderKebabTriggerIcon} + + + {onCopyPath ? ( + + {t("sidebar.workspace.actions.copyPath")} + + ) : null} + {onCopyBranchName ? ( + + {t("sidebar.workspace.actions.copyBranchName")} + + ) : null} + {onRename ? ( + + {t("sidebar.workspace.actions.rename")} + + ) : null} + {onMarkAsRead ? ( + + Mark as read + + ) : null} + + {archiveLabel ?? t("sidebar.workspace.actions.archive")} + + + + ); +} + function ProjectIcon({ iconDataUri, placeholderInitial, @@ -721,6 +977,286 @@ function NewWorktreeButton({ ); } +function NewWorkspaceGhostRow({ + project, + displayName, + worktreeTarget, + onWorkspacePress, +}: { + project: SidebarProjectEntry; + displayName: string; + worktreeTarget: SidebarProjectHostTarget; + onWorkspacePress?: () => void; +}) { + const { t } = useTranslation(); + const handlePress = useCallback(() => { + onWorkspacePress?.(); + router.navigate( + buildHostNewWorkspaceRoute(worktreeTarget.serverId, worktreeTarget.iconWorkingDir, { + displayName, + projectId: project.projectKey, + }) as Href, + ); + }, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]); + const rowStyle = useCallback( + ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.newWorkspaceGhostRow, + (Boolean(hovered) || pressed) && styles.newWorkspaceGhostRowHovered, + ], + [], + ); + + return ( + + {({ hovered, pressed }) => ( + <> + + + + + {t("sidebar.workspace.actions.newWorkspace")} + + + )} + + ); +} + +function useLongPressDragInteraction(input: { + drag: () => void; + menuController: ReturnType | null; +}) { + const didLongPressRef = useRef(false); + const dragArmedRef = useRef(false); + const dragActivatedRef = useRef(false); + const didStartDragRef = useRef(false); + const scrollIntentRef = useRef(false); + const menuOpenedRef = useRef(false); + const touchStartRef = useRef<{ x: number; y: number } | null>(null); + const touchCurrentRef = useRef<{ x: number; y: number } | null>(null); + const dragArmTimerRef = useRef | null>(null); + const contextMenuTimerRef = useRef | null>(null); + + const clearTimers = useCallback(() => { + if (dragArmTimerRef.current) { + clearTimeout(dragArmTimerRef.current); + dragArmTimerRef.current = null; + } + if (contextMenuTimerRef.current) { + clearTimeout(contextMenuTimerRef.current); + contextMenuTimerRef.current = null; + } + }, []); + + const openContextMenuAtStartPoint = useCallback(() => { + if (!input.menuController || !touchStartRef.current) { + return; + } + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + input.menuController.setAnchorRect({ + x: touchStartRef.current.x, + y: touchStartRef.current.y + statusBarHeight, + width: 0, + height: 0, + }); + input.menuController.setOpen(true); + menuOpenedRef.current = true; + didLongPressRef.current = true; + }, [input.menuController]); + + const handleLongPress = useCallback(() => { + // Manual timers own long-press behavior on mobile. + }, []); + + useEffect(() => { + return () => { + clearTimers(); + }; + }, [clearTimers]); + + const armTimers = useCallback(() => { + clearTimers(); + + const DRAG_ARM_DELAY_MS = 180; + const DRAG_ARM_STATIONARY_SLOP_PX = 4; + const CONTEXT_MENU_DELAY_MS = 450; + const CONTEXT_MENU_STATIONARY_SLOP_PX = 6; + + dragArmTimerRef.current = setTimeout(() => { + if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { + return; + } + const start = touchStartRef.current; + const current = touchCurrentRef.current ?? start; + if (!start || !current) { + return; + } + const dx = current.x - start.x; + const dy = current.y - start.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance > DRAG_ARM_STATIONARY_SLOP_PX) { + return; + } + dragArmedRef.current = true; + dragActivatedRef.current = true; + didLongPressRef.current = true; + void Haptics.selectionAsync().catch(() => {}); + input.drag(); + }, DRAG_ARM_DELAY_MS); + + if (!input.menuController || platformIsWeb) { + return; + } + + contextMenuTimerRef.current = setTimeout(() => { + if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { + return; + } + const start = touchStartRef.current; + const current = touchCurrentRef.current ?? start; + if (!start || !current) { + return; + } + const dx = current.x - start.x; + const dy = current.y - start.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) { + return; + } + void Haptics.selectionAsync().catch(() => {}); + openContextMenuAtStartPoint(); + }, CONTEXT_MENU_DELAY_MS); + }, [clearTimers, input, openContextMenuAtStartPoint]); + + const handleDragIntent = useCallback( + (_details: { dx: number; dy: number; distance: number }) => { + if (!dragActivatedRef.current) { + return; + } + didStartDragRef.current = true; + didLongPressRef.current = true; + clearTimers(); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + }, + [clearTimers], + ); + + const handleScrollIntent = useCallback( + (_details: { dx: number; dy: number; distance: number }) => { + scrollIntentRef.current = true; + didLongPressRef.current = true; + clearTimers(); + }, + [clearTimers], + ); + + const handleSwipeIntent = useCallback( + (_details: { dx: number; dy: number; distance: number }) => { + didLongPressRef.current = true; + clearTimers(); + }, + [clearTimers], + ); + + const handlePressIn = useCallback( + (event: GestureResponderEvent) => { + didLongPressRef.current = false; + dragArmedRef.current = false; + dragActivatedRef.current = false; + didStartDragRef.current = false; + scrollIntentRef.current = false; + menuOpenedRef.current = false; + touchStartRef.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + touchCurrentRef.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + armTimers(); + }, + [armTimers], + ); + + const handleTouchMove = useCallback( + (event: GestureResponderEvent) => { + const start = touchStartRef.current; + if (!start || didStartDragRef.current || menuOpenedRef.current) { + return; + } + + const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent; + const x = touch?.pageX; + const y = touch?.pageY; + if (typeof x !== "number" || typeof y !== "number") { + return; + } + + const current = { x, y }; + touchCurrentRef.current = current; + const dx = current.x - start.x; + const dy = current.y - start.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const decision = decideLongPressMove({ + dragArmed: dragArmedRef.current, + didStartDrag: didStartDragRef.current, + startPoint: start, + currentPoint: current, + }); + + if (decision === "vertical_scroll") { + handleScrollIntent({ dx, dy, distance }); + return; + } + + if (decision === "horizontal_swipe" || decision === "cancel_long_press") { + handleSwipeIntent({ dx, dy, distance }); + return; + } + + if (decision === "start_drag") { + handleDragIntent({ dx, dy, distance }); + } + }, + [handleDragIntent, handleScrollIntent, handleSwipeIntent], + ); + + const handlePressOut = useCallback(() => { + clearTimers(); + dragArmedRef.current = false; + dragActivatedRef.current = false; + touchStartRef.current = null; + touchCurrentRef.current = null; + }, [clearTimers]); + + return { + didLongPressRef, + handleLongPress, + handlePressIn, + handleTouchMove, + handlePressOut, + }; +} + function ProjectHeaderRow({ project, displayName, @@ -729,8 +1265,7 @@ function ProjectHeaderRow({ selected = false, chevron, onPress, - serverId, - canCreateWorktree, + worktreeTarget, isProjectActive = false, onWorkspacePress, onWorktreeCreated: _onWorktreeCreated, @@ -747,8 +1282,17 @@ function ProjectHeaderRow({ const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = useIsCompactFormFactor(); const handleBeginWorkspaceSetup = useCallback(() => { - navigateToNewWorkspaceForProject({ serverId, project, displayName, onWorkspacePress }); - }, [displayName, onWorkspacePress, project, serverId]); + if (!worktreeTarget) { + return; + } + onWorkspacePress?.(); + router.navigate( + buildHostNewWorkspaceRoute(worktreeTarget.serverId, worktreeTarget.iconWorkingDir, { + displayName, + projectId: project.projectKey, + }) as Href, + ); + }, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]); const interaction = useLongPressDragInteraction({ drag, menuController, @@ -804,7 +1348,7 @@ function ProjectHeaderRow({ { + if (interaction.didLongPressRef.current) { + interaction.didLongPressRef.current = false; + return; + } + onPress(); + }, [interaction.didLongPressRef, onPress]); + + const accessibilityState = useMemo(() => ({ selected }), [selected]); + + return ( + + {({ isHovered, hoverHandlers }) => { + const isDesktop = !isTouchPlatform; + const showScriptsIcon = isDesktop && workspace.hasRunningScripts; + const hasRunningService = workspace.scripts.some( + (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", + ); + let scriptIconKind: "service" | "command" | null = null; + if (showScriptsIcon) { + scriptIconKind = hasRunningService ? "service" : "command"; + } + const workspaceRowStyle = getProjectWorkspaceRowStyle({ + isDragging, + selected, + isHovered, + }); + return ( + } + style={styles.workspaceRowContainer} + {...hoverHandlers} + > + + + + + + + ); + }} + + ); +} + +function WorkspaceRowWithMenu({ + workspace, + subtitle, + selected, + shortcutNumber, + showShortcutBadge, + onPress, + drag, + isDragging, + dragHandleProps, + canCopyBranchName, + isCreating = false, +}: { workspace: SidebarWorkspaceEntry; + subtitle?: string | null; + selected: boolean; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onPress: () => void; + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableListDragHandleProps; + canCopyBranchName: boolean; + isCreating?: boolean; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); + const [isRenameOpen, setIsRenameOpen] = useState(false); + const workspaceDirectory = resolveWorkspaceDirectory({ + workspaceDirectory: workspace.workspaceDirectory, + }); + const worktreeArchiveStatus = useCheckoutGitActionsStore((state) => + workspaceDirectory + ? state.getStatus({ + serverId: workspace.serverId, + cwd: workspaceDirectory, + actionId: "archive-worktree", + }) + : "idle", + ); + const isWorktree = workspace.workspaceKind === "worktree"; + const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace; + const redirectAfterArchive = useCallback(() => { + redirectIfArchivingActiveWorkspace({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace), + }); + }, [selected, workspace]); + + const archiveController = useWorkspaceArchive({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + workspaceKind: workspace.workspaceKind, + name: workspace.name, + ...toWorktreeArchiveRisk(workspace), + onArchiveStarted: redirectAfterArchive, + onSetHiding: setIsHidingWorkspace, + }); + + const handleArchive = useCallback(() => { + if (isArchiving) { + return; + } + archiveController.archive(); + }, [archiveController, isArchiving]); + + const handleCopyPath = useCallback(() => { + let copyTargetDirectory: string; + try { + copyTargetDirectory = requireWorkspaceDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t("sidebar.workspace.toasts.workspacePathUnavailable"), + ); + return; + } + void Clipboard.setStringAsync(copyTargetDirectory); + toast.copied(t("sidebar.workspace.toasts.pathCopied")); + }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); + + const handleCopyBranchName = useCallback(() => { + if (!workspace.currentBranch) { + return; + } + void Clipboard.setStringAsync(workspace.currentBranch); + toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); + }, [t, toast, workspace.currentBranch]); + + const renameMutation = useMutation({ + mutationFn: async (title: string) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); + } + await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); + }, + }); + + const handleOpenRename = useCallback(() => { + setIsRenameOpen(true); + }, []); + + const handleCloseRename = useCallback(() => { + setIsRenameOpen(false); + }, []); + + const handleSubmitRename = useCallback( + async (value: string) => { + await renameMutation.mutateAsync(value.trim()); + }, + [renameMutation], + ); + + const archiveShortcutKeys = useShortcutKeys("archive-worktree"); + const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + const handleMarkAsRead = useCallback(() => { + void clearAttention().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); + }); + }, [clearAttention, toast]); + + useKeyboardActionHandler({ + handlerId: `worktree-archive-${workspace.workspaceKey}`, + actions: ["worktree.archive"], + enabled: selected && !isArchiving, + priority: 0, + handle: () => { + handleArchive(); + return true; + }, + }); + + return ( + <> + + + + ); +} + +interface WorkspaceRowItemProps { + workspace: SidebarWorkspacePlacement; + subtitle?: string | null; shortcutNumber: number | null; showShortcutBadge: boolean; canCopyBranchName: boolean; isCreating?: boolean; selectionEnabled: boolean; - serverId: string | null; activeWorkspaceSelection: ActiveWorkspaceSelection | null; onWorkspacePress?: () => void; drag?: () => void; @@ -885,12 +1732,12 @@ interface WorkspaceRowItemProps { function WorkspaceRowItem({ workspace, + subtitle, shortcutNumber, showShortcutBadge, canCopyBranchName, isCreating = false, selectionEnabled, - serverId, activeWorkspaceSelection, onWorkspacePress, drag, @@ -898,16 +1745,17 @@ function WorkspaceRowItem({ dragHandleProps, }: WorkspaceRowItemProps) { const handlePress = useCallback(() => { - if (!serverId) { + if (!workspace.serverId) { return; } onWorkspacePress?.(); - navigateToWorkspace(serverId, workspace.workspaceId); - }, [serverId, onWorkspacePress, workspace.workspaceId]); + navigateToWorkspace(workspace.serverId, workspace.workspaceId); + }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); return ( void; @@ -989,8 +1839,9 @@ function WorkspaceRow({ } return ( - void; -}) { - const { t } = useTranslation(); - const handlePress = useCallback(() => { - navigateToNewWorkspaceForProject({ serverId, project, displayName, onWorkspacePress }); - }, [displayName, onWorkspacePress, project, serverId]); - const rowStyle = useCallback( - ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.newWorkspaceGhostRow, - (Boolean(hovered) || pressed) && styles.newWorkspaceGhostRowHovered, - ], - [], - ); - - return ( - - {({ hovered, pressed }) => ( - <> - - - - - {t("sidebar.workspace.actions.newWorkspace")} - - - )} - - ); -} - function ProjectBlock({ project, collapsed, displayName, iconDataUri, - serverId, - canRemoveProject, selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, @@ -1082,20 +1874,19 @@ function ProjectBlock({ useNestable, creatingWorkspaceIds, activeWorkspaceSelection, + hostLabelByServerId, }: { project: SidebarProjectEntry; collapsed: boolean; displayName: string; iconDataUri: string | null; - serverId: string | null; - canRemoveProject: boolean; selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; onToggleCollapsed: (projectKey: string) => void; onWorkspacePress?: () => void; - onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; + onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; @@ -1103,6 +1894,7 @@ function ProjectBlock({ useNestable: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; + hostLabelByServerId: ReadonlyMap; }) { const rowModel = useMemo( () => @@ -1115,14 +1907,13 @@ function ProjectBlock({ const active = isProjectSelectedByRoute({ selection: activeWorkspaceSelection, - serverId, project, enabled: selectionEnabled, }); const renderWorkspaceRow = useCallback( ( - item: SidebarWorkspaceEntry, + item: SidebarWorkspacePlacement, input?: { drag?: () => void; isDragging?: boolean; @@ -1132,12 +1923,16 @@ function ProjectBlock({ return ( 1 + ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) + : null + } shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} showShortcutBadge={showShortcutBadges} canCopyBranchName={project.projectKind === "git"} isCreating={creatingWorkspaceIds.has(item.workspaceId)} selectionEnabled={selectionEnabled} - serverId={serverId} activeWorkspaceSelection={activeWorkspaceSelection} onWorkspacePress={onWorkspacePress} drag={input?.drag} @@ -1148,10 +1943,11 @@ function ProjectBlock({ }, [ project.projectKind, + project.hosts.length, activeWorkspaceSelection, creatingWorkspaceIds, + hostLabelByServerId, onWorkspacePress, - serverId, selectionEnabled, shortcutIndexByWorkspaceKey, showShortcutBadges, @@ -1164,7 +1960,7 @@ function ProjectBlock({ drag: workspaceDrag, isActive, dragHandleProps: workspaceDragHandleProps, - }: DraggableRenderItemInfo) => { + }: DraggableRenderItemInfo) => { return renderWorkspaceRow(item, { drag: workspaceDrag, isDragging: isActive, @@ -1175,7 +1971,7 @@ function ProjectBlock({ ); const handleWorkspaceDragEnd = useCallback( - (workspaces: SidebarWorkspaceEntry[]) => { + (workspaces: SidebarWorkspacePlacement[]) => { onWorkspaceReorder(project.projectKey, workspaces); }, [onWorkspaceReorder, project.projectKey], @@ -1186,7 +1982,7 @@ function ProjectBlock({ const [isRemovingProject, setIsRemovingProject] = useState(false); const handleRemoveProject = useCallback(() => { - if (isRemovingProject || !serverId) { + if (isRemovingProject) { return; } @@ -1202,19 +1998,32 @@ function ProjectBlock({ return; } - const client = getHostRuntimeStore().getClient(serverId); - if (!client) { - toast.error(t("sidebar.project.toasts.hostDisconnected")); - return; - } - if (!canRemoveProject) { + setIsRemovingProject(true); + const readiness = getCurrentProjectRemoveReadiness({ + projectKey: project.projectKey, + hosts: project.hosts, + }); + if (readiness.kind === "needs_host_update") { toast.error(t("sidebar.project.toasts.updateHostToRemove")); + setIsRemovingProject(false); return; } - setIsRemovingProject(true); - void client - .removeProject(project.projectKey) + void removeProjectFromHosts({ + projectKey: project.projectKey, + targets: readiness.targets, + getClient: (serverId) => getHostRuntimeStore().getClient(serverId), + }) + .then((outcome) => { + if (outcome.kind === "host_disconnected") { + toast.error(t("sidebar.project.toasts.hostDisconnected")); + return null; + } + if (outcome.kind === "failed") { + toast.error(t("sidebar.project.toasts.removeFailed")); + } + return null; + }) .catch((error) => { toast.error( error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"), @@ -1224,7 +2033,7 @@ function ProjectBlock({ setIsRemovingProject(false); }); })(); - }, [isRemovingProject, serverId, displayName, t, toast, project.projectKey, canRemoveProject]); + }, [isRemovingProject, displayName, t, toast, project.projectKey, project.hosts]); const handleToggleCollapsed = useCallback(() => { onToggleCollapsed(project.projectKey); @@ -1248,12 +2057,12 @@ function ProjectBlock({ containerStyle={styles.workspaceListContainer} /> ); - } else { + } else if (rowModel.trailingAction.kind === "new_worktree") { projectChildren = ( ); @@ -1270,8 +2079,9 @@ function ProjectBlock({ selected={false} chevron={rowModel.chevron} onPress={handleToggleCollapsed} - serverId={serverId} - canCreateWorktree={rowModel.trailingAction === "new_worktree"} + worktreeTarget={ + rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null + } isProjectActive={active} onWorkspacePress={onWorkspacePress} onWorktreeCreated={onWorktreeCreated} @@ -1297,11 +2107,10 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.collapsed === next.collapsed && previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && - previous.serverId === next.serverId && - previous.canRemoveProject === next.canRemoveProject && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && + previous.hostLabelByServerId === next.hostLabelByServerId && previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && @@ -1323,13 +2132,11 @@ function areProjectBlockSelectionsEqual( const previousActive = isProjectSelectedByRoute({ selection: previous.activeWorkspaceSelection, project: previous.project, - serverId: previous.serverId, enabled: previous.selectionEnabled, }); const nextActive = isProjectSelectedByRoute({ selection: next.activeWorkspaceSelection, project: next.project, - serverId: next.serverId, enabled: next.selectionEnabled, }); if (previousActive !== nextActive) { @@ -1347,8 +2154,9 @@ function areProjectBlockSelectionsEqual( const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual); export function SidebarWorkspaceList({ + statusWorkspacePlacements, projects, - serverId, + projectNamesByKey, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, @@ -1361,57 +2169,58 @@ export function SidebarWorkspaceList({ parentGestureRef, }: SidebarWorkspaceListProps) { const pathname = usePathname(); + const hosts = useHosts(); + const hostLabelByServerId = useMemo(() => { + const labels = new Map(); + for (const host of hosts) { + labels.set(host.serverId, host.label?.trim() || host.serverId); + } + return labels; + }, [hosts]); - if (groupMode === "status") { - return ( + const content = + groupMode === "status" ? ( + ) : ( + ); - } - return ( - - ); + return content; } function SidebarStatusModeWrapper({ - serverId, - projects, + statusWorkspacePlacements, + projectNamesByKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, }: { - serverId: string | null; - projects: SidebarProjectEntry[]; + statusWorkspacePlacements: SidebarStatusWorkspacePlacement[]; + projectNamesByKey: Map; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; }) { - const hydratedWorkspaces = useStatusModeWorkspaceEntries({ - serverId, - projects, - }); - const projectNamesByKey = useProjectNamesMap(serverId); const showShortcutBadges = useShowShortcutBadges(); return ( & { + hostLabelByServerId, +}: Omit< + SidebarWorkspaceListProps, + "statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh" +> & { pathname: string; + hostLabelByServerId: ReadonlyMap; }) { const { t } = useTranslation(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); @@ -1444,9 +2257,6 @@ function ProjectModeList({ const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); - const canRemoveProject = useSessionStore((state) => - serverId ? state.sessions[serverId]?.serverInfo?.features?.projectRemove === true : false, - ); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), @@ -1454,6 +2264,14 @@ function ProjectModeList({ ); const selectionEnabled = isWorkspaceRoute; const activeWorkspaceSelection = useActiveWorkspaceSelection(); + const projectIconTargets = useMemo( + () => + projects.flatMap((project) => { + const target = resolveSidebarProjectIconTarget(project); + return target ? [{ projectKey: project.projectKey, ...target }] : []; + }), + [projects], + ); const nativeScrollGestureProps = useMemo( () => parentGestureRef @@ -1469,8 +2287,7 @@ function ProjectModeList({ ); const projectIconByProjectKey = useProjectIconDataByProjectKey({ - serverId, - projects, + projects: projectIconTargets, }); useEffect(() => { @@ -1521,12 +2338,8 @@ function ProjectModeList({ const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { - if (!serverId) { - return; - } - const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey); - const currentProjectOrder = getProjectOrder(serverId); + const currentProjectOrder = getProjectOrder(); if ( !hasVisibleOrderChanged({ currentOrder: currentProjectOrder, @@ -1537,24 +2350,19 @@ function ProjectModeList({ } setProjectOrder( - serverId, mergeWithRemainder({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }), ); }, - [getProjectOrder, serverId, setProjectOrder], + [getProjectOrder, setProjectOrder], ); const handleWorkspaceReorder = useCallback( - (projectKey: string, reorderedWorkspaces: SidebarWorkspaceEntry[]) => { - if (!serverId) { - return; - } - + (projectKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); - const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey); + const currentWorkspaceOrder = getWorkspaceOrder(projectKey); if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, @@ -1565,7 +2373,6 @@ function ProjectModeList({ } setWorkspaceOrder( - serverId, projectKey, mergeWithRemainder({ currentOrder: currentWorkspaceOrder, @@ -1573,7 +2380,7 @@ function ProjectModeList({ }), ); }, - [getWorkspaceOrder, serverId, setWorkspaceOrder], + [getWorkspaceOrder, setWorkspaceOrder], ); const handleWorktreeCreated = useCallback((workspaceId: string) => { @@ -1610,8 +2417,6 @@ function ProjectModeList({ collapsed={collapsedProjectKeys.has(item.projectKey)} displayName={item.projectName} iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} - serverId={serverId} - canRemoveProject={canRemoveProject} selectionEnabled={selectionEnabled} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} @@ -1626,6 +2431,7 @@ function ProjectModeList({ useNestable={platformIsNative} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} + hostLabelByServerId={hostLabelByServerId} /> ); }, @@ -1634,13 +2440,12 @@ function ProjectModeList({ activeWorkspaceSelection, handleWorktreeCreated, handleWorkspaceReorder, + hostLabelByServerId, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, projectIconByProjectKey, - canRemoveProject, selectionEnabled, - serverId, shortcutIndexByWorkspaceKey, showShortcutBadges, creatingWorkspaceIds, @@ -1650,8 +2455,10 @@ function ProjectModeList({ const content = ( <> {projects.length === 0 ? ( - - {t("sidebar.project.empty.title")} + + + {t("sidebar.project.empty.title")} + {t("sidebar.project.empty.description")} ) : null, @@ -68,27 +150,45 @@ function SessionsScreenContent({ serverId }: { serverId: string }) { return ( + {showHostFilter ? ( + + + + ) : null} {isInitialLoad ? ( ) : null} - {!isInitialLoad && sortedAgents.length === 0 ? ( + {!isInitialLoad && showLoadError ? ( - {t("sessions.empty")} - ) : null} - {!isInitialLoad && sortedAgents.length > 0 ? ( + {!isInitialLoad && !showLoadError && sortedAgents.length === 0 ? ( + + {emptyText} + + + ) : null} + {!isInitialLoad && !showLoadError && sortedAgents.length > 0 ? ( ) : null} @@ -100,6 +200,39 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.surface0, }, + filterContainer: { + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[6], + }, + paddingTop: theme.spacing[4], + }, + filterTriggerWrap: { + alignSelf: "flex-start", + }, + filterTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + alignSelf: "flex-start", + paddingVertical: theme.spacing[1.5], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + filterTriggerHovered: { + backgroundColor: theme.colors.surface2, + }, + filterTriggerPressed: { + backgroundColor: theme.colors.surface3, + }, + filterTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, emptyContainer: { flex: 1, justifyContent: "center", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index a29015647..2e98bf637 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,13 +1,5 @@ -import { - Fragment, - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import type { ComponentType, ReactElement, ReactNode } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ComponentType, ReactNode } from "react"; import { Alert, Pressable, @@ -26,7 +18,6 @@ import type { TFunction } from "i18next"; import { Buffer } from "buffer"; import { ArrowLeft, - Monitor, Settings, Palette, Server, @@ -48,6 +39,7 @@ import { AppDiagnosticSheet } from "@/components/app-diagnostic-sheet"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; +import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { SettingsSection } from "@/screens/settings/settings-section"; @@ -61,14 +53,9 @@ import { type ServiceUrlBehavior, type Settings as EffectiveSettings, } from "@/hooks/use-settings"; -import { - getHostRuntimeStore, - isHostRuntimeConnected, - useHostRuntimeIsConnected, - useHosts, -} from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; -import type { HostProfile } from "@/types/host-connection"; +import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -82,7 +69,6 @@ import { Button } from "@/components/ui/button"; import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; -import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; @@ -115,7 +101,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { - buildHostOpenProjectRoute, + buildOpenProjectRoute, buildProjectsSettingsRoute, buildSettingsHostSectionRoute, buildSettingsSectionRoute, @@ -774,48 +760,11 @@ function DesktopAppUpdateRow() { // Sidebar // --------------------------------------------------------------------------- -function useAnyOnlineHostServerId(serverIds: string[]): string | null { - const runtime = getHostRuntimeStore(); - return useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => { - let firstOnlineServerId: string | null = null; - let firstOnlineAt: string | null = null; - for (const serverId of serverIds) { - const snapshot = runtime.getSnapshot(serverId); - const lastOnlineAt = snapshot?.lastOnlineAt ?? null; - if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { - continue; - } - if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { - firstOnlineAt = lastOnlineAt; - firstOnlineServerId = serverId; - } - } - return firstOnlineServerId; - }, - () => null, - ); -} - /** - * Local daemon first, then remaining hosts in their existing order. Lets the - * picker and the active-host resolver agree on a stable "first" host. + * Local daemon first, then remaining hosts in their existing order. */ function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] { - return useMemo(() => { - if (!localServerId) { - return hosts; - } - const localIndex = hosts.findIndex((host) => host.serverId === localServerId); - if (localIndex <= 0) { - return hosts; - } - const next = hosts.slice(); - const [local] = next.splice(localIndex, 1); - next.unshift(local); - return next; - }, [hosts, localServerId]); + return useMemo(() => orderHostsLocalFirst(hosts, localServerId), [hosts, localServerId]); } interface SidebarSectionButtonProps { @@ -935,78 +884,9 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr ); } -// Sentinel option id for the "Add host" row appended to the picker list. -const ADD_HOST_OPTION_ID = "__add_host__"; - -interface HostPickerOptionProps { - serverId: string; - label: string; - isLocal: boolean; - selected: boolean; - active: boolean; - onPress: () => void; -} - -function HostPickerOption({ - serverId, - label, - isLocal, - selected, - active, - onPress, -}: HostPickerOptionProps) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const leadingSlot = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foregroundMuted], - ); - // The local host carries a "Local" marker; the active host is conveyed by the - // row's selected check, so both can coexist on one row. - const trailingSlot = useMemo( - () => - isLocal ? ( - - {t("settings.hostPicker.local")} - - ) : undefined, - [isLocal, t], - ); - return ( - - ); -} - -function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const leadingSlot = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foregroundMuted], - ); - return ( - - ); -} - interface HostPickerProps { activeServerId: string | null; sortedHosts: HostProfile[]; - localServerId: string | null; onSelectHost: (serverId: string) => void; onAddHost: () => void; } @@ -1017,66 +897,19 @@ interface HostPickerProps { * . The local host is listed first and tagged "Local"; an "Add host" * row is always reachable from the list — even with a single host. */ -function HostPicker({ - activeServerId, - sortedHosts, - localServerId, - onSelectHost, - onAddHost, -}: HostPickerProps) { - const { theme } = useUnistyles(); +function HostPicker({ activeServerId, sortedHosts, onSelectHost, onAddHost }: HostPickerProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); const activeHost = sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null; - const options = useMemo(() => { - const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label })); - return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: t("settings.addHost") }]; - }, [sortedHosts, t]); - - const handleSelect = useCallback( - (id: string) => { - if (id === ADD_HOST_OPTION_ID) { - onAddHost(); - return; - } - onSelectHost(id); - }, - [onAddHost, onSelectHost], - ); - - const renderOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }): ReactElement => { - if (option.id === ADD_HOST_OPTION_ID) { - return ; - } - return ( - - ); - }, - [localServerId], - ); - const handleOpen = useCallback(() => setIsOpen(true), []); + const hostLocalMarkerTestID = useCallback(() => "settings-host-local-marker", []); + const hostOptionTestID = useCallback( + (serverId: string) => `settings-host-picker-item-${serverId}`, + [], + ); const triggerStyle = useCallback( ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ sidebarStyles.pickerTrigger, @@ -1086,7 +919,23 @@ function HostPicker({ ); return ( - <> + - + {activeHost ? : null} {activeHost?.label ?? t("settings.groups.host")} - - + ); } @@ -1189,7 +1026,6 @@ function SettingsSidebar({ @@ -1254,9 +1090,10 @@ function SettingsSidebar({ export interface SettingsScreenProps { view: SettingsView; + openAddHostIntent?: string | null; } -export default function SettingsScreen({ view }: SettingsScreenProps) { +export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) { const router = useRouter(); const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -1267,6 +1104,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false); const [playbackTestResult, setPlaybackTestResult] = useState(null); + const lastOpenedAddHostIntentRef = useRef(null); const isDesktopApp = isElectronRuntime(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); @@ -1281,8 +1119,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); - const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); - const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds); const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( view.kind === "host" ? view.serverId : null, ); @@ -1381,6 +1217,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { setIsAddHostMethodVisible(true); }, []); + useEffect(() => { + if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) { + return; + } + lastOpenedAddHostIntentRef.current = openAddHostIntent; + handleAddHost(); + }, [handleAddHost, openAddHostIntent]); + const handleSelectDirectConnection = useCallback(() => { setIsAddHostMethodVisible(false); setIsDirectHostVisible(true); @@ -1488,12 +1332,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { if (navigateToLastWorkspace()) { return; } - if (anyOnlineServerId) { - router.replace(buildHostOpenProjectRoute(anyOnlineServerId)); - return; - } - router.replace("/"); - }, [anyOnlineServerId, router]); + router.replace(buildOpenProjectRoute()); + }, [router]); const detailHeader = ((): { title: string; @@ -1849,11 +1689,6 @@ const sidebarStyles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, flex: 1, }, - localMarker: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - marginLeft: theme.spacing[1], - }, pickerTrigger: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/stores/keyboard-shortcuts-store.test.ts b/packages/app/src/stores/keyboard-shortcuts-store.test.ts index d0291dad5..17a67d767 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.test.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.test.ts @@ -4,7 +4,6 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store"; beforeEach(() => { useKeyboardShortcutsStore.setState({ commandCenterOpen: false, - projectPickerOpen: false, shortcutsDialogOpen: false, capturingShortcut: false, altDown: false, diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index d47084a58..f0ce7f960 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -5,7 +5,6 @@ const SHORTCUT_BADGE_DELAY_MS = 150; interface KeyboardShortcutsState { commandCenterOpen: boolean; - projectPickerOpen: boolean; shortcutsDialogOpen: boolean; capturingShortcut: boolean; altDown: boolean; @@ -15,7 +14,6 @@ interface KeyboardShortcutsState { sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; setCommandCenterOpen: (open: boolean) => void; - setProjectPickerOpen: (open: boolean) => void; setShortcutsDialogOpen: (open: boolean) => void; setCapturingShortcut: (capturing: boolean) => void; setAltDown: (down: boolean) => void; @@ -49,7 +47,6 @@ function updateBadgeTimer( export const useKeyboardShortcutsStore = create((set, get) => ({ commandCenterOpen: false, - projectPickerOpen: false, shortcutsDialogOpen: false, capturingShortcut: false, altDown: false, @@ -58,7 +55,6 @@ export const useKeyboardShortcutsStore = create((set, ge sidebarShortcutWorkspaceTargets: [], setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), - setProjectPickerOpen: (open) => set({ projectPickerOpen: open }), setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }), setAltDown: (down) => { 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 ba07f70a7..94f2300d0 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -14,6 +14,7 @@ import { } from "./navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit } from "@/utils/host-route-browser"; export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; @@ -41,7 +42,10 @@ function navigateDeps(): NavigateToWorkspaceDeps { useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { kind: "agent", agentId }); }, rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection), - navigateToRoute: (route) => router.dismissTo(route as Href), + navigateToRoute: (route) => { + router.dismissTo(route as Href); + stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit(); + }, }; } diff --git a/packages/app/src/stores/project-picker-store.ts b/packages/app/src/stores/project-picker-store.ts new file mode 100644 index 000000000..c5485502e --- /dev/null +++ b/packages/app/src/stores/project-picker-store.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +interface ProjectPickerRequest { + serverId: string; +} + +interface ProjectPickerStoreState { + request: ProjectPickerRequest | null; + open: (serverId: string) => void; + close: () => void; +} + +export const useProjectPickerStore = create((set) => ({ + request: null, + open: (serverId) => set({ request: { serverId } }), + close: () => set({ request: null }), +})); diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 05c7c748f..1c77c1005 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -12,7 +12,7 @@ import { selectWorkspaceExists, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScopeForServer, + selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, selectWorkspaceStructureProjects, workspaceEqualityFns, @@ -81,32 +81,31 @@ export function useWorkspaceDirectory( ); } -export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure { +export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { const projects = useStoreWithEqualityFn( useSessionStore, - (state) => selectWorkspaceStructureProjects(state, serverId), + (state) => selectWorkspaceStructureProjects(state, serverIds), workspaceEqualityFns.deep, ); const projectOrder = useStoreWithEqualityFn( useSidebarOrderStore, - (state) => selectProjectOrder(state, serverId), + (state) => selectProjectOrder(state), workspaceEqualityFns.deep, ); const workspaceOrderByScope = useStoreWithEqualityFn( useSidebarOrderStore, - (state) => selectWorkspaceOrderByScopeForServer(state, serverId), + (state) => selectWorkspaceOrderByScope(state), workspaceEqualityFns.deep, ); return useMemo( () => composeWorkspaceStructure({ - serverId, projects, projectOrder, workspaceOrderByScope, }), - [projectOrder, projects, serverId, workspaceOrderByScope], + [projectOrder, projects, workspaceOrderByScope], ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index 516eb594c..583b9b4fa 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -10,7 +10,7 @@ import { selectWorkspaceDirectory, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScopeForServer, + selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, selectWorkspaceStructureProjects, workspaceEqualityFns, @@ -86,15 +86,15 @@ function trackSelector( function emptySidebarOrder(): SidebarOrderSnapshot { return { - projectOrderByServerId: {}, - workspaceOrderByServerAndProject: {}, + projectOrder: [], + workspaceOrderByProject: {}, }; } function selectWorkspaceStructureProjectKeys( state: Parameters[0], ): string[] { - return selectWorkspaceStructureProjects(state, SERVER_ID).map((project) => project.projectKey); + return selectWorkspaceStructureProjects(state, [SERVER_ID]).map((project) => project.projectKey); } afterEach(() => { @@ -211,10 +211,9 @@ describe("workspace structure composition", () => { sidebar: SidebarOrderSnapshot, ): ReturnType { return composeWorkspaceStructure({ - serverId, - projects: selectWorkspaceStructureProjects(useSessionStore.getState(), serverId), - projectOrder: selectProjectOrder(sidebar, serverId), - workspaceOrderByScope: selectWorkspaceOrderByScopeForServer(sidebar, serverId), + projects: selectWorkspaceStructureProjects(useSessionStore.getState(), [serverId]), + projectOrder: selectProjectOrder(sidebar), + workspaceOrderByScope: selectWorkspaceOrderByScope(sidebar), }); } @@ -257,7 +256,7 @@ describe("workspace structure composition", () => { const tracked = trackSelector( useSessionStore, - (state) => selectWorkspaceStructureProjects(state, SERVER_ID), + (state) => selectWorkspaceStructureProjects(state, [SERVER_ID]), workspaceEqualityFns.deep, ); const before = tracked.current; @@ -265,7 +264,10 @@ describe("workspace structure composition", () => { useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]); const afterAdd = tracked.current; expect(afterAdd).not.toBe(before); - expect(afterAdd[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]); + expect(afterAdd[0]?.workspaceKeys).toEqual([ + "test-server:workspace-a", + "test-server:workspace-b", + ]); useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]); expect(tracked.current).toBe(afterAdd); @@ -286,7 +288,7 @@ describe("workspace structure composition", () => { }, ]); - const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), SERVER_ID); + const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]); expect(projects).toEqual([ expect.objectContaining({ projectKey: "empty-project", @@ -305,7 +307,7 @@ describe("workspace structure composition", () => { const tracked = trackSelector( useSessionStore, - (state) => selectWorkspaceStructureProjects(state, SERVER_ID), + (state) => selectWorkspaceStructureProjects(state, [SERVER_ID]), workspaceEqualityFns.deep, ); const before = tracked.current; @@ -334,7 +336,7 @@ describe("workspace structure composition", () => { const before = snapshotStructure(SERVER_ID, emptySidebarOrder()); const after = snapshotStructure(SERVER_ID, { ...emptySidebarOrder(), - projectOrderByServerId: { [SERVER_ID]: ["project-b", "project-a"] }, + projectOrder: ["project-b", "project-a"], }); expect(after.projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 1c5ce851c..f38154d3d 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -23,8 +23,8 @@ export interface SessionsSnapshot { } export interface SidebarOrderSnapshot { - projectOrderByServerId: Record; - workspaceOrderByServerAndProject: Record; + projectOrder: string[]; + workspaceOrderByProject: Record; } const EMPTY_WORKSPACE_KEYS: string[] = []; @@ -35,10 +35,6 @@ export const workspaceEqualityFns = { deep: equal as (a: unknown, b: unknown) => boolean, }; -function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string { - return `${serverId.trim()}::${projectKey.trim()}`; -} - function applyStoredOrdering(input: { items: T[]; storedOrder: readonly string[]; @@ -137,76 +133,63 @@ export function selectHasHydratedWorkspaces( export function selectWorkspaceStructureProjects( state: SessionsSnapshot, - serverId: string | null, + serverIds: readonly string[], ): WorkspaceStructureProject[] { - if (!serverId) { - return EMPTY_WORKSPACE_STRUCTURE.projects; - } + const sessions: Array<{ + serverId: string; + workspaces: Iterable; + emptyProjects: Iterable; + }> = []; - const session = state.sessions[serverId]; - const workspaces = session?.workspaces; - const emptyProjects = session?.emptyProjects; - if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) { - return EMPTY_WORKSPACE_STRUCTURE.projects; - } - - return buildWorkspaceStructureProjects({ - serverId, - workspaces: workspaces?.values() ?? [], - emptyProjects: emptyProjects?.values() ?? [], - }); -} - -export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] { - return serverId - ? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS) - : EMPTY_WORKSPACE_KEYS; -} - -export function selectWorkspaceOrderByScopeForServer( - state: SidebarOrderSnapshot, - serverId: string | null, -): Record { - if (!serverId) { - return {}; - } - const prefix = `${serverId.trim()}::`; - const relevantOrderByScope: Record = {}; - for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject)) { - if (scopeKey.startsWith(prefix)) { - relevantOrderByScope[scopeKey] = order; + for (const serverId of serverIds) { + const session = state.sessions[serverId]; + const workspaces = session?.workspaces; + const emptyProjects = session?.emptyProjects; + if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) { + continue; } + sessions.push({ + serverId, + workspaces: workspaces?.values() ?? [], + emptyProjects: emptyProjects?.values() ?? [], + }); } - return relevantOrderByScope; + + if (sessions.length === 0) { + return EMPTY_WORKSPACE_STRUCTURE.projects; + } + + return buildWorkspaceStructureProjects({ sessions }); +} + +export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { + return state.projectOrder ?? EMPTY_WORKSPACE_KEYS; +} + +export function selectWorkspaceOrderByScope(state: SidebarOrderSnapshot): Record { + return state.workspaceOrderByProject ?? {}; } export function composeWorkspaceStructure(input: { - serverId: string | null; projects: WorkspaceStructureProject[]; projectOrder: readonly string[]; workspaceOrderByScope: Record; }): WorkspaceStructure { - if (!input.serverId || input.projects.length === 0) { + if (input.projects.length === 0) { return EMPTY_WORKSPACE_STRUCTURE; } const orderedProjects = applyStoredOrdering({ items: input.projects.map((project) => { const workspaceOrder = - input.workspaceOrderByScope[ - getWorkspaceOrderScopeKey(input.serverId as string, project.projectKey) - ] ?? EMPTY_WORKSPACE_KEYS; - const workspaceItems = project.workspaceKeys.map((workspaceId) => ({ - workspaceId, - workspaceKey: `${input.serverId as string}:${workspaceId}`, - })); + input.workspaceOrderByScope[project.projectKey] ?? EMPTY_WORKSPACE_KEYS; return { ...project, workspaceKeys: applyStoredOrdering({ - items: workspaceItems, + items: project.workspaceKeys, storedOrder: workspaceOrder, - getKey: (workspace) => workspace.workspaceKey, - }).map((workspace) => workspace.workspaceId), + getKey: (workspaceKey) => workspaceKey, + }), }; }), storedOrder: input.projectOrder, diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts new file mode 100644 index 000000000..d28e03f73 --- /dev/null +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { migrateSidebarOrderState } from "./sidebar-order-store"; + +describe("migrateSidebarOrderState", () => { + it("prefixes legacy per-server workspace order with the source server id", () => { + const migrated = migrateSidebarOrderState({ + projectOrderByServerId: { + "host-a": ["project-a"], + "host-b": ["project-a"], + }, + workspaceOrderByServerAndProject: { + "host-a::project-a": ["main", "feature"], + "host-b::project-a": ["main"], + }, + }); + + expect(migrated).toEqual({ + projectOrder: ["project-a"], + workspaceOrderByProject: { + "project-a": ["host-a:main", "host-a:feature", "host-b:main"], + }, + }); + }); +}); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 62afdbb51..8ad128eee 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -3,12 +3,24 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; interface SidebarOrderStoreState { - projectOrderByServerId: Record; - workspaceOrderByServerAndProject: Record; - getProjectOrder: (serverId: string) => string[]; - setProjectOrder: (serverId: string, keys: string[]) => void; - getWorkspaceOrder: (serverId: string, projectKey: string) => string[]; - setWorkspaceOrder: (serverId: string, projectKey: string, keys: string[]) => void; + projectOrder: string[]; + workspaceOrderByProject: Record; + getProjectOrder: () => string[]; + setProjectOrder: (keys: string[]) => void; + getWorkspaceOrder: (projectKey: string) => string[]; + setWorkspaceOrder: (projectKey: string, keys: string[]) => void; +} + +interface SidebarOrderPersistedState { + projectOrder?: string[]; + workspaceOrderByProject?: Record; + projectOrderByServerId?: Record; + workspaceOrderByServerAndProject?: Record; +} + +interface SidebarWorkspaceOrderScope { + serverId: string; + projectKey: string; } function normalizeKeys(keys: string[]): string[] { @@ -27,56 +39,96 @@ function normalizeKeys(keys: string[]): string[] { return normalized; } -function buildWorkspaceScopeKey(serverId: string, projectKey: string): string { - return `${serverId.trim()}::${projectKey.trim()}`; +function normalizeWorkspaceOrderByProject( + workspaceOrderByProject: Record | undefined, +): Record { + const normalized: Record = {}; + for (const [projectKey, order] of Object.entries(workspaceOrderByProject ?? {})) { + const scope = projectKey.trim(); + if (!scope) continue; + normalized[scope] = normalizeKeys(order); + } + return normalized; +} + +function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null { + const separatorIndex = scopeKey.indexOf("::"); + if (separatorIndex < 0) return null; + const serverId = scopeKey.slice(0, separatorIndex).trim(); + const projectKey = scopeKey.slice(separatorIndex + 2).trim(); + if (!serverId || !projectKey) return null; + return { serverId, projectKey }; +} + +function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { + const workspaceKey = rawWorkspaceKey.trim(); + if (!workspaceKey) return null; + const serverPrefix = `${serverId}:`; + return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`; +} + +export function migrateSidebarOrderState(persistedState: unknown): { + projectOrder: string[]; + workspaceOrderByProject: Record; +} { + const state = persistedState as SidebarOrderPersistedState | undefined; + + if (!state) { + return { projectOrder: [], workspaceOrderByProject: {} }; + } + + const projectOrder = normalizeKeys(state.projectOrder ?? []); + const seenProjects = new Set(projectOrder); + for (const keys of Object.values(state.projectOrderByServerId ?? {})) { + for (const key of normalizeKeys(keys)) { + if (seenProjects.has(key)) continue; + seenProjects.add(key); + projectOrder.push(key); + } + } + + const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject); + for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { + const scope = extractWorkspaceOrderScope(scopeKey); + if (!scope) continue; + const existing = workspaceOrderByProject[scope.projectKey] ?? []; + const merged = [...existing]; + const seen = new Set(merged); + for (const key of order) { + const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key); + if (!workspaceKey || seen.has(workspaceKey)) continue; + seen.add(workspaceKey); + merged.push(workspaceKey); + } + workspaceOrderByProject[scope.projectKey] = merged; + } + + return { projectOrder, workspaceOrderByProject }; } export const useSidebarOrderStore = create()( persist( (set, get) => ({ - projectOrderByServerId: {}, - workspaceOrderByServerAndProject: {}, - getProjectOrder: (serverId) => { - const key = serverId.trim(); - if (!key) { - return []; - } - return get().projectOrderByServerId[key] ?? []; + projectOrder: [], + workspaceOrderByProject: {}, + getProjectOrder: () => get().projectOrder, + setProjectOrder: (keys) => { + const normalized = normalizeKeys(keys); + set({ projectOrder: normalized }); }, - setProjectOrder: (serverId, keys) => { - const key = serverId.trim(); - if (!key) { - return; - } + getWorkspaceOrder: (projectKey) => { + const scope = projectKey.trim(); + if (!scope) return []; + return get().workspaceOrderByProject[scope] ?? []; + }, + setWorkspaceOrder: (projectKey, keys) => { + const scope = projectKey.trim(); + if (!scope) return; const normalized = normalizeKeys(keys); set((state) => ({ - projectOrderByServerId: { - ...state.projectOrderByServerId, - [key]: normalized, - }, - })); - }, - getWorkspaceOrder: (serverId, projectKey) => { - const serverKey = serverId.trim(); - const projectScope = projectKey.trim(); - if (!serverKey || !projectScope) { - return []; - } - const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope); - return get().workspaceOrderByServerAndProject[scopeKey] ?? []; - }, - setWorkspaceOrder: (serverId, projectKey, keys) => { - const serverKey = serverId.trim(); - const projectScope = projectKey.trim(); - if (!serverKey || !projectScope) { - return; - } - const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope); - const normalized = normalizeKeys(keys); - set((state) => ({ - workspaceOrderByServerAndProject: { - ...state.workspaceOrderByServerAndProject, - [scopeKey]: normalized, + workspaceOrderByProject: { + ...state.workspaceOrderByProject, + [scope]: normalized, }, })); }, @@ -85,9 +137,11 @@ export const useSidebarOrderStore = create()( name: "sidebar-project-workspace-order", storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ - projectOrderByServerId: state.projectOrderByServerId, - workspaceOrderByServerAndProject: state.workspaceOrderByServerAndProject, + projectOrder: state.projectOrder, + workspaceOrderByProject: state.workspaceOrderByProject, }), + version: 1, + migrate: migrateSidebarOrderState, }, ), ); diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts new file mode 100644 index 000000000..33c04c086 --- /dev/null +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StateStorage } from "zustand/middleware"; +import { + createSidebarViewStorage, + migrateSidebarViewState, + useSidebarViewStore, +} from "./sidebar-view-store"; + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined), + }, +})); + +interface MemoryStorage extends StateStorage> { + reads: string[]; +} + +function createMemoryStorage(entries: Record): MemoryStorage { + const reads: string[] = []; + return { + reads, + getItem: async (name) => { + reads.push(name); + return entries[name] ?? null; + }, + setItem: async (name, value) => { + entries[name] = value; + }, + removeItem: async (name) => { + entries[name] = null; + }, + }; +} + +describe("sidebar view store", () => { + beforeEach(() => { + useSidebarViewStore.setState({ + groupMode: "project", + hostFilter: null, + }); + }); + + it("keeps a host filter that still points at an available host", () => { + useSidebarViewStore.getState().setHostFilter("host-a"); + + useSidebarViewStore.getState().reconcileHostFilter(["host-a", "host-b"]); + + expect(useSidebarViewStore.getState().hostFilter).toBe("host-a"); + }); + + it("clears a host filter after that host is removed", () => { + useSidebarViewStore.getState().setHostFilter("removed-host"); + + useSidebarViewStore.getState().reconcileHostFilter(["host-a"]); + + expect(useSidebarViewStore.getState().hostFilter).toBeNull(); + }); + + it("migrates legacy per-host group modes to the new global mode", () => { + expect( + migrateSidebarViewState({ + groupModeByServerId: { + "host-a": "project", + "host-b": "status", + }, + }), + ).toEqual({ + groupMode: "status", + hostFilter: null, + }); + }); + + it("keeps current persisted sidebar view state during version migration", () => { + expect( + migrateSidebarViewState({ + groupMode: "status", + hostFilter: "host-a", + }), + ).toEqual({ + groupMode: "status", + hostFilter: "host-a", + }); + }); + + it("falls back to the legacy storage key when the new key is empty", async () => { + const storage = createMemoryStorage({ + "sidebar-view": null, + "sidebar-group-mode": JSON.stringify({ + state: { groupModeByServerId: { "host-a": "status" } }, + version: 0, + }), + }); + + const value = await createSidebarViewStorage(storage).getItem("sidebar-view"); + + expect(value).toBe( + JSON.stringify({ + state: { groupModeByServerId: { "host-a": "status" } }, + version: 0, + }), + ); + expect(storage.reads).toEqual(["sidebar-view", "sidebar-group-mode"]); + }); + + it("uses the new storage key without reading the legacy key when current state exists", async () => { + const storage = createMemoryStorage({ + "sidebar-view": JSON.stringify({ + state: { groupMode: "project", hostFilter: "host-a" }, + version: 1, + }), + "sidebar-group-mode": JSON.stringify({ + state: { groupModeByServerId: { "host-b": "status" } }, + version: 0, + }), + }); + + const value = await createSidebarViewStorage(storage).getItem("sidebar-view"); + + expect(value).toBe( + JSON.stringify({ + state: { groupMode: "project", hostFilter: "host-a" }, + version: 1, + }), + ); + expect(storage.reads).toEqual(["sidebar-view"]); + }); +}); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index de915ddcc..b1a052b88 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -1,41 +1,101 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; export type SidebarGroupMode = "project" | "status"; +const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; +const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; +const SIDEBAR_VIEW_STORE_VERSION = 1; + interface SidebarViewStoreState { - groupModeByServerId: Record; - getGroupMode: (serverId: string) => SidebarGroupMode; - setGroupMode: (serverId: string, mode: SidebarGroupMode) => void; + groupMode: SidebarGroupMode; + hostFilter: string | null; + setGroupMode: (mode: SidebarGroupMode) => void; + setHostFilter: (serverId: string | null) => void; + reconcileHostFilter: (serverIds: readonly string[]) => void; +} + +interface SidebarViewPersistedState { + groupMode: SidebarGroupMode; + hostFilter: string | null; +} + +function isSidebarGroupMode(value: unknown): value is SidebarGroupMode { + return value === "project" || value === "status"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readLegacyGroupMode(persistedState: Record): SidebarGroupMode | null { + const groupModeByServerId = persistedState.groupModeByServerId; + if (!isRecord(groupModeByServerId)) { + return null; + } + + const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode); + if (modes.length === 0) return null; + return modes.includes("status") ? "status" : "project"; +} + +export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { + if (!isRecord(persistedState)) { + return { groupMode: "project", hostFilter: null }; + } + + const legacyGroupMode = readLegacyGroupMode(persistedState); + if (legacyGroupMode) { + return { groupMode: legacyGroupMode, hostFilter: null }; + } + + return { + groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project", + hostFilter: typeof persistedState.hostFilter === "string" ? persistedState.hostFilter : null, + }; +} + +export function createSidebarViewStorage( + backingStorage: StateStorage = AsyncStorage, +): StateStorage { + return { + getItem: async (name) => { + const value = await backingStorage.getItem(name); + if (value !== null || name !== SIDEBAR_VIEW_STORAGE_KEY) { + return value; + } + return backingStorage.getItem(LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY); + }, + setItem: (name, value) => backingStorage.setItem(name, value), + removeItem: (name) => backingStorage.removeItem(name), + }; } export const useSidebarViewStore = create()( persist( - (set, get) => ({ - groupModeByServerId: {}, - getGroupMode: (serverId) => { - const key = serverId.trim(); - if (!key) return "project"; - return get().groupModeByServerId[key] ?? "project"; - }, - setGroupMode: (serverId, mode) => { - const key = serverId.trim(); - if (!key) return; - set((state) => ({ - groupModeByServerId: { - ...state.groupModeByServerId, - [key]: mode, - }, - })); - }, + (set) => ({ + groupMode: "project", + hostFilter: null, + setGroupMode: (mode) => set({ groupMode: mode }), + setHostFilter: (serverId) => set({ hostFilter: serverId }), + reconcileHostFilter: (serverIds) => + set((state) => { + if (!state.hostFilter || serverIds.includes(state.hostFilter)) { + return state; + } + return { hostFilter: null }; + }), }), { - name: "sidebar-group-mode", - storage: createJSONStorage(() => AsyncStorage), + name: SIDEBAR_VIEW_STORAGE_KEY, + version: SIDEBAR_VIEW_STORE_VERSION, + storage: createJSONStorage(createSidebarViewStorage), partialize: (state) => ({ - groupModeByServerId: state.groupModeByServerId, + groupMode: state.groupMode, + hostFilter: state.hostFilter, }), + migrate: migrateSidebarViewState, }, ), ); diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index 2837def0d..2bf1c6fde 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -1,5 +1,47 @@ import { describe, expect, it } from "vitest"; -import { normalizeStoredHostProfile } from "./host-connection"; +import { + normalizeStoredHostProfile, + orderHostsLocalFirst, + type HostProfile, +} from "./host-connection"; + +function makeHost(serverId: string): HostProfile { + return { + serverId, + label: serverId, + lifecycle: {}, + connections: [], + preferredConnectionId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("orderHostsLocalFirst", () => { + it("moves the local host to the first position", () => { + const remote = makeHost("srv_remote"); + const local = makeHost("srv_local"); + const anotherRemote = makeHost("srv_another_remote"); + + expect(orderHostsLocalFirst([remote, local, anotherRemote], "srv_local")).toEqual([ + local, + remote, + anotherRemote, + ]); + }); + + it("preserves host order when the local host is missing", () => { + const hosts = [makeHost("srv_remote"), makeHost("srv_another_remote")]; + + expect(orderHostsLocalFirst(hosts, "srv_local")).toBe(hosts); + }); + + it("preserves host order when there is no local host", () => { + const hosts = [makeHost("srv_remote"), makeHost("srv_another_remote")]; + + expect(orderHostsLocalFirst(hosts, null)).toBe(hosts); + }); +}); describe("normalizeStoredHostProfile", () => { it("loads direct TCP connections stored before TLS and password fields existed", () => { diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index baf11e935..62bb9eef6 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -56,6 +56,25 @@ export function normalizeHostLabel(value: string | null | undefined, serverId: s return trimmed.length > 0 ? trimmed : serverId; } +export function orderHostsLocalFirst( + hosts: T[], + localServerId: string | null, +): T[] { + if (!localServerId) { + return hosts; + } + const localIndex = hosts.findIndex((host) => host.serverId === localServerId); + if (localIndex <= 0) { + return hosts; + } + const ordered = hosts.slice(); + const [local] = ordered.splice(localIndex, 1); + if (local) { + ordered.unshift(local); + } + return ordered; +} + function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean { if (left.type !== right.type || left.id !== right.id) { return false; diff --git a/packages/app/src/utils/active-host.test.ts b/packages/app/src/utils/active-host.test.ts deleted file mode 100644 index b791f6e74..000000000 --- a/packages/app/src/utils/active-host.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { HostProfile } from "@/types/host-connection"; -import { resolveActiveHost } from "./active-host"; - -function host(serverId: string): HostProfile { - return { - serverId, - label: serverId, - lifecycle: {}, - connections: [], - preferredConnectionId: null, - createdAt: "", - updatedAt: "", - }; -} - -describe("resolveActiveHost", () => { - it("uses the route host when it exists", () => { - expect( - resolveActiveHost({ - hosts: [host("srv-1"), host("srv-2")], - pathname: "/h/srv-2/workspace/ws-main", - })?.serverId, - ).toBe("srv-2"); - }); - - it("falls back to the first host when the route host is missing", () => { - expect( - resolveActiveHost({ - hosts: [host("srv-1"), host("srv-2")], - pathname: "/h/missing/workspace/ws-main", - })?.serverId, - ).toBe("srv-1"); - }); - - it("returns null when no hosts exist", () => { - expect(resolveActiveHost({ hosts: [], pathname: "/settings" })).toBeNull(); - }); -}); diff --git a/packages/app/src/utils/active-host.ts b/packages/app/src/utils/active-host.ts deleted file mode 100644 index 9662d9048..000000000 --- a/packages/app/src/utils/active-host.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HostProfile } from "@/types/host-connection"; -import { parseServerIdFromPathname } from "@/utils/host-routes"; - -export function resolveActiveHost({ - hosts, - pathname, -}: { - hosts: readonly HostProfile[]; - pathname: string; -}): HostProfile | null { - const serverIdFromPath = parseServerIdFromPathname(pathname); - if (serverIdFromPath) { - const routeMatch = hosts.find((host) => host.serverId === serverIdFromPath); - if (routeMatch) { - return routeMatch; - } - } - - return hosts[0] ?? null; -} diff --git a/packages/app/src/utils/host-route-browser.ts b/packages/app/src/utils/host-route-browser.ts new file mode 100644 index 000000000..bfb70ae6b --- /dev/null +++ b/packages/app/src/utils/host-route-browser.ts @@ -0,0 +1,35 @@ +import { isWeb } from "@/constants/platform"; +import { stripHostWorkspaceRouteEchoSearch } from "@/utils/host-routes"; + +function getCurrentBrowserRoute(): string | null { + if (!isWeb || typeof window === "undefined") { + return null; + } + return `${window.location.pathname}${window.location.search}${window.location.hash}`; +} + +export function replaceBrowserRouteWithCanonicalHostWorkspaceRoute(route: string): void { + if (!isWeb || typeof window === "undefined") { + return; + } + window.history.replaceState(null, "", stripHostWorkspaceRouteEchoSearch(route)); +} + +export function stripHostWorkspaceRouteEchoSearchFromBrowserUrl(): void { + const currentRoute = getCurrentBrowserRoute(); + if (!currentRoute) { + return; + } + const canonicalRoute = stripHostWorkspaceRouteEchoSearch(currentRoute); + if (canonicalRoute === currentRoute) { + return; + } + window.history.replaceState(null, "", canonicalRoute); +} + +export function stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit(): void { + if (!isWeb || typeof window === "undefined") { + return; + } + window.setTimeout(stripHostWorkspaceRouteEchoSearchFromBrowserUrl, 0); +} diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index c053bae1a..9ccce6090 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -5,6 +5,10 @@ import { buildHostRootRoute, buildHostWorkspaceOpenRoute, buildHostWorkspaceRoute, + buildOpenProjectRoute, + resolveKnownHostRoute, + buildSessionsRoute, + buildSettingsAddHostRoute, buildProjectSettingsRoute, buildProjectsSettingsRoute, decodeFilePathFromPathSegment, @@ -16,6 +20,7 @@ import { parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, parseWorkspaceOpenIntent, + stripHostWorkspaceRouteEchoSearch, } from "./host-routes"; describe("parseHostAgentRouteFromPathname", () => { @@ -122,6 +127,28 @@ describe("workspace route parsing", () => { ); }); + it("strips route params repeated as workspace route search params", () => { + expect( + stripHostWorkspaceRouteEchoSearch( + "/h/local/workspace/164?serverId=local&workspaceId=164&open=agent%3Aagent-1#pane", + ), + ).toBe("/h/local/workspace/164?open=agent%3Aagent-1#pane"); + }); + + it("keeps non-route workspace search params", () => { + expect(stripHostWorkspaceRouteEchoSearch("/h/local/workspace/164?workspaceId=other")).toBe( + "/h/local/workspace/164?workspaceId=other", + ); + }); + + it("strips encoded workspace route echoes", () => { + expect( + stripHostWorkspaceRouteEchoSearch( + "/h/local/workspace/b64_L3RtcC9yZXBv?workspaceId=%2Ftmp%2Frepo", + ), + ).toBe("/h/local/workspace/b64_L3RtcC9yZXBv"); + }); + it("builds a global new workspace route without a source directory", () => { expect(buildHostNewWorkspaceRoute("local")).toBe("/h/local/new"); }); @@ -153,6 +180,14 @@ describe("workspace route parsing", () => { }); describe("projects settings routes", () => { + it("buildSettingsAddHostRoute opens settings with the add-host flag", () => { + expect(buildSettingsAddHostRoute()).toBe("/settings/general?addHost=1"); + }); + + it("buildSettingsAddHostRoute accepts a repeatable intent id", () => { + expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201"); + }); + it("buildProjectsSettingsRoute returns /settings/projects", () => { expect(buildProjectsSettingsRoute()).toBe("/settings/projects"); }); @@ -177,6 +212,12 @@ describe("projects settings routes", () => { }); }); +describe("global routes", () => { + it("buildSessionsRoute returns the all-host Sessions route", () => { + expect(buildSessionsRoute()).toBe("/sessions"); + }); +}); + describe("host settings section slugs", () => { it("keeps current host settings sections", () => { expect(normalizeHostSectionSlug("connections")).toBe("connections"); @@ -192,3 +233,32 @@ describe("host settings section slugs", () => { expect(normalizeHostSectionSlug("daemon")).toBe("host"); }); }); + +describe("resolveKnownHostRoute", () => { + it("renders when the route host is still saved", () => { + expect( + resolveKnownHostRoute({ + routeServerId: "srv-current", + hosts: [{ serverId: "srv-current" }, { serverId: "srv-next" }], + }), + ).toEqual({ kind: "render" }); + }); + + it("sends removed host routes to the host-agnostic open project screen", () => { + expect( + resolveKnownHostRoute({ + routeServerId: "srv-removed", + hosts: [{ serverId: "srv-next" }], + }), + ).toEqual({ kind: "redirect", href: buildOpenProjectRoute() }); + }); + + it("sends host routes to welcome when no hosts are saved", () => { + expect( + resolveKnownHostRoute({ + routeServerId: "srv-removed", + hosts: [], + }), + ).toEqual({ kind: "redirect", href: "/welcome" }); + }); +}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index ad1a36bba..8b9141eea 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -23,6 +23,11 @@ function extractSearch(pathname: string): string { : pathname.slice(queryIndex + 1); } +function extractHash(pathname: string): string { + const hashIndex = pathname.indexOf("#"); + return hashIndex >= 0 ? pathname.slice(hashIndex) : ""; +} + function trimNonEmpty(value: NullableString): string | null { if (typeof value !== "string") { return null; @@ -298,6 +303,38 @@ export function parseHostWorkspaceRouteFromPathname( return { serverId, workspaceId }; } +export function stripHostWorkspaceRouteEchoSearch(route: string): string { + const pathname = stripSearchAndHash(route); + const selection = parseHostWorkspaceRouteFromPathname(pathname); + const search = extractSearch(route); + if (!selection || !search) { + return route; + } + + const params = new URLSearchParams(search); + let didStrip = false; + + const serverId = params.get("serverId"); + if (serverId && trimNonEmpty(decodeSegment(serverId)) === selection.serverId) { + params.delete("serverId"); + didStrip = true; + } + + const workspaceId = params.get("workspaceId"); + if (workspaceId && decodeWorkspaceIdFromPathSegment(workspaceId) === selection.workspaceId) { + params.delete("workspaceId"); + didStrip = true; + } + + if (!didStrip) { + return route; + } + + const nextSearch = params.toString(); + const nextQuery = nextSearch ? `?${nextSearch}` : ""; + return `${pathname}${nextQuery}${extractHash(route)}`; +} + export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) { const normalizedServerId = trimNonEmpty(serverId); const normalizedWorkspaceId = trimNonEmpty(workspaceId); @@ -353,6 +390,14 @@ export function buildHostRootRoute(serverId: string) { return `/h/${encodeSegment(normalized)}` as const; } +export function buildHostOpenProjectRoute(serverId: string) { + const base = buildHostRootRoute(serverId); + if (base === "/") { + return "/" as const; + } + return `${base}/open-project` as const; +} + export function buildHostSessionsRoute(serverId: string) { const base = buildHostRootRoute(serverId); if (base === "/") { @@ -361,12 +406,32 @@ export function buildHostSessionsRoute(serverId: string) { return `${base}/sessions` as const; } -export function buildHostOpenProjectRoute(serverId: string) { - const base = buildHostRootRoute(serverId); - if (base === "/") { - return "/" as const; +export function buildSessionsRoute() { + return "/sessions" as const; +} + +export function buildOpenProjectRoute() { + return "/open-project" as const; +} + +export type KnownHostRouteResolution = + | { kind: "render" } + | { kind: "redirect"; href: ReturnType | "/welcome" }; + +export function resolveKnownHostRoute(input: { + routeServerId: string | null | undefined; + hosts: readonly { serverId: string }[]; +}): KnownHostRouteResolution { + const routeServerId = trimNonEmpty(input.routeServerId); + if (routeServerId && input.hosts.some((host) => host.serverId === routeServerId)) { + return { kind: "render" }; } - return `${base}/open-project` as const; + + if (input.hosts.length > 0) { + return { kind: "redirect", href: buildOpenProjectRoute() }; + } + + return { kind: "redirect", href: "/welcome" }; } export function buildHostNewWorkspaceRoute( @@ -448,6 +513,10 @@ export function buildSettingsSectionRoute(section: SettingsSectionSlug) { return `/settings/${section}` as const; } +export function buildSettingsAddHostRoute(intentId: string | number = "1") { + return `/settings/general?addHost=${encodeURIComponent(String(intentId))}` as const; +} + export function buildSettingsHostRoute(serverId: string) { const normalized = trimNonEmpty(serverId); if (!normalized) { @@ -475,30 +544,3 @@ export function buildProjectSettingsRoute(projectKey: string) { } return `/settings/projects/${encodeSegment(normalized)}` as const; } - -export function mapPathnameToServer(pathname: string, nextServerId: string) { - const normalized = trimNonEmpty(nextServerId); - if (!normalized) { - return "/" as const; - } - - const suffix = pathname.replace(/^\/h\/[^/]+\/?/, ""); - const base = buildHostRootRoute(normalized); - if (suffix.startsWith("settings")) { - return buildSettingsHostRoute(normalized); - } - if (suffix.startsWith("sessions")) { - return `${base}/sessions` as const; - } - if (suffix.startsWith("open-project")) { - return `${base}/open-project` as const; - } - const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); - if (workspaceRoute) { - return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId); - } - if (suffix.startsWith("agent/")) { - return `${base}/${suffix}` as const; - } - return base; -} diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index a53d55c8a..911ddb8d1 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -80,10 +80,8 @@ function findProjectCustomName( function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] { return buildHostProjectList({ - serverId: host.serverId, projects: buildWorkspaceStructureProjects({ - serverId: host.serverId, - workspaces: host.workspaces, + sessions: [{ serverId: host.serverId, workspaces: host.workspaces }], }), }); } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index dc66aa03b..2bb644bef 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; -import { buildSidebarProjectRowModel } from "./sidebar-project-row-model"; import type { SidebarProjectEntry, SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; +import { + buildSidebarProjectRowModel, + resolveSidebarProjectIconTarget, +} from "./sidebar-project-row-model"; function workspace(overrides: Partial = {}): SidebarWorkspaceEntry { return { @@ -11,6 +14,7 @@ function workspace(overrides: Partial = {}): SidebarWorks serverId: "srv", workspaceId: "ws-root", projectKey: "project-1", + projectName: "paseo", workspaceDirectory: "/repo", projectKind: "git", workspaceKind: "checkout", @@ -37,7 +41,9 @@ function project(overrides: Partial = {}): SidebarProjectEn projectName: "paseo", projectKind, iconWorkingDir: "/repo", - canCreateWorktree: overrides.canCreateWorktree ?? projectKind === "git", + hosts: overrides.hosts ?? [ + { serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" }, + ], workspaces: [workspace()], ...overrides, }; @@ -56,7 +62,7 @@ describe("buildSidebarProjectRowModel", () => { expect(result).toEqual({ kind: "project_section", chevron: "collapse", - trailingAction: "none", + trailingAction: { kind: "none" }, }); }); @@ -72,7 +78,29 @@ describe("buildSidebarProjectRowModel", () => { expect(result).toEqual({ kind: "project_section", chevron: "expand", - trailingAction: "new_worktree", + trailingAction: { + kind: "new_worktree", + target: { serverId: "srv", iconWorkingDir: "/repo" }, + }, + }); + }); + + it("targets the project host, not route state, for new worktree actions", () => { + const result = buildSidebarProjectRowModel({ + project: project({ + hosts: [ + { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }, + { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, + ], + }), + collapsed: false, + }); + + expect(result).toMatchObject({ + trailingAction: { + kind: "new_worktree", + target: { serverId: "host-b", iconWorkingDir: "/repo/b" }, + }, }); }); @@ -91,10 +119,26 @@ describe("buildSidebarProjectRowModel", () => { expect(result).toEqual({ kind: "project_section", chevron: "expand", - trailingAction: "new_worktree", + trailingAction: { + kind: "new_worktree", + target: { serverId: "srv", iconWorkingDir: "/repo" }, + }, }); }); + it("resolves project icons from the project host, not the focused host", () => { + const iconTarget = resolveSidebarProjectIconTarget( + project({ + hosts: [ + { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, + { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }, + ], + }), + ); + + expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" }); + }); + it("renders an empty project as an expandable section", () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", workspaces: [] }), @@ -104,7 +148,10 @@ describe("buildSidebarProjectRowModel", () => { expect(result).toEqual({ kind: "project_section", chevron: "collapse", - trailingAction: "new_worktree", + trailingAction: { + kind: "new_worktree", + target: { serverId: "srv", iconWorkingDir: "/repo" }, + }, }); }); }); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index d7847b808..2fe5b740f 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -1,13 +1,63 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +export interface SidebarProjectHostTarget { + serverId: string; + iconWorkingDir: string; +} + +export type SidebarProjectTrailingAction = + | { kind: "new_worktree"; target: SidebarProjectHostTarget } + | { kind: "none" }; + export interface SidebarProjectSectionRowModel { kind: "project_section"; chevron: "expand" | "collapse"; - trailingAction: "new_worktree" | "none"; + trailingAction: SidebarProjectTrailingAction; } export type SidebarProjectRowModel = SidebarProjectSectionRowModel; +function hostTarget(input: { + serverId: string; + iconWorkingDir: string; +}): SidebarProjectHostTarget | null { + const iconWorkingDir = input.iconWorkingDir.trim(); + if (!input.serverId || !iconWorkingDir) { + return null; + } + return { serverId: input.serverId, iconWorkingDir }; +} + +export function resolveSidebarProjectIconTarget( + project: SidebarProjectEntry, +): SidebarProjectHostTarget | null { + for (const host of project.hosts) { + const target = hostTarget(host); + if (target) { + return target; + } + } + return null; +} + +function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null { + for (const host of project.hosts) { + if (!host.canCreateWorktree) { + continue; + } + const target = hostTarget(host); + if (target) { + return target; + } + } + return null; +} + +function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction { + const target = resolveNewWorktreeTarget(project); + return target ? { kind: "new_worktree", target } : { kind: "none" }; +} + export function buildSidebarProjectRowModel(input: { project: SidebarProjectEntry; collapsed: boolean; @@ -15,6 +65,6 @@ export function buildSidebarProjectRowModel(input: { return { kind: "project_section", chevron: input.collapsed ? "expand" : "collapse", - trailingAction: input.project.canCreateWorktree ? "new_worktree" : "none", + trailingAction: projectTrailingAction(input.project), }; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 1163e1c14..0d262aa4f 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -24,6 +24,7 @@ function workspace(input: { serverId: input.serverId, workspaceId: input.workspaceId, projectKey: input.projectKey ?? "project-default", + projectName: input.projectKey ?? "Project", workspaceDirectory: input.workspaceDirectory, projectKind: "git", workspaceKind: "checkout", @@ -48,7 +49,13 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb projectName: projectKey, projectKind: "git", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", - canCreateWorktree: true, + hosts: [ + { + serverId: workspaces[0]?.serverId ?? "s1", + iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", + canCreateWorktree: true, + }, + ], workspaces, }; } @@ -139,7 +146,10 @@ describe("buildSidebarShortcutModel", () => { }), ]); directoryProject.projectKind = "directory"; - directoryProject.canCreateWorktree = false; + directoryProject.hosts = directoryProject.hosts.map((host) => ({ + ...host, + canCreateWorktree: false, + })); const model = buildSidebarShortcutModel({ projects: [gitProject, directoryProject], diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 2fb34cf05..e3dff267f 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -1,6 +1,7 @@ import type { SidebarProjectEntry, - SidebarWorkspaceEntry, + SidebarStatusWorkspacePlacement, + SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; @@ -14,7 +15,9 @@ export interface SidebarShortcutModel { shortcutIndexByWorkspaceKey: Map; } -function createShortcutTarget(workspace: SidebarWorkspaceEntry): SidebarShortcutWorkspaceTarget { +function createShortcutTarget( + workspace: SidebarWorkspacePlacement, +): SidebarShortcutWorkspaceTarget { return { serverId: workspace.serverId, workspaceId: workspace.workspaceId, @@ -50,7 +53,7 @@ export function buildSidebarShortcutModel(input: { } export function buildStatusSidebarShortcutModel(input: { - workspaces: SidebarWorkspaceEntry[]; + workspaces: SidebarStatusWorkspacePlacement[]; projectNamesByKey: Map; collapsedStatusGroupKeys?: ReadonlySet; shortcutLimit?: number; diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index 1c0309179..a8cfc3939 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -12,6 +12,7 @@ import { } from "@/workspace/workspace-archive"; const SERVER_ID = "workspace-archive-test"; +const SECOND_SERVER_ID = "workspace-archive-test-2"; type ArchiveWorkspacePayload = Awaited>; @@ -75,8 +76,12 @@ function deferred(): { return { promise, resolve, reject }; } +function storedWorkspaceOn(serverId: string, id: string): WorkspaceDescriptor | undefined { + return useSessionStore.getState().sessions[serverId]?.workspaces.get(id); +} + function storedWorkspace(id: string): WorkspaceDescriptor | undefined { - return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id); + return storedWorkspaceOn(SERVER_ID, id); } beforeEach(() => { @@ -86,6 +91,8 @@ beforeEach(() => { afterEach(() => { clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" }); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" }); + clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-1" }); + clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-2" }); useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); @@ -175,8 +182,11 @@ describe("archiveWorkspacesOptimistically", () => { ); const failures = await archiveWorkspacesOptimistically({ - client, - workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })], + getClient: () => client, + workspaces: [ + target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }), + target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }), + ], }); expect(failures).toHaveLength(1); @@ -184,4 +194,49 @@ describe("archiveWorkspacesOptimistically", () => { expect(storedWorkspace(first.id)).toBeUndefined(); expect(storedWorkspace(second.id)).toEqual(second); }); + + it("archives each workspace through its own server client", async () => { + const first = workspace({ id: "workspace-1" }); + const second = workspace({ + id: "workspace-2", + workspaceDirectory: "/repo/project/workspace-2", + name: "workspace-2", + }); + useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient); + useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first]); + useSessionStore.getState().mergeWorkspaces(SECOND_SERVER_ID, [second]); + + const archivedByServer = new Map(); + const clientFor = (serverId: string) => + createClient(async (workspaceId) => { + archivedByServer.set(serverId, [...(archivedByServer.get(serverId) ?? []), workspaceId]); + return archivePayload({ workspaceId }); + }); + + const failures = await archiveWorkspacesOptimistically({ + getClient: (serverId) => clientFor(serverId), + workspaces: [ + target({ + serverId: SERVER_ID, + workspaceId: first.id, + workspaceDirectory: first.workspaceDirectory, + }), + target({ + serverId: SECOND_SERVER_ID, + workspaceId: second.id, + workspaceDirectory: second.workspaceDirectory, + }), + ], + }); + + expect(failures).toEqual([]); + expect(archivedByServer).toEqual( + new Map([ + [SERVER_ID, [first.id]], + [SECOND_SERVER_ID, [second.id]], + ]), + ); + expect(storedWorkspaceOn(SERVER_ID, first.id)).toBeUndefined(); + expect(storedWorkspaceOn(SECOND_SERVER_ID, second.id)).toBeUndefined(); + }); }); diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index 5199f371a..1930a8464 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -4,10 +4,12 @@ import { } from "@/contexts/session-workspace-upserts"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import { i18n } from "@/i18n/i18next"; export interface WorkspaceArchiveTarget { serverId: string; workspaceId: string; + workspaceDirectory?: string | null; } interface WorkspaceArchiveClient { @@ -19,6 +21,7 @@ interface OptimisticWorkspaceArchiveSnapshot { } export interface WorkspaceArchiveFailure { + serverId: string; workspaceId: string; error: unknown; } @@ -27,6 +30,8 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai return ( typeof error === "object" && error !== null && + "serverId" in error && + typeof error.serverId === "string" && "workspaceId" in error && typeof error.workspaceId === "string" && "error" in error @@ -45,6 +50,7 @@ function hideWorkspaceOptimistically( markWorkspaceArchivePending({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, }); useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); return { workspace: snapshot }; @@ -98,18 +104,31 @@ export async function archiveWorkspaceOptimistically(input: { } export async function archiveWorkspacesOptimistically(input: { - client: WorkspaceArchiveClient; + getClient: (serverId: string) => WorkspaceArchiveClient | null; workspaces: WorkspaceArchiveTarget[]; }): Promise { const results = await Promise.allSettled( input.workspaces.map(async (workspace) => { + const client = input.getClient(workspace.serverId); + if (!client) { + throw { + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + error: new Error(i18n.t("sidebar.workspace.toasts.hostDisconnected")), + } satisfies WorkspaceArchiveFailure; + } + try { await archiveWorkspaceOptimistically({ - client: input.client, + client, workspace, }); } catch (error) { - throw { workspaceId: workspace.workspaceId, error } satisfies WorkspaceArchiveFailure; + throw { + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + error, + } satisfies WorkspaceArchiveFailure; } }), );