Merge sidebar workspaces across all connected hosts (#1538)

* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
This commit is contained in:
Mohamed Boudra
2026-06-26 12:16:49 +08:00
committed by GitHub
parent 1109e453bc
commit a49c658d1f
103 changed files with 6856 additions and 2857 deletions
+52 -42
View File
@@ -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<void> {
const envTestPath = path.join(repoRoot, ".env.test");
@@ -440,7 +444,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise<void> {
}
}
async function resolveDictationConfig(): Promise<DictationConfig> {
async function logSpeechHarnessConfig(): Promise<void> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -451,23 +455,20 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
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<typeof createLineBuffer>;
}
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<void> {
await rm(targetPath, {
recursive: true,
force: true,
maxRetries: 40,
retryDelay: 250,
});
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
@@ -731,13 +742,13 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
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,
});
+3 -4
View File
@@ -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<void> {
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,
});
@@ -0,0 +1,17 @@
import { expect, type Page } from "@playwright/test";
export async function expectAppRoute(
page: Page,
expectedRoute: string,
options?: { timeout?: number },
): Promise<void> {
await expect
.poll(
() => {
const current = new URL(page.url());
return `${current.pathname}${current.search}`;
},
{ timeout: options?.timeout },
)
.toBe(expectedRoute);
}
+16 -14
View File
@@ -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<void> {
@@ -55,9 +61,7 @@ export async function openSettingsHostSection(
section: HostSection,
): Promise<void> {
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<void> {
@@ -84,13 +88,13 @@ export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
export async function openCompactSettings(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
export async function openCompactSettings(page: Page, expectedStartRoute: string): Promise<void> {
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<void> {
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<void> {
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<void> {
@@ -201,9 +205,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
}
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
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<void> {
+8 -7
View File
@@ -156,14 +156,15 @@ class StartupAssertions {
this.page = page;
}
async expectsSavedHostShell(input: { label: string }): Promise<this> {
async expectsSavedHostShell(input: { serverId: string; label: string }): Promise<this> {
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;
}
+2 -4
View File
@@ -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<void> {
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 });
}
/**
+18 -12
View File
@@ -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);
+1 -1
View File
@@ -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();
});
@@ -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<ReturnType<typeof seedWorkspace>>;
},
): Promise<void> {
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,
+80 -44
View File
@@ -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({
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenter />
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
@@ -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<string | null>(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<PendingOpenProjectRequest | null>(null);
const [pendingPath, setPendingPath] = useState<string | null>(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() {
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
+7 -36
View File
@@ -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 <StartupSplashScreen />;
}
return (
<Redirect
href={resolveHostIndexRoute({
serverId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces,
workspaceExists: workspaceSelectionExists,
}),
})}
/>
);
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 <Redirect href={buildOpenProjectRoute()} />;
}
@@ -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 (
<HostRouteBootstrapBoundary>
<HostOpenProjectRouteContent />
<Redirect href={buildOpenProjectRoute()} />
</HostRouteBootstrapBoundary>
);
}
function HostOpenProjectRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <OpenProjectScreen serverId={serverId} />;
}
+5 -16
View File
@@ -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 (
<HostRouteBootstrapBoundary>
<HostAgentsRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentsRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <SessionsScreen serverId={serverId} />;
export default function HostSessionsRoute() {
// COMPAT(hostSessionsRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildSessionsRoute()} />;
}
@@ -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;
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function OpenProjectRoute() {
return (
<HostRouteBootstrapBoundary>
<OpenProjectScreen />
</HostRouteBootstrapBoundary>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function SessionsRoute() {
return (
<HostRouteBootstrapBoundary>
<SessionsScreen />
</HostRouteBootstrapBoundary>
);
}
+3 -2
View File
@@ -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 <SettingsScreen view={view} />;
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
}
+101 -21
View File
@@ -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 ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{showDesktopAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</>
);
}
function SessionRowTrailingAttention({
isMobile,
showAttentionIndicator,
requiresAttention,
}: {
isMobile: boolean;
showAttentionIndicator: boolean;
requiresAttention: boolean | undefined;
}) {
const { t } = useTranslation();
if (!isMobile || !showAttentionIndicator || !requiresAttention) {
return null;
}
return (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
);
}
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({
() => <Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />,
[theme.fontSize.xs, theme.colors.foregroundMuted],
);
const showDesktopAttention =
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
return (
<Pressable
@@ -223,20 +278,14 @@ function SessionRow({
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || t("agentList.fallbackTitle")}
</Text>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
pendingPermissionCount={pendingPermissionCount}
showDesktopAttention={showDesktopAttention}
/>
</View>
{isMobile && (
{isMobile ? (
<View style={styles.rowMetaRow}>
<Text
style={styles.sessionMetaText}
@@ -263,10 +312,18 @@ function SessionRow({
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
)}
) : null}
</View>
{!isMobile && (
{!isMobile ? (
<View style={styles.rowColumns}>
<Text
style={styles.columnMeta}
@@ -275,6 +332,11 @@ function SessionRow({
>
{projectName}
</Text>
{showHostColumn ? (
<Text style={styles.columnMetaHost} numberOfLines={1}>
{agent.serverLabel}
</Text>
) : null}
<Text
style={styles.columnMeta}
numberOfLines={1}
@@ -286,12 +348,12 @@ function SessionRow({
{timeAgo}
</Text>
</View>
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
) : null}
<SessionRowTrailingAttention
isMobile={isMobile}
showAttentionIndicator={showAttentionIndicator}
requiresAttention={agent.requiresAttention}
/>
</Pressable>
);
}
@@ -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",
@@ -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 <View style={dotStyle} />;
}
const styles = StyleSheet.create(() => ({
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
}));
@@ -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")
);
}
@@ -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);
});
@@ -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<ComboboxProps["renderOption"]>;
interface HostPickerHost {
serverId: string;
label: string;
}
export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElement {
return (
<View style={styles.statusDotSlot}>
<HostStatusDot serverId={serverId} />
</View>
);
}
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(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
const handleSettingsPress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onOpenHostSettings?.(serverId);
},
[onOpenHostSettings, serverId],
);
const trailingSlot = useMemo(() => {
if (!isLocal && !onOpenHostSettings) return undefined;
return (
<>
{isLocal ? (
<Text style={styles.localMarker} testID={localMarkerTestID}>
Local
</Text>
) : null}
{onOpenHostSettings ? (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</>
);
}, [
handleSettingsPress,
isLocal,
label,
localMarkerTestID,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
);
}
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 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
);
}
export interface HostPickerProps {
hosts: HostPickerHost[];
value: string;
onSelect: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
anchorRef: React.RefObject<View | null>;
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<RenderHostOption>(
({ option, selected, active, onPress }) => {
if (option.id === ADD_HOST_OPTION_ID) {
return (
<SystemHostPickerOption
kind="add"
active={active}
onPress={onPress}
testID={addHostTestID}
/>
);
}
if (option.id === ALL_HOSTS_OPTION_ID) {
return (
<SystemHostPickerOption
kind="all"
active={active}
selected={selected}
onPress={onPress}
/>
);
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={showLocalMarker === true && localServerId === option.id}
selected={selected}
active={active}
onPress={onPress}
onOpenHostSettings={onOpenHostSettings ? handleOpenHostSettings : undefined}
localMarkerTestID={hostLocalMarkerTestID?.(option.id)}
testID={hostOptionTestID?.(option.id)}
/>
);
},
[
addHostTestID,
hostLocalMarkerTestID,
hostOptionTestID,
localServerId,
onOpenHostSettings,
showLocalMarker,
handleOpenHostSettings,
],
);
return (
<>
{children}
<Combobox
options={options}
value={value}
onSelect={handleSelect}
renderOption={renderOption}
searchable={isSearchable}
searchPlaceholder="Search hosts"
title={title ?? "Host"}
open={open}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
/>
</>
);
}
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],
},
}));
+165 -355
View File
@@ -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<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
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;
}) => (
<HostSwitchOption
serverId={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
/>
),
[],
);
const hostTriggerRef = useRef<View | null>(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<View>;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
hostOptionsEmpty: boolean;
hostStatusDotStyle: StyleProp<ViewStyle>;
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 (
<Pressable
ref={triggerRef}
style={pressableStyle}
onPress={handlePress}
disabled={hostOptionsEmpty}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
);
}
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 (
<ComboboxItem
label={label}
description={formatConnectionStatus(connectionStatus)}
selected={selected}
active={active}
onPress={onPress}
/>
);
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<View | null>;
}) {
return (
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
@@ -449,7 +335,7 @@ function FooterIconButton({
>
{({ hovered }) => (
<Icon
size={theme.iconSize.md}
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -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<View | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
onOpenHostSettings(id);
},
[onOpenHostSettings],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
return (
<HostPicker
hosts={hosts}
value=""
onSelect={handleSelect}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
onOpenHostSettings={onOpenHostSettings}
searchable
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
hostLocalMarkerTestID={sidebarHostLocalMarkerTestID}
>
<FooterIconButton
buttonRef={triggerRef}
onPress={handleOpen}
testID="sidebar-hosts-trigger"
accessibilityLabel="Hosts"
icon={Server}
iconSize={theme.iconSize.sm}
theme={theme}
/>
</HostPicker>
);
}
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<ViewStyle>;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
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 (
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<HostPickerTrigger
triggerRef={hostTriggerRef}
setIsHostPickerOpen={setIsHostPickerOpen}
hostOptionsEmpty={hostOptions.length === 0}
hostStatusDotStyle={hostStatusDotStyle}
activeHostLabel={activeHostLabel}
/>
</View>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<FooterIconButton
@@ -566,33 +488,15 @@ function SidebarFooter({
theme={theme}
/>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title={labels.switchHost}
searchPlaceholder={labels.searchHosts}
desktopMinWidth={280}
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
);
}
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"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -851,12 +740,13 @@ function MobileSidebar({
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
@@ -867,19 +757,12 @@ function MobileSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</Animated.View>
@@ -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({
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
@@ -1029,19 +904,12 @@ function DesktopSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
{/* 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 }) {
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu serverId={serverId} />
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
@@ -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",
@@ -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<OpenProjectFailure["errorCode"]>;
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 (
<Pressable style={pressableStyle} onPress={handlePress}>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Icon size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
</View>
<Text style={rowTextStyle} numberOfLines={1}>
{label}
{shortenPath(path)}
</Text>
{option.kind === "path" ? (
<Text style={rowActionTextStyle}>{t("projectPicker.openPath")}</Text>
) : null}
</View>
</Pressable>
);
@@ -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<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [openErrorReason, setOpenErrorReason] = useState<OpenProjectFailureReason | null>(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 ? <Text style={errorTextStyle}>{errorMessage}</Text> : null}
</View>
<ScrollView
@@ -300,17 +274,19 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && options.length === 0 ? (
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && options.length > 0 ? (
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
openPathLabel={t("projectPicker.openPath")}
onSelect={handleSelectPath}
/>
))}
@@ -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],
@@ -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: {},
});
});
});
File diff suppressed because it is too large Load Diff
@@ -5,14 +5,16 @@ import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -32,22 +34,29 @@ interface DisplayPreferenceOption<Value extends string> {
label: string;
}
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelect = useCallback(
const handleSelectMode = useCallback(
(mode: SidebarGroupMode) => {
if (!serverId) return;
setGroupMode(serverId, mode);
setGroupMode(mode);
},
[serverId, setGroupMode],
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
@@ -65,6 +74,8 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
[],
);
const showHostFilter = hosts.length > 1;
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -75,7 +86,7 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
@@ -85,9 +96,33 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelect}
onSelect={handleSelectMode}
/>
))}
{showHostFilter ? (
<>
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
))}
</>
) : null}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
@@ -129,6 +164,33 @@ function DisplayPreferenceMenuItem<Value extends string>({
);
}
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
@@ -1,9 +1,14 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
useSidebarWorkspaceEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
@@ -20,12 +25,44 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
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 {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -40,11 +77,21 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
workspaces: SidebarStatusWorkspacePlacement[];
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -53,7 +100,6 @@ interface StatusWorkspaceListProps {
export function SidebarStatusWorkspaceList({
workspaces,
projectNamesByKey,
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -89,7 +135,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -106,7 +151,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -121,7 +165,6 @@ function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
serverId,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -129,7 +172,6 @@ function StatusGroupList({
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -149,7 +191,6 @@ function StatusGroupList({
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
serverId={serverId}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -248,45 +289,473 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
projectName,
serverId,
shortcutNumber,
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
workspace: SidebarStatusWorkspacePlacement;
projectName: string;
serverId: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
const handlePress = useCallback(() => {
if (!serverId) return;
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!hydratedWorkspace) return null;
if (!workspaceEntry) return null;
return (
<SidebarWorkspaceRow
workspace={hydratedWorkspace}
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
onPress={handlePress}
subtitle={projectName}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
}) {
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: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [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 : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.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;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
function StatusWorkspaceRowInner({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const isTouchPlatform = platformIsNative;
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 accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive?: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({
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 archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
}: {
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -350,4 +819,37 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));
+10 -4
View File
@@ -230,6 +230,7 @@ export interface ComboboxItemProps {
disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
interactiveFeedback?: boolean;
onPress: () => void;
testID?: string;
}
@@ -244,6 +245,7 @@ export function ComboboxItem({
active,
disabled,
elevated,
interactiveFeedback = true,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -269,12 +271,16 @@ export function ComboboxItem({
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.comboboxItem,
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
interactiveFeedback &&
hovered &&
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
interactiveFeedback &&
pressed &&
(elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
interactiveFeedback && active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
],
[elevated, active, disabled],
[elevated, active, disabled, interactiveFeedback],
);
const itemContentStyle = useMemo(
@@ -992,5 +992,6 @@ const styles = StyleSheet.create((theme) => ({
},
itemContent: {
flexShrink: 1,
minWidth: 0,
},
}));
+7 -10
View File
@@ -12,7 +12,7 @@ import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
@@ -170,15 +170,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
useEffect(() => {
if (!anyOnlineServerId) return;
router.replace(buildHostRootRoute(anyOnlineServerId));
router.replace(buildOpenProjectRoute());
}, [anyOnlineServerId, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId));
},
[router],
);
const finishOnboarding = useCallback(() => {
router.replace(buildOpenProjectRoute());
}, [router]);
const handleOpenPaseoSite = useCallback(() => {
void openExternalUrl("https://paseo.sh");
@@ -197,9 +194,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, [router]);
const handleHostSaved = useCallback(
({ profile, serverId }: { profile: HostProfile; serverId: string }) => {
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
@@ -39,6 +39,8 @@ import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
interface Rect {
x: number;
@@ -284,6 +286,7 @@ function WorkspaceHoverCardContent({
{workspace.name}
</Text>
</View>
<HostRow serverId={workspace.serverId} />
{workspace.currentBranch ? (
<CopyableInfoRow
icon={ThemedGitBranch}
@@ -327,6 +330,22 @@ function WorkspaceHoverCardContent({
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedFolder = withUnistyles(Folder);
function HostRow({ serverId }: { serverId: string }): ReactElement | null {
const hosts = useHosts();
const host = hosts.find((h) => h.serverId === serverId);
const snapshot = useHostRuntimeSnapshot(serverId);
const label = host?.label?.trim() || serverId;
const status = formatConnectionStatus(snapshot?.connectionStatus ?? "idle");
return (
<View style={styles.hostRow}>
<Text style={styles.hostRowLabel}>{label}</Text>
<Text style={styles.hostRowStatus}>{status}</Text>
</View>
);
}
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
@@ -548,6 +567,22 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
hostRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hostRowLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hostRowStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
cardMetaRow: {
flexDirection: "row",
alignItems: "center",
@@ -6,11 +6,13 @@ import { act } from "@testing-library/react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import type { HostProfile } from "@/types/host-connection";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
@@ -42,6 +44,27 @@ function workspaceDescriptor(input: {
};
}
function hostProfile(serverId = "srv"): HostProfile {
const now = "2026-04-19T00:00:00.000Z";
return {
serverId,
label: "Shortcut Host",
lifecycle: {},
connections: [],
preferredConnectionId: null,
createdAt: now,
updatedAt: now,
};
}
function setHostProfiles(hosts: HostProfile[]): void {
(
getHostRuntimeStore() as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
describe("WorkspaceShortcutTargetsSubscriber", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -58,14 +81,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
collapsedProjectKeys: new Set(),
});
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
projectOrder: [],
workspaceOrderByProject: {},
});
useSidebarViewStore.setState({
groupModeByServerId: {},
groupMode: "project",
hostFilter: null,
});
act(() => {
setHostProfiles([hostProfile()]);
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
"srv",
@@ -88,13 +113,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
container?.remove();
container = null;
act(() => {
setHostProfiles([]);
useSessionStore.getState().clearSession("srv");
useSessionStore.getState().clearSession("host-a");
useSessionStore.getState().clearSession("host-b");
});
});
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -105,7 +133,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("srv", "status");
useSidebarViewStore.getState().setGroupMode("status");
useSessionStore.getState().setWorkspaces(
"srv",
new Map([
@@ -158,7 +186,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -169,13 +197,52 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
it("publishes shortcut targets from the visible host filter in project and status modes", async () => {
act(() => {
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
useSessionStore
.getState()
.setWorkspaces(
"host-b",
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().setHostFilter("host-b");
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "host-b", workspaceId: "b-1" },
]);
await act(async () => {
useSidebarViewStore.getState().setGroupMode("status");
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "host-b", workspaceId: "b-1" },
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
@@ -1,9 +1,6 @@
import { useEffect, useMemo } from "react";
import {
useProjectNamesMap,
useStatusModeWorkspaceEntries,
} from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
@@ -12,28 +9,16 @@ import {
buildStatusSidebarShortcutModel,
} from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({
enabled,
serverId,
}: {
enabled: boolean;
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
// groupMode must be resolved before gating the status-mode subscriptions below.
const groupMode = useSidebarViewStore((state) =>
enabled && serverId ? state.getGroupMode(serverId) : "project",
);
// Only subscribe to agents/workspaces when the status-group view is actually active.
// In project mode (the default), these subscriptions would fire on every agent update
// (agents Map identity is replaced on every status transition) with no effect on
// the shortcut targets, causing ~15-46 wasted re-renders per agent switch.
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({
enabled,
});
const groupMode = useSidebarViewStore((state) => state.groupMode);
const isStatusMode = enabled && groupMode === "status";
const statusWorkspaces = useStatusModeWorkspaceEntries({
serverId: isStatusMode ? serverId : null,
projects,
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
enabled: isStatusMode,
});
const projectNamesByKey = useProjectNamesMap(isStatusMode ? serverId : null);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
@@ -47,7 +32,7 @@ export function WorkspaceShortcutTargetsSubscriber({
const shortcutModel = useMemo(() => {
if (groupMode === "status") {
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces,
workspaces: statusWorkspacePlacements,
projectNamesByKey,
collapsedStatusGroupKeys,
});
@@ -63,17 +48,17 @@ export function WorkspaceShortcutTargetsSubscriber({
groupMode,
projectNamesByKey,
projects,
statusWorkspaces,
statusWorkspacePlacements,
]);
useEffect(() => {
if (!enabled || !serverId) {
if (!enabled) {
setSidebarShortcutWorkspaceTargets([]);
return;
}
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
}, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
}, [enabled, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
useEffect(() => {
return () => {
@@ -1,7 +1,12 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
const pendingWorkspaceArchivesByServer = new Map<string, Set<string>>();
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
@@ -10,6 +15,7 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
@@ -17,8 +23,11 @@ export function markWorkspaceArchivePending(input: {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Set<string>();
archives.add(pendingArchiveKey({ serverId, workspaceId }));
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
@@ -45,15 +54,34 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
if (!serverId) {
return false;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
return archives?.has(pendingArchiveKey({ serverId, workspaceId })) ?? false;
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -63,5 +91,6 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}
@@ -1,23 +0,0 @@
import { getDesktopHost } from "@/desktop/host";
export async function pickDirectory(): Promise<string | null> {
const open = getDesktopHost()?.dialog?.open;
if (typeof open !== "function") {
throw new Error("Desktop dialog open() is unavailable in this environment.");
}
const selection = await open({
directory: true,
multiple: false,
});
if (selection === null) {
return null;
}
if (typeof selection === "string") {
return selection;
}
throw new Error("Unexpected directory picker response.");
}
+57
View File
@@ -0,0 +1,57 @@
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
interface PrStatusLike {
url: string;
state: string;
isMerged: boolean;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
let state: "merged" | "open" | "closed";
if (status.isMerged || status.state === "merged") state = "merged";
else if (status.state === "open") state = "open";
else state = "closed";
return {
url: status.url,
number,
state,
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
};
}
+2 -58
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
interface UseCheckoutPrStatusQueryOptions {
serverId: string;
@@ -11,64 +12,7 @@ interface UseCheckoutPrStatusQueryOptions {
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
interface PrStatusLike {
url: string;
state: string;
isMerged: boolean;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
let state: "merged" | "open" | "closed";
if (status.isMerged || status.state === "merged") state = "merged";
else if (status.state === "open") state = "open";
else state = "closed";
return {
url: status.url,
number,
state,
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
};
}
export { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
return selectPrHintFromStatus(payload.status);
@@ -1,3 +1,13 @@
export function agentHistoryQueryKey(serverId: string | null) {
return ["agentHistory", serverId] as const;
}
const ALL_AGENT_HISTORY_QUERY_ROOT = ["allAgentHistory"] as const;
export function allAgentHistoryQueryRootKey() {
return ALL_AGENT_HISTORY_QUERY_ROOT;
}
export function allAgentHistoryQueryKey(serverIds: readonly string[]) {
return [...ALL_AGENT_HISTORY_QUERY_ROOT, ...[...serverIds].sort()] as const;
}
+15
View File
@@ -19,6 +19,21 @@ export interface OpenProjectFailure {
}
export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure;
export type OpenProjectFailureReason = "directory_not_found" | "open_failed";
export function getOpenProjectFailureReason(
result: OpenProjectResult,
): OpenProjectFailureReason | null {
if (result.ok) {
return null;
}
if (result.errorCode === "directory_not_found") {
return "directory_not_found";
}
return "open_failed";
}
export interface OpenProjectDirectlyInput {
serverId: string;
@@ -15,6 +15,7 @@ function ws(
serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj",
projectName: input.projectName ?? "Project",
projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory,
projectKind: input.projectKind ?? "git",
@@ -1,6 +1,6 @@
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
"needs_input",
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
export interface StatusGroup {
bucket: StatusBucket;
label: string;
rows: SidebarWorkspaceEntry[];
rows: SidebarStatusWorkspacePlacement[];
}
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
workspaces: SidebarStatusWorkspacePlacement[],
projectNamesByKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
for (const ws of workspaces) {
const bucket: StatusBucket = ws.statusBucket;
@@ -54,8 +54,8 @@ export function buildStatusGroups(
}
function compareStatusRows(
a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry,
a: SidebarStatusWorkspacePlacement,
b: SidebarStatusWorkspacePlacement,
projectNamesByKey: Map<string, string>,
): number {
const aTime = a.statusEnteredAt?.getTime() ?? null;
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarStatusWorkspacePlacements,
buildSidebarWorkspacePlacementModel,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
@@ -23,12 +26,20 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
hosts?: WorkspaceStructureProject["hosts"];
}): WorkspaceStructureProject {
return {
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
hosts: input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys,
};
}
@@ -36,10 +47,8 @@ function project(input: {
function sidebarProject(input: {
projectKey: string;
workspaceKeys: string[];
serverId?: string;
}): SidebarProjectEntry {
const projects = buildSidebarProjectsFromStructure({
serverId: input.serverId ?? "srv",
projects: [project({ projectKey: input.projectKey, workspaceKeys: input.workspaceKeys })],
});
const result = projects[0];
@@ -49,6 +58,31 @@ function sidebarProject(input: {
return result;
}
function workspace(input: {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
projectKind: "git",
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
@@ -107,7 +141,6 @@ describe("appendMissingOrderKeys", () => {
describe("buildSidebarProjectsFromStructure", () => {
it("creates structural workspace rows from ordered workspace keys", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [
project({
projectKey: "project-1",
@@ -131,7 +164,6 @@ describe("buildSidebarProjectsFromStructure", () => {
it("preserves the structure hook project order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
@@ -143,15 +175,126 @@ describe("buildSidebarProjectsFromStructure", () => {
it("preserves the structure hook workspace order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
});
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
expect(projects[0]?.workspaces.map((placement) => placement.workspaceId)).toEqual([
"feature",
"main",
]);
});
it("resolves workspace keys by known host prefix when server ids contain colons", () => {
const projects = buildSidebarProjectsFromStructure({
projects: [
project({
projectKey: "project-1",
hosts: [
{
serverId: "relay:paseo-host",
iconWorkingDir: "/repo/project-1",
canCreateWorktree: true,
},
],
workspaceKeys: ["relay:paseo-host:ws-main"],
}),
],
});
expect(projects[0]?.workspaces[0]).toMatchObject({
workspaceKey: "relay:paseo-host:ws-main",
serverId: "relay:paseo-host",
workspaceId: "ws-main",
});
});
});
describe("shared sidebar workspace model", () => {
it("feeds project placement and status grouping from the same cross-host workspace identities", () => {
const model = buildSidebarWorkspacePlacementModel({
projects: [
project({
projectKey: "getpaseo/paseo",
projectName: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
],
workspaceKeys: ["host-a:main", "host-b:feature"],
}),
],
});
const statusRows = buildSidebarStatusWorkspacePlacements({
placements: model.workspaces,
sessions: [
{
serverId: "host-a",
workspaces: new Map([
[
"main",
workspace({
id: "main",
name: "main",
projectId: "getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
status: "done",
}),
],
]),
},
{
serverId: "host-b",
workspaces: new Map([
[
"feature",
workspace({
id: "feature",
name: "feature/status-flow",
projectId: "getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
status: "running",
statusEnteredAt: new Date("2026-06-10T00:00:00.000Z"),
}),
],
]),
},
],
});
expect(model.workspaces.map((entry) => entry.workspaceKey)).toEqual([
"host-a:main",
"host-b:feature",
]);
expect(model.projects).toEqual([
expect.objectContaining({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
],
workspaces: [
expect.objectContaining({
workspaceKey: "host-a:main",
serverId: "host-a",
name: "main",
}),
expect.objectContaining({
workspaceKey: "host-b:feature",
serverId: "host-b",
name: "feature",
}),
],
}),
]);
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
[
["host-a:main", "done", "main"],
["host-b:feature", "running", "feature/status-flow"],
],
);
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
});
});
describe("computeSidebarOrderUpdates", () => {
@@ -206,8 +349,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: false,
}),
).toEqual({ isLoading: true, isInitialLoad: true, isRevalidating: false });
@@ -217,8 +360,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: true,
}),
).toEqual({ isLoading: true, isInitialLoad: false, isRevalidating: false });
@@ -228,8 +371,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: true,
serverIds: ["srv"],
hydratedServerIds: ["srv"],
hasProjects: true,
}),
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
@@ -239,8 +382,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: false,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: false,
}),
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
@@ -1,38 +1,44 @@
import type { PrHint } from "@/git/use-pr-status-query";
import {
canCreateWorktreeForProjectKind,
type HostProjectListItem,
} from "@/projects/host-project-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import type { PrHint } from "@/git/pr-hint";
import { selectPrHintFromStatus } from "@/git/pr-hint";
import { type HostProjectListItem } from "@/projects/host-project-model";
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
function workspaceNameFromDirectory(directory: string): string {
const trimmed = directory.trim().replace(/[\\/]+$/g, "");
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
}
export type SidebarStateBucket = WorkspaceDescriptor["status"];
export interface SidebarWorkspaceEntry {
export interface SidebarWorkspacePlacement {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKey: string;
projectName: string;
projectRootPath?: string;
workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
}
export interface SidebarStatusWorkspacePlacement extends SidebarWorkspacePlacement {
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
}
export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
// Raw user-set title (null when the name is derived from branch/directory).
// Prefills the rename input and signals whether a reset is available.
title: string | null;
// Checkout branch (null when not a git checkout or detached HEAD).
currentBranch: string | null;
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
prHint: PrHint | null;
@@ -47,52 +53,278 @@ export interface SidebarProjectEntry {
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
canCreateWorktree: boolean;
workspaces: SidebarWorkspaceEntry[];
hosts: WorkspaceStructureHostPlacement[];
workspaces: SidebarWorkspacePlacement[];
}
export interface SidebarWorkspacePlacementModel {
workspaces: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
}
export interface SidebarStatusWorkspaceSession {
serverId: string;
workspaces: Map<string, WorkspaceDescriptor>;
agents?: Map<string, Agent>;
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
return (
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey)
);
}
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
if (!currentBranch) {
return null;
}
const trimmed = currentBranch.trim();
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
}
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey,
projectName: projectNameForWorkspace(input.workspace, projectKey),
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
title: input.workspace.title ?? null,
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};
}
function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
pendingCreateAttempts: input.pendingCreateAttempts,
});
if (pendingStartedAt) {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
function getPendingInitialAgentCreateStartedAt(input: {
serverId: string;
workspaceId: string;
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
}): Date | null {
let latestStartedAt: Date | null = null;
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
if (pending.serverId !== input.serverId) continue;
if (pending.workspaceId !== input.workspaceId) continue;
if (pending.lifecycle === "abandoned") continue;
const startedAt = new Date(pending.timestamp);
if (!latestStartedAt || startedAt > latestStartedAt) {
latestStartedAt = startedAt;
}
}
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents?: Map<string, Agent>;
}): WorkspaceAgentActivity | null {
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function buildSidebarWorkspacePlacementModel(input: {
projects: readonly HostProjectListItem[];
}): SidebarWorkspacePlacementModel {
const projects = buildSidebarProjectsFromHostProjects({ projects: input.projects });
return {
projects,
workspaces: projects.flatMap((project) => project.workspaces),
projectNamesByKey: new Map(
projects.map((project) => [project.projectKey, project.projectName]),
),
};
}
function createStructuralWorkspaceEntry(input: {
serverId: string;
project: HostProjectListItem;
workspaceId: string;
}): SidebarWorkspaceEntry {
workspaceKey: string;
}): SidebarWorkspacePlacement {
const identity = resolveStructuralWorkspaceIdentity({
project: input.project,
workspaceKey: input.workspaceKey,
});
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
workspaceKey: identity.workspaceKey,
serverId: identity.serverId,
workspaceId: identity.workspaceId,
projectKey: input.project.projectKey,
projectName: input.project.projectName,
projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined,
projectKind: input.project.projectKind,
workspaceKind: "checkout",
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
title: null,
currentBranch: null,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
name: identity.workspaceId,
};
}
export function buildSidebarProjectsFromStructure(input: {
function resolveStructuralWorkspaceIdentity(input: {
project: HostProjectListItem;
workspaceKey: string;
}): {
workspaceKey: string;
serverId: string;
workspaceId: string;
} {
const hostsByLongestPrefix = [...input.project.hosts].sort(
(left, right) => right.serverId.length - left.serverId.length,
);
for (const host of hostsByLongestPrefix) {
const prefix = `${host.serverId}:`;
if (!input.workspaceKey.startsWith(prefix)) continue;
const workspaceId = input.workspaceKey.slice(prefix.length);
if (!workspaceId) continue;
return {
workspaceKey: input.workspaceKey,
serverId: host.serverId,
workspaceId,
};
}
const separatorIndex = input.workspaceKey.indexOf(":");
if (separatorIndex > 0) {
return {
workspaceKey: input.workspaceKey,
serverId: input.workspaceKey.slice(0, separatorIndex),
workspaceId: input.workspaceKey.slice(separatorIndex + 1),
};
}
const serverId = input.project.hosts[0]?.serverId ?? input.workspaceKey;
return {
workspaceKey: `${serverId}:${input.workspaceKey}`,
serverId,
workspaceId: input.workspaceKey,
};
}
export function buildSidebarStatusWorkspacePlacements(input: {
placements: readonly SidebarWorkspacePlacement[];
sessions: SidebarStatusWorkspaceSession[];
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
}): SidebarStatusWorkspacePlacement[] {
if (input.placements.length === 0 || input.sessions.length === 0) {
return [];
}
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
const rows: SidebarStatusWorkspacePlacement[] = [];
for (const placement of input.placements) {
const session = sessionByServerId.get(placement.serverId);
if (!session) continue;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: session.workspaces,
workspaceId: placement.workspaceId,
});
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
if (!workspace) continue;
const effectiveStatus = deriveEffectiveWorkspaceStatus({
serverId: placement.serverId,
workspace,
pendingCreateAttempts: input.pendingCreateAttempts,
agents: session.agents,
});
rows.push({
...placement,
name: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
});
}
return rows;
}
export function buildSidebarProjectsFromStructure(input: {
projects: WorkspaceStructureProject[];
}): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
})),
});
}
@@ -109,12 +341,11 @@ export function buildSidebarProjectsFromHostProjects(input: {
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
canCreateWorktree: project.canCreateWorktree,
workspaces: project.workspaceKeys.map((workspaceId) =>
hosts: project.hosts,
workspaces: project.workspaceKeys.map((workspaceKey) =>
createStructuralWorkspaceEntry({
serverId: project.serverId,
project,
workspaceId,
workspaceKey,
}),
),
}));
@@ -227,11 +458,14 @@ export interface SidebarLoadingState {
export function deriveSidebarLoadingState(input: {
isActive: boolean;
serverId: string | null;
hasHydratedWorkspaces: boolean;
serverIds: string[];
hydratedServerIds: string[];
hasProjects: boolean;
}): SidebarLoadingState {
const isLoading = input.isActive && Boolean(input.serverId) && !input.hasHydratedWorkspaces;
const hasRegisteredHosts = input.serverIds.length > 0;
const allHydrated =
input.serverIds.length > 0 && input.serverIds.length === input.hydratedServerIds.length;
const isLoading = input.isActive && hasRegisteredHosts && !allHydrated;
const isInitialLoad = isLoading && !input.hasProjects;
return { isLoading, isInitialLoad, isRevalidating: false };
}
@@ -1,15 +0,0 @@
import { useMemo } from "react";
import { usePathname } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { resolveActiveHost } from "@/utils/active-host";
export function useActiveHost() {
const pathname = usePathname();
const hosts = useHosts();
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
}
export function useActiveServerId(): string | null {
return useActiveHost()?.serverId ?? null;
}
@@ -1,10 +1,28 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type {
DaemonClient,
FetchAgentHistoryEntry,
FetchAgentHistoryOptions,
} from "@getpaseo/client/internal/daemon-client";
import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history";
import type { AgentHistoryClient, AgentHistoryHost } from "./use-agent-history";
import { allAgentHistoryQueryKey } from "./agent-history-query-key";
(
globalThis as unknown as {
__DEV__: boolean;
}
).__DEV__ = false;
type UseAgentHistoryModule = typeof import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
});
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
@@ -30,6 +48,17 @@ function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient
};
}
function createFailingClient(): FakeAgentHistoryClient {
const calls: FetchAgentHistoryOptions[] = [];
return {
calls,
fetchAgentHistory: async (options) => {
calls.push(options ?? {});
throw new Error("Host history failed");
},
};
}
function historyPayload(input: {
entries: FetchAgentHistoryEntry[];
hasMore?: boolean;
@@ -105,6 +134,12 @@ function historyEntry(input: {
}
describe("fetchAgentHistoryPage", () => {
it("builds the all-host query key independent of host order", () => {
expect(allAgentHistoryQueryKey(["server-b", "server-a"])).toEqual(
allAgentHistoryQueryKey(["server-a", "server-b"]),
);
});
it("requests the first page with the default limit and updated_at descending sort", async () => {
const client = createClient([
historyPayload({
@@ -206,4 +241,134 @@ describe("fetchAgentHistoryPage", () => {
expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
});
it("fetches and sorts history across hosts with host labels", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "older-a",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Older A",
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "newer-b",
cwd: "/repo/b",
updatedAt: "2026-04-02T10:00:00.000Z",
title: "Newer B",
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
});
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
"Linux box:newer-b",
"MacBook:older-a",
]);
});
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "next-a",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "next-b",
cwd: "/repo/b",
updatedAt: "2026-04-02T10:00:00.000Z",
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: { "server-b": "cursor-b" },
});
expect(page.agents.map((agent) => agent.id)).toEqual(["next-b"]);
expect(serverAClient.calls).toEqual([]);
expect(serverBClient.calls).toEqual([
{
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-b" },
} satisfies FetchAgentHistoryOptions,
]);
});
it("keeps fulfilled host history when another host fails", async () => {
const failedClient = createFailingClient();
const healthyClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "healthy-history",
cwd: "/repo/healthy",
updatedAt: "2026-04-02T10:00:00.000Z",
}),
],
hasMore: true,
nextCursor: "healthy-cursor",
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "failed-host", serverLabel: "Failed", client: failedClient },
{ serverId: "healthy-host", serverLabel: "Healthy", client: healthyClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
});
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
"Healthy:healthy-history",
]);
expect(page.pageInfoByServerId).toEqual({
"healthy-host": {
nextCursor: "healthy-cursor",
prevCursor: null,
hasMore: true,
},
});
});
it("throws when every requested host history fetch fails", async () => {
await expect(
fetchAgentHistoryBatch({
hosts: [
{ serverId: "failed-a", serverLabel: "Failed A", client: createFailingClient() },
{ serverId: "failed-b", serverLabel: "Failed B", client: createFailingClient() },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
}),
).rejects.toThrow("No connected hosts could load agent history");
});
});
+150 -37
View File
@@ -4,26 +4,28 @@ import type {
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
const AGENT_HISTORY_PAGE_LIMIT = 200;
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
const AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE = "No connected hosts could load agent history";
export interface AgentHistoryResult {
agents: AggregatedAgent[];
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
isError: boolean;
hasMore: boolean;
isLoadingMore: boolean;
refreshAll: () => void;
refreshAll: () => Promise<void>;
loadMore: () => void;
}
@@ -34,6 +36,19 @@ export interface AgentHistoryPage {
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
export interface AgentHistoryHost {
serverId: string;
serverLabel: string;
client: AgentHistoryClient;
}
interface AgentHistoryBatchPage {
agents: AggregatedAgent[];
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
}
type AgentHistoryCursorByServerId = Record<string, string | null>;
export async function fetchAgentHistoryPage(input: {
client: AgentHistoryClient;
serverId: string;
@@ -75,70 +90,167 @@ export async function fetchAgentHistoryPage(input: {
};
}
function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}
function getNextAgentHistoryPageParam(
page: AgentHistoryBatchPage,
): AgentHistoryCursorByServerId | null {
const cursorByServerId: AgentHistoryCursorByServerId = {};
for (const [serverId, pageInfo] of Object.entries(page.pageInfoByServerId)) {
if (pageInfo.hasMore && pageInfo.nextCursor) {
cursorByServerId[serverId] = pageInfo.nextCursor;
}
}
return Object.keys(cursorByServerId).length > 0 ? cursorByServerId : null;
}
export async function fetchAgentHistoryBatch(input: {
hosts: readonly AgentHistoryHost[];
cursorByServerId: AgentHistoryCursorByServerId | null;
}): Promise<AgentHistoryBatchPage> {
const cursorByServerId = input.cursorByServerId ?? {};
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
const hostsToFetch = hasCursorFilter
? input.hosts.filter((host) => Object.hasOwn(cursorByServerId, host.serverId))
: input.hosts;
const settledPages = await Promise.allSettled(
hostsToFetch.map(async (host) => {
const page = await fetchAgentHistoryPage({
client: host.client,
serverId: host.serverId,
cursor: cursorByServerId[host.serverId] ?? null,
});
return { host, page };
}),
);
const pages = settledPages.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
);
if (pages.length === 0) {
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
}
const agents = pages.flatMap(({ host, page }) =>
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
);
const pageInfoByServerId = Object.fromEntries(
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
);
return {
agents: sortByLatestActivity(agents),
pageInfoByServerId,
};
}
export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]);
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
const targetHosts = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
if (!client || !isHostRuntimeConnected(snapshot)) {
continue;
}
hosts.push({
serverId: targetServerId,
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
client,
});
}
return hosts;
}, [daemons, runtime, runtimeVersion, serverId]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
const queryKey = useMemo(
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
[serverId, targetServerIds],
);
const serverLabelById = useMemo(
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
[daemons],
);
const historyQuery = useInfiniteQuery<
AgentHistoryPage,
AgentHistoryBatchPage,
Error,
{ pages: AgentHistoryPage[] },
ReturnType<typeof agentHistoryQueryKey>,
string | null
{ pages: AgentHistoryBatchPage[] },
readonly unknown[],
AgentHistoryCursorByServerId | null
>({
queryKey,
enabled: Boolean(enabled && serverId && client && isConnected),
enabled: Boolean(enabled && targetHosts.length > 0),
staleTime: 30_000,
initialPageParam: null as string | null,
getNextPageParam: (lastPage) =>
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
initialPageParam: null,
getNextPageParam: getNextAgentHistoryPageParam,
queryFn: async ({ pageParam }) => {
if (!serverId || !client) {
if (targetHosts.length === 0) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
return fetchAgentHistoryBatch({
hosts: targetHosts,
cursorByServerId: pageParam,
});
},
});
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
historyQuery;
const {
data,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
refetch,
} = historyQuery;
const refreshAll = useCallback(() => {
if (!serverId || !client || !isConnected) {
const refreshAll = useCallback(async () => {
if (!enabled || targetHosts.length === 0) {
return;
}
void refetch();
}, [client, isConnected, refetch, serverId]);
await refetch();
}, [enabled, refetch, targetHosts.length]);
const loadMore = useCallback(() => {
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
if (!enabled || targetHosts.length === 0 || !hasNextPage || isFetchingNextPage) {
return;
}
void fetchNextPage();
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
const agents = useMemo(
() =>
(data?.pages ?? [])
.flatMap((page) => page.agents)
.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabel ?? agent.serverLabel,
}),
),
[data?.pages, serverLabel],
);
const agents = useMemo(() => {
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
const labelledAgents = historyAgents.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
}),
);
return sortByLatestActivity(labelledAgents);
}, [data?.pages, serverLabelById]);
const isInitialLoad = isLoading && agents.length === 0;
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
@@ -147,6 +259,7 @@ export function useAgentHistory(options: {
isLoading,
isInitialLoad,
isRevalidating,
isError,
hasMore: hasNextPage,
isLoadingMore: isFetchingNextPage,
refreshAll,
@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-all-agents-list";
import type { Agent } from "@/stores/session-store";
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "idle",
createdAt: AGENT_TIMESTAMP,
updatedAt: AGENT_TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: AGENT_TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/tmp/project",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input?: Partial<Agent>): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
describe("useAllAgentsList", () => {
it("excludes archived agents by default", () => {
const visibleAgent = makeAgent({ id: "visible" });
const archivedAgent = makeAgent({
id: "archived",
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
});
const result = __private__.buildAllAgentsList({
agents: [visibleAgent, archivedAgent],
serverId: "server-1",
serverLabel: "Local",
includeArchived: false,
});
expect(result.map((agent) => agent.id)).toEqual(["visible"]);
});
it("includes archived agents when requested", () => {
const visibleAgent = makeAgent({ id: "visible" });
const archivedAgent = makeAgent({
id: "archived",
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
});
const result = __private__.buildAllAgentsList({
agents: [visibleAgent, archivedAgent],
serverId: "server-1",
serverLabel: "Local",
includeArchived: true,
});
expect(result.map((agent) => agent.id)).toEqual(["visible", "archived"]);
expect(result[1]?.archivedAt).toEqual(archivedAgent.archivedAt);
});
});
@@ -1,125 +0,0 @@
import { useCallback, useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatus,
useHostRuntimeIsDirectoryLoading,
} from "@/runtime/host-runtime";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: {
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
const source = params.source;
return {
id: source.id,
serverId: params.serverId,
serverLabel: params.serverLabel,
title: source.title ?? null,
status: source.status,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,
provider: source.provider,
pendingPermissionCount: source.pendingPermissions.length,
requiresAttention: source.requiresAttention,
attentionReason: source.attentionReason,
attentionTimestamp: source.attentionTimestamp ?? null,
archivedAt: source.archivedAt ?? null,
createdAt: source.createdAt,
labels: source.labels,
};
}
function buildAllAgentsList(params: {
agents: Iterable<Agent>;
serverId: string;
serverLabel: string;
includeArchived: boolean;
}): AggregatedAgent[] {
const list: AggregatedAgent[] = [];
for (const agent of params.agents) {
const aggregated = toAggregatedAgent({
source: agent,
serverId: params.serverId,
serverLabel: params.serverLabel,
});
if (!params.includeArchived && aggregated.archivedAt) {
continue;
}
list.push(aggregated);
}
list.sort((left, right) => {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
if (leftRunning && !rightRunning) {
return -1;
}
if (!leftRunning && rightRunning) {
return 1;
}
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
});
return list;
}
export function useAllAgentsList(options?: {
serverId?: string | null;
includeArchived?: boolean;
}): AggregatedAgentsResult {
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const includeArchived = options?.includeArchived ?? false;
const liveAgents = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.agents ?? null) : null,
);
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
const refreshAll = useCallback(() => {
if (!serverId || connectionStatus !== "online") {
return;
}
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, connectionStatus]);
const agents = useMemo(() => {
if (!serverId || !liveAgents) {
return [];
}
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
return buildAllAgentsList({
agents: liveAgents.values(),
serverId,
serverLabel,
includeArchived,
});
}, [daemons, includeArchived, liveAgents, serverId]);
const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? "");
const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isRevalidating = isDirectoryLoading && agents.length > 0;
return {
agents,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
};
}
export const __private__ = {
buildAllAgentsList,
toAggregatedAgent,
};
@@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
import {
applyArchivedAgentCloseResults,
isAgentArchiving,
@@ -144,6 +144,17 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]), {
pages: [
{
agents: [
{ id: "agent-1", serverId: "server-a", archivedAt: null },
{ id: "agent-1", serverId: "server-b", archivedAt: null },
],
},
],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -174,6 +185,24 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
expect(
queryClient.getQueryState(allAgentHistoryQueryKey(["server-a", "server-b"]))?.isInvalidated,
).toBe(true);
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]))).toEqual({
pages: [
{
agents: [
{
id: "agent-1",
serverId: "server-a",
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
},
{ id: "agent-1", serverId: "server-b", archivedAt: null },
],
},
],
pageParams: [null],
});
});
it("can apply archived agent close results without invalidating cached lists", () => {
@@ -194,6 +223,10 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a"]), {
pages: [{ agents: [{ id: "agent-1", serverId: "server-a", archivedAt: null }] }],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -205,6 +238,9 @@ describe("useArchiveAgent", () => {
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(allAgentHistoryQueryKey(["server-a"]))?.isInvalidated).toBe(
false,
);
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
pages: [
{
@@ -213,5 +249,19 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a"]))).toEqual({
pages: [
{
agents: [
{
id: "agent-1",
serverId: "server-a",
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
},
],
},
],
pageParams: [null],
});
});
});
+30 -3
View File
@@ -1,8 +1,14 @@
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
const EMPTY_PENDING_ARCHIVE_AGENT_IDS = new Set<string>();
@@ -29,6 +35,7 @@ export interface AgentsListQueryData {
export interface AgentHistoryQueryAgent {
id?: string | null;
serverId?: string | null;
archivedAt?: Date | null;
}
@@ -175,7 +182,10 @@ export function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryDat
let pageChanged = false;
const agents = page.agents.map((agent) => {
if (agent.id !== input.agentId) {
if (
agent.id !== input.agentId ||
(agent.serverId != null && agent.serverId !== input.serverId)
) {
return agent;
}
pageChanged = true;
@@ -200,6 +210,10 @@ export function markAgentArchivedInHistoryCache(
agentHistoryQueryKey(input.serverId),
(current) => markAgentArchivedInHistoryPayload(current, input),
);
queryClient.setQueriesData<AgentHistoryQueryData | undefined>(
{ queryKey: allAgentHistoryQueryRootKey() },
(current) => markAgentArchivedInHistoryPayload(current, input),
);
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
@@ -218,6 +232,7 @@ interface ArchivedAgentListCacheSnapshot {
sidebarAgentsList: AgentsListQueryData | undefined;
allAgents: AgentsListQueryData | undefined;
agentHistory: AgentHistoryQueryData | undefined;
allAgentHistory: Array<[QueryKey, AgentHistoryQueryData | undefined]>;
}
interface ArchiveAgentMutationContext {
@@ -268,6 +283,9 @@ function getArchivedAgentListCacheSnapshot(
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
agentHistoryQueryKey(serverId),
),
allAgentHistory: queryClient.getQueriesData<AgentHistoryQueryData | undefined>({
queryKey: allAgentHistoryQueryRootKey(),
}),
};
}
@@ -295,6 +313,9 @@ function restoreArchivedAgentListCacheSnapshot(
);
restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
for (const [queryKey, querySnapshot] of snapshot.allAgentHistory) {
restoreCachedQuerySnapshot(queryClient, queryKey, querySnapshot);
}
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
@@ -360,6 +381,9 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
void input.queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void input.queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
}
}
@@ -449,6 +473,9 @@ export function useArchiveAgent() {
void queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
},
});
+5 -15
View File
@@ -4,14 +4,13 @@ import { router, usePathname, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -20,7 +19,6 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { isWeb } from "@/constants/platform";
const EMPTY_AGENTS: AggregatedAgent[] = [];
@@ -139,7 +137,6 @@ function resolveActionShortcutKeys(
export function useCommandCenter() {
const { t } = useTranslation();
const pathname = usePathname();
const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -153,11 +150,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = open ? routeActiveServerId : null;
const { agents } = useAllAgentsList({
serverId: activeServerId,
});
const { agents } = useAggregatedAgents();
const agentResults = useMemo(() => {
if (!open || agents.length === 0) {
@@ -172,10 +165,7 @@ export function useCommandCenter() {
return buildSettingsRoute();
}, []);
const homeRoute = useMemo<Href | undefined>(() => {
if (!routeActiveServerId) return undefined;
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
}, [routeActiveServerId]);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute() as Href, []);
const actionItems = useMemo(() => {
if (!open) {
@@ -239,7 +229,7 @@ export function useCommandCenter() {
[pathname, setOpen],
);
const openProjectPicker = useOpenProjectPicker(activeServerId);
const openProjectPicker = useOpenProjectPicker();
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {
@@ -23,7 +23,6 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
import { isNative } from "@/constants/platform";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
type ActiveWorkspaceSelection,
navigateToLastWorkspace,
@@ -55,8 +54,7 @@ export function useKeyboardShortcuts({
step: 0,
timeoutId: null,
});
const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
const openProjectPickerAction = useOpenProjectPicker();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
@@ -1,30 +1,15 @@
import { useCallback } from "react";
import { pickDirectory } from "@/desktop/pick-directory";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useIsLocalDaemon } from "./use-is-local-daemon";
import { useOpenProject } from "./use-open-project";
import { useHostChooser } from "@/hosts/host-chooser";
import { useProjectPickerStore } from "@/stores/project-picker-store";
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
const normalizedServerId = serverId?.trim() ?? "";
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
const openProject = useOpenProject(serverId);
export function useOpenProjectPicker(): () => void {
const chooseHost = useHostChooser();
const openProjectPicker = useProjectPickerStore((state) => state.open);
return useCallback(async () => {
if (!normalizedServerId) {
return;
}
if (!isLocalDaemon) {
setProjectPickerOpen(true);
return;
}
const path = await pickDirectory();
if (path === null) {
return;
}
await openProject(path);
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
return useCallback(() => {
chooseHost({
title: "Choose host",
onChooseHost: openProjectPicker,
});
}, [chooseHost, openProjectPicker]);
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { openProjectDirectly } from "@/hooks/open-project";
import { getOpenProjectFailureReason, openProjectDirectly } from "@/hooks/open-project";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
@@ -132,3 +132,21 @@ describe("openProjectDirectly", () => {
expect(session.hydrated).toEqual([]);
});
});
describe("getOpenProjectFailureReason", () => {
it("keeps the known directory-not-found failure reason", () => {
expect(
getOpenProjectFailureReason({
ok: false,
errorCode: "directory_not_found",
error: "Directory not found: /missing",
}),
).toBe("directory_not_found");
});
it("uses the generic failure reason for untyped project-open failures", () => {
expect(getOpenProjectFailureReason({ ok: false, errorCode: null, error: "boom" })).toBe(
"open_failed",
);
});
});
@@ -18,6 +18,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
serverId: "srv",
workspaceId,
projectKey,
projectName: projectKey,
projectRootPath: `/repo/${projectKey}`,
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
projectKind: "git",
@@ -43,7 +44,7 @@ function project(projectKey: string): SidebarProjectEntry {
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/repo/${projectKey}`,
canCreateWorktree: true,
hosts: [],
workspaces: [workspace(projectKey, `${projectKey}-main`)],
};
}
@@ -1,22 +1,24 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo } from "react";
import equal from "fast-deep-equal";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarProjectsFromHostProjects,
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
} from "./sidebar-workspaces-view-model";
export {
@@ -24,131 +26,21 @@ export {
applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure,
buildSidebarStatusWorkspacePlacements,
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
type SidebarLoadingState,
type SidebarOrderUpdates,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspacePlacement,
type SidebarWorkspacePlacementModel,
type SidebarProjectEntry,
type SidebarStateBucket,
type SidebarWorkspaceEntry,
} from "./sidebar-workspaces-view-model";
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
if (!currentBranch) {
return null;
}
const trimmed = currentBranch.trim();
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
}
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): SidebarWorkspaceEntry {
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
title: input.workspace.title ?? null,
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
pendingCreateAttempts: input.pendingCreateAttempts,
});
if (pendingStartedAt) {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
function getPendingInitialAgentCreateStartedAt(input: {
serverId: string;
workspaceId: string;
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
}): Date | null {
let latestStartedAt: Date | null = null;
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
if (pending.serverId !== input.serverId) continue;
if (pending.workspaceId !== input.workspaceId) continue;
if (pending.lifecycle === "abandoned") continue;
const startedAt = new Date(pending.timestamp);
if (!latestStartedAt || startedAt > latestStartedAt) {
latestStartedAt = startedAt;
}
}
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents: Map<string, Agent> | undefined;
}): WorkspaceAgentActivity | null {
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
@@ -182,9 +74,13 @@ export function useSidebarWorkspaceEntry(
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
const EMPTY_PROJECT_NAMES = new Map<string, string>();
export interface SidebarWorkspacesListResult {
workspacePlacements: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
@@ -192,121 +88,122 @@ export interface SidebarWorkspacesListResult {
}
export function useSidebarWorkspacesList(options?: {
serverId?: string | null;
hostFilter?: string | null;
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
const allHosts = useHosts();
const hostRegistryLoaded = useHostRegistryLoaded();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const isActive = Boolean(serverId) && options?.enabled !== false;
const persistedProjectOrder = useSidebarOrderStore((state) =>
isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const hostProjects = useHostProjects(isActive ? serverId : null);
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilter = options?.hostFilter ?? storeHostFilter;
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
const effectiveHostFilter =
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
const isActive = options?.enabled !== false;
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
);
const projects = useMemo(() => {
if (!serverId || hostProjects.length === 0) {
return EMPTY_PROJECTS;
const serverIds = useMemo(() => {
if (effectiveHostFilter) {
return allServerIds.filter((id) => id === effectiveHostFilter);
}
return buildSidebarProjectsFromHostProjects({
projects: hostProjects,
});
}, [hostProjects, serverId]);
return allServerIds;
}, [allServerIds, effectiveHostFilter]);
useEffect(() => {
if (!serverId) {
if (!hostRegistryLoaded) {
return;
}
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
reconcileHostFilter(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useStoreWithEqualityFn(
useSessionStore,
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
shallow,
);
const hostProjects = useHostProjects(serverIds);
const sidebarModel = useMemo(
() =>
buildSidebarWorkspacePlacementModel({
projects: hostProjects,
}),
[hostProjects],
);
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
const workspacePlacements =
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
const projectNamesByKey =
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES;
useEffect(() => {
if (!serverId) {
return;
}
const orderStore = useSidebarOrderStore.getState();
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey),
getWorkspaceOrder: (projectKey) =>
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER,
});
if (updates.projectOrder) {
orderStore.setProjectOrder(serverId, updates.projectOrder);
orderStore.setProjectOrder(updates.projectOrder);
}
for (const { projectKey, order } of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(serverId, projectKey, order);
orderStore.setWorkspaceOrder(projectKey, order);
}
}, [persistedProjectOrder, projects, serverId]);
}, [persistedProjectOrder, projects]);
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== "online") {
return;
}
const client = runtime.getClient(serverId);
if (!client) {
return;
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
if (!isActive) return;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
if (snapshot?.connectionStatus !== "online") continue;
const client = runtime.getClient(serverId);
if (!client) continue;
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
next.set(workspace.id, workspace);
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
}, [connectionStatus, isActive, runtime, serverId]);
})();
}
}, [isActive, runtime, serverIds]);
const loadingState = deriveSidebarLoadingState({
isActive,
serverId,
hasHydratedWorkspaces,
serverIds,
hydratedServerIds,
hasProjects: projects.length > 0,
});
return {
workspacePlacements,
projects,
projectNamesByKey,
...loadingState,
refreshAll,
};
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
areStatusModeSessionsEqual,
selectStatusModeSessions,
type StatusModeSession,
} from "./use-status-mode-workspaces";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
function workspaceMap(): Map<string, WorkspaceDescriptor> {
return new Map();
}
function agentMap(): Map<string, Agent> {
return new Map();
}
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
return {
workspaces: input?.workspaces ?? workspaceMap(),
agents: input?.agents ?? agentMap(),
};
}
describe("status mode session selection", () => {
it("selects only sessions needed by visible placements", () => {
const hostA = statusSession();
const hostB = statusSession();
const unusedHost = statusSession();
expect(
selectStatusModeSessions(
{
"host-a": hostA,
"host-b": hostB,
unused: unusedHost,
},
["host-b", "missing", "host-a"],
),
).toEqual([
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
]);
});
it("keeps selector output equal when only wrapper objects change", () => {
const workspaces = workspaceMap();
const agents = agentMap();
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
expect(previous).not.toBe(next);
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
});
it("detects workspace or agent map changes for selected hosts", () => {
const agents = agentMap();
const previous = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
["host-a"],
);
const next = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
["host-a"],
);
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
});
});
@@ -1,63 +1,100 @@
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
buildSidebarStatusWorkspacePlacements,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspacePlacement,
} from "./use-sidebar-workspaces-list";
const EMPTY_WORKSPACES: SidebarWorkspaceEntry[] = [];
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
typeof useCreateFlowStore.getState
>["pendingByDraftId"] = {};
export function useStatusModeWorkspaceEntries(input: {
serverId: string | null;
projects: SidebarProjectEntry[];
}): SidebarWorkspaceEntry[] {
const workspaces = useSessionStore((state) =>
input.serverId ? state.sessions[input.serverId]?.workspaces : undefined,
interface StatusModeSessionSource {
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
}
export interface StatusModeSession {
serverId: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
}
export function selectStatusModeSessions(
sessions: Record<string, StatusModeSessionSource | undefined>,
serverIds: readonly string[],
): StatusModeSession[] {
const statusSessions: StatusModeSession[] = [];
for (const serverId of serverIds) {
const session = sessions[serverId];
if (!session) {
continue;
}
statusSessions.push({
serverId,
workspaces: session.workspaces,
agents: session.agents,
});
}
return statusSessions;
}
export function areStatusModeSessionsEqual(
left: readonly StatusModeSession[],
right: readonly StatusModeSession[],
): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
const leftSession = left[index];
const rightSession = right[index];
if (
!leftSession ||
!rightSession ||
leftSession.serverId !== rightSession.serverId ||
leftSession.workspaces !== rightSession.workspaces ||
leftSession.agents !== rightSession.agents
) {
return false;
}
}
return true;
}
export function useStatusModeWorkspacePlacements(input: {
placements: SidebarWorkspacePlacement[];
enabled?: boolean;
}): SidebarStatusWorkspacePlacement[] {
const isEnabled = input.enabled !== false && input.placements.length > 0;
const serverIds = useMemo(
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
[input.placements],
);
const agents = useSessionStore((state) =>
input.serverId ? state.sessions[input.serverId]?.agents : undefined,
const statusSessions = useStoreWithEqualityFn(
useSessionStore,
(state) =>
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
areStatusModeSessionsEqual,
);
const pendingCreateAttempts = useCreateFlowStore((state) =>
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
return useMemo(() => {
if (!input.serverId || input.projects.length === 0 || !workspaces) {
if (!isEnabled) {
return EMPTY_WORKSPACES;
}
const entries: SidebarWorkspaceEntry[] = [];
for (const placedWorkspace of input.projects.flatMap((project) => project.workspaces)) {
const workspace = workspaces.get(placedWorkspace.workspaceId);
entries.push(
workspace
? createSidebarWorkspaceEntry({
serverId: input.serverId,
workspace,
pendingCreateAttempts,
agents,
})
: placedWorkspace,
);
}
return entries;
}, [agents, input.projects, input.serverId, pendingCreateAttempts, workspaces]);
}
export function useProjectNamesMap(serverId: string | null): Map<string, string> {
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
return useMemo(() => {
const map = new Map<string, string>();
if (!serverId || !workspaces) return map;
for (const workspace of workspaces.values()) {
const key = workspace.project?.projectKey ?? workspace.projectId;
if (!map.has(key)) {
map.set(key, workspace.projectCustomName ?? workspace.projectDisplayName);
}
}
return map;
}, [serverId, workspaces]);
return buildSidebarStatusWorkspacePlacements({
placements: input.placements,
sessions: statusSessions,
pendingCreateAttempts,
});
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
}
+354
View File
@@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { isNative } from "@/constants/platform";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
export interface ChooseHostInput {
title?: string;
filter?: HostFilter;
onChooseHost: HostChoiceHandler;
onNoHosts?: () => void;
}
interface HostChoiceRequest {
id: number;
title: string;
serverIds: string[];
onChooseHost: HostChoiceHandler;
}
interface HostChooserState {
request: HostChoiceRequest | null;
open: (request: Omit<HostChoiceRequest, "id">) => void;
close: () => void;
}
let nextRequestId = 1;
const useHostChooserStore = create<HostChooserState>((set) => ({
request: null,
open: (request) => {
set({ request: { ...request, id: nextRequestId++ } });
},
close: () => set({ request: null }),
}));
function matchesHostQuery(host: HostProfile, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return (
host.label.toLowerCase().includes(normalized) ||
host.serverId.toLowerCase().includes(normalized)
);
}
export function useHostChooser() {
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
return useCallback(
(input: ChooseHostInput) => {
const availableHosts = orderHostsLocalFirst(hosts, localServerId).filter(
input.filter ?? (() => true),
);
if (availableHosts.length === 0) {
(input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))();
return false;
}
if (availableHosts.length === 1) {
void input.onChooseHost(availableHosts[0].serverId);
return true;
}
open({
title: input.title ?? "Choose host",
serverIds: availableHosts.map((host) => host.serverId),
onChooseHost: input.onChooseHost,
});
return true;
},
[hosts, localServerId, open],
);
}
function HostChooserRow({
host,
active,
onChooseHost,
}: {
host: HostProfile;
active: boolean;
onChooseHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => onChooseHost(host.serverId), [host.serverId, onChooseHost]);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(active || hovered || pressed) && styles.rowActive,
],
[active],
);
return (
<Pressable
accessibilityRole="button"
onPress={handlePress}
style={rowStyle}
testID={`host-chooser-row-${host.serverId}`}
>
<View style={styles.rowIconSlot}>
<HostStatusDotSlot serverId={host.serverId} />
</View>
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{host.label}
</Text>
<Text style={styles.rowSubtitle} numberOfLines={1}>
{host.serverId}
</Text>
</View>
<Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
export function HostChooserModal() {
const { theme } = useUnistyles();
const hosts = useHosts();
const request = useHostChooserStore((state) => state.request);
const close = useHostChooserStore((state) => state.close);
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const requestHosts = useMemo(() => {
if (!request) return [];
const hostByServerId = new Map(hosts.map((host) => [host.serverId, host] as const));
return request.serverIds.flatMap((serverId) => {
const host = hostByServerId.get(serverId);
return host ? [host] : [];
});
}, [hosts, request]);
const options = useMemo(
() => requestHosts.filter((host) => matchesHostQuery(host, query)),
[query, requestHosts],
);
const activeOptionIndex = options.length === 0 ? 0 : Math.min(activeIndex, options.length - 1);
useEffect(() => {
if (!request) return;
setQuery("");
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [request]);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(0);
}, []);
const chooseHost = useCallback(
(serverId: string) => {
const currentRequest = request;
close();
if (!currentRequest) return;
void currentRequest.onChooseHost(serverId);
},
[close, request],
);
useEffect(() => {
if (!request || isNative || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key === "Enter") {
const host = options[activeOptionIndex];
if (!host) return;
event.preventDefault();
chooseHost(host.serverId);
return;
}
if (options.length === 0) return;
event.preventDefault();
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
if (next < 0) {
setActiveIndex(options.length - 1);
return;
}
if (next >= options.length) {
setActiveIndex(0);
return;
}
setActiveIndex(next);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [activeOptionIndex, chooseHost, close, options, request]);
if (!request) return null;
return (
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={close} />
<View style={styles.panel}>
<View style={styles.header}>
<Text style={styles.title}>{request.title}</Text>
<TextInput
ref={inputRef}
value={query}
onChangeText={handleQueryChange}
placeholder="Search hosts..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
testID="host-chooser-search"
/>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
{options.map((host, index) => (
<HostChooserRow
key={host.serverId}
host={host}
active={index === activeOptionIndex}
onChooseHost={chooseHost}
/>
))}
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
width: 640,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
input: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
padding: 0,
outlineStyle: "none",
} as object,
results: {
maxHeight: 420,
},
resultsContent: {
paddingVertical: theme.spacing[2],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
minHeight: 56,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
rowActive: {
backgroundColor: theme.colors.surface1,
},
rowIconSlot: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
rowText: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
}));
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveHostIndexRoute,
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRoute,
@@ -339,14 +338,14 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "render" });
});
it("sends removed host routes to a saved host instead of welcome", () => {
it("sends removed host routes to global project selection instead of welcome", () => {
expect(
resolveStartupRoute({
...baseHostInput,
route: { kind: "host", serverId: "server-removed" },
hosts: [{ serverId: "server-next" }],
}),
).toEqual({ kind: "redirect", href: "/h/server-next/open-project" });
).toEqual({ kind: "redirect", href: "/open-project" });
});
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
@@ -358,45 +357,3 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "redirect", href: "/welcome" });
});
});
describe("resolveHostIndexRoute", () => {
it("restores the remembered workspace when the host index is reopened for the same host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "unknown",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("opens project selection when the remembered workspace is proven missing", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual("/h/server-saved/open-project");
});
it("opens project selection when the remembered workspace belongs to another host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/open-project");
});
});
@@ -2,9 +2,9 @@ import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-worksp
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type { Href } from "expo-router";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
buildOpenProjectRoute,
} from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
@@ -174,20 +174,6 @@ export function resolveWorkspaceSelectionStatus(input: {
return input.hasHydratedWorkspaces ? "missing" : "unknown";
}
export function resolveHostIndexRoute(input: {
serverId: string;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
}): Href {
if (
input.workspaceSelection?.serverId === input.serverId &&
shouldRestoreWorkspaceSelection(input)
) {
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
}
return buildHostOpenProjectRoute(input.serverId);
}
function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === "";
}
@@ -244,7 +230,7 @@ function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): Star
const fallbackServerId = input.hosts[0]?.serverId ?? null;
if (fallbackServerId) {
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
return { kind: "redirect", href: buildOpenProjectRoute() };
}
return { kind: "redirect", href: WELCOME_ROUTE };
+90 -14
View File
@@ -1,14 +1,16 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
export interface HostProjectListItem {
serverId: string;
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
canCreateWorktree: boolean;
}
export interface HostProjectRouteContext {
@@ -30,17 +32,15 @@ export function canCreateWorktreeForProjectKind(
}
export function buildHostProjectList(input: {
serverId: string;
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
}));
}
@@ -51,13 +51,18 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
return null;
}
return {
serverId: route.serverId,
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKind: "git",
iconWorkingDir,
hosts: [
{
serverId: route.serverId,
iconWorkingDir,
canCreateWorktree: true,
},
],
workspaceKeys: [],
canCreateWorktree: true,
};
}
@@ -73,29 +78,100 @@ export function hostProjectFromWorkspace(input: {
if (!projectKey || !iconWorkingDir) {
return null;
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
serverId: input.serverId,
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKind: input.workspace.projectKind,
iconWorkingDir,
workspaceKeys: [input.workspace.id],
canCreateWorktree: canCreateWorktreeForProjectKind(input.workspace.projectKind),
hosts: [
{
serverId: input.serverId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
};
}
function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree);
}
export function getHostProjectSourceDirectory(
project: HostProjectListItem,
serverId: string,
): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
}
export function canCreateWorkspaceForHostProject(input: {
project: HostProjectListItem;
serverId: string;
allowAllProjects: boolean;
}): boolean {
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
if (!host) {
return false;
}
return input.allowAllProjects || host.canCreateWorktree;
}
export function filterWorkspaceProjectsForHost(input: {
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem[] {
return input.projects.filter((project) =>
canCreateWorkspaceForHostProject({
project,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
}),
);
}
export function resolveInitialWorkspaceProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem | null {
const candidates = [input.routeProject, input.lastActiveProject];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
})
) {
return hydratedProject;
}
}
return input.projects[0] ?? null;
}
export function resolveInitialWorktreeProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
}): HostProjectListItem | null {
if (input.routeProject?.canCreateWorktree) {
if (input.routeProject && projectCanCreateWorktree(input.routeProject)) {
return input.routeProject;
}
if (input.lastActiveProject?.canCreateWorktree) {
if (input.lastActiveProject && projectCanCreateWorktree(input.lastActiveProject)) {
return input.lastActiveProject;
}
return input.projects.find((project) => project.canCreateWorktree) ?? null;
return input.projects.find((project) => projectCanCreateWorktree(project)) ?? null;
}
export function resolveSelectedHostProject(input: {
+124 -17
View File
@@ -3,9 +3,13 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
@@ -17,19 +21,31 @@ function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceS
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: input.projectKind !== "directory",
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
serverId: input.serverId ?? "host-a",
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
canCreateWorktree: input.canCreateWorktree ?? true,
};
}
@@ -71,7 +87,6 @@ describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
serverId: "host-a",
projects: [
structureProject({
projectKey: "project-b",
@@ -79,6 +94,7 @@ describe("host project list", () => {
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
}),
structureProject({
projectKey: "project-a",
@@ -86,27 +102,26 @@ describe("host project list", () => {
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
}),
],
}),
).toEqual([
{
serverId: "host-a",
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
workspaceKeys: ["workspace-b"],
canCreateWorktree: false,
},
{
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
},
]);
});
@@ -129,14 +144,24 @@ describe("host project list", () => {
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: { ...routeProject, projectKind: "directory", canCreateWorktree: false },
routeProject: {
...routeProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
},
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
canCreateWorktree: false,
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
},
projects: [
{ ...firstProject, projectKind: "directory", canCreateWorktree: false },
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
@@ -148,11 +173,92 @@ describe("host project list", () => {
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [{ ...firstProject, projectKind: "directory", canCreateWorktree: false }],
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
],
}),
).toBeNull();
});
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
});
expect(
filterWorkspaceProjectsForHost({
projects: [hostAOnly, hostBOnly],
serverId: "host-b",
allowAllProjects: false,
}).map((project) => project.projectKey),
).toEqual(["host-b-project"]);
});
it("allows directory projects only when workspace multiplicity is supported", () => {
const directoryProject = hostProject({
projectKey: "directory-project",
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
});
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: false,
}),
).toBe(false);
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: true,
}),
).toBe(true);
});
it("falls back when the route project is not available on the selected host", () => {
const selectedHostProject = hostProject({
projectKey: "selected-host-project",
hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
],
});
expect(
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject: null,
projects: [selectedHostProject],
serverId: "host-b",
allowAllProjects: false,
}),
).toEqual(selectedHostProject);
});
it("resolves the selected host project source directory", () => {
const project = hostProject({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
});
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
@@ -173,26 +279,24 @@ describe("host project list", () => {
sourceDirectory: "/repo/a",
}),
).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: [],
canCreateWorktree: true,
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["host-a:workspace-a"],
});
expect(
@@ -200,6 +304,9 @@ describe("host project list", () => {
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({ projectKind: "directory", canCreateWorktree: false });
).toMatchObject({
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
});
});
});
+9 -5
View File
@@ -4,21 +4,25 @@ import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-
export {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
type HostProjectRouteContext,
} from "@/projects/host-project-model";
export function useHostProjects(serverId: string | null): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverId);
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverIds);
return useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
if (workspaceStructure.projects.length === 0) {
return [];
}
return buildHostProjectList({ serverId, projects: workspaceStructure.projects });
}, [serverId, workspaceStructure.projects]);
return buildHostProjectList({ projects: workspaceStructure.projects });
}, [workspaceStructure.projects]);
}
+9 -12
View File
@@ -4,7 +4,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
export interface ProjectIconRequestTarget {
serverId?: string | null;
serverId: string;
projectKey: string;
iconWorkingDir: string;
}
@@ -21,24 +21,19 @@ function useStableProjectIconData(
}
export function useProjectIconDataByProjectKey(input: {
serverId: string | null;
projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> {
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
if (!serverId) {
continue;
}
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
unique.set(`${project.serverId}:${cwd}`, { serverId: project.serverId, cwd });
}
return Array.from(unique.values());
}, [input.projects, input.serverId]);
}, [input.projects]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -83,15 +78,17 @@ export function useProjectIconDataByProjectKey(input: {
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
const cwd = project.iconWorkingDir.trim();
if (!cwd || !serverId) {
if (!cwd) {
byProject.set(project.projectKey, null);
continue;
}
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
byProject.set(
project.projectKey,
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
);
}
return byProject;
}, [input.projects, input.serverId, projectIconData, projectIconRequests]);
}, [input.projects, projectIconData, projectIconRequests]);
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
getProjectRemoveReadiness,
removeProjectFromHosts,
type ProjectRemoveProject,
} from "./project-remove";
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
};
function createProjectRemoveClient() {
const removedProjectKeys: string[] = [];
return {
removedProjectKeys,
client: {
async removeProject(projectKey: string): Promise<{ removedWorkspaceIds: string[] }> {
removedProjectKeys.push(projectKey);
return { removedWorkspaceIds: [] };
},
},
};
}
describe("project remove policy", () => {
it("requires every host to support project removal", () => {
const readiness = getProjectRemoveReadiness({
project,
supportsProjectRemove: (serverId) => serverId === "host-a",
});
expect(readiness).toEqual({
kind: "needs_host_update",
serverIds: ["host-b"],
});
});
it("removes the project from every participating host", async () => {
const hostA = createProjectRemoveClient();
const hostB = createProjectRemoveClient();
const readiness = getProjectRemoveReadiness({
project,
supportsProjectRemove: () => true,
});
expect(readiness).toEqual({
kind: "ready",
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
});
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
getClient: (serverId) => {
if (serverId === "host-a") return hostA.client;
if (serverId === "host-b") return hostB.client;
return null;
},
});
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
});
it("reports disconnected hosts before sending any remove request", async () => {
const hostA = createProjectRemoveClient();
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
});
expect(outcome).toEqual({ kind: "host_disconnected", serverIds: ["host-b"] });
expect(hostA.removedProjectKeys).toEqual([]);
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { selectHostFeature } from "@/runtime/host-features";
interface ProjectRemoveHost {
serverId: string;
}
export interface ProjectRemoveProject {
projectKey: string;
hosts: readonly ProjectRemoveHost[];
}
export interface ProjectRemoveTarget {
serverId: string;
}
export type ProjectRemoveReadiness =
| { kind: "ready"; targets: ProjectRemoveTarget[] }
| { kind: "needs_host_update"; serverIds: string[] };
export type ProjectRemoveOutcome =
| { kind: "removed"; serverIds: string[] }
| { kind: "host_disconnected"; serverIds: string[] }
| { kind: "failed"; serverIds: string[] };
type ProjectRemoveClient = Pick<DaemonClient, "removeProject">;
export function getProjectRemoveReadiness(input: {
project: ProjectRemoveProject;
supportsProjectRemove: (serverId: string) => boolean;
}): ProjectRemoveReadiness {
const unsupportedServerIds: string[] = [];
const targets: ProjectRemoveTarget[] = [];
for (const host of input.project.hosts) {
if (!input.supportsProjectRemove(host.serverId)) {
unsupportedServerIds.push(host.serverId);
continue;
}
targets.push({ serverId: host.serverId });
}
if (unsupportedServerIds.length > 0) {
return { kind: "needs_host_update", serverIds: unsupportedServerIds };
}
return { kind: "ready", targets };
}
export function getCurrentProjectRemoveReadiness(
project: ProjectRemoveProject,
): ProjectRemoveReadiness {
const sessionState = useSessionStore.getState();
return getProjectRemoveReadiness({
project,
supportsProjectRemove: (serverId) => selectHostFeature(sessionState, serverId, "projectRemove"),
});
}
export async function removeProjectFromHosts(input: {
projectKey: string;
targets: readonly ProjectRemoveTarget[];
getClient: (serverId: string) => ProjectRemoveClient | null;
}): Promise<ProjectRemoveOutcome> {
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
const disconnectedServerIds: string[] = [];
for (const target of input.targets) {
const client = input.getClient(target.serverId);
if (!client) {
disconnectedServerIds.push(target.serverId);
continue;
}
clients.push({ serverId: target.serverId, client });
}
if (disconnectedServerIds.length > 0) {
return { kind: "host_disconnected", serverIds: disconnectedServerIds };
}
const results = await Promise.allSettled(
clients.map(async ({ client }) => {
await client.removeProject(input.projectKey);
}),
);
const failedServerIds: string[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
const failed = clients[index];
if (failed) {
failedServerIds.push(failed.serverId);
}
}
}
if (failedServerIds.length > 0) {
return { kind: "failed", serverIds: failedServerIds };
}
return { kind: "removed", serverIds: clients.map((entry) => entry.serverId) };
}
+104 -49
View File
@@ -1,11 +1,18 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export interface WorkspaceStructureHostPlacement {
serverId: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
@@ -13,8 +20,6 @@ export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
@@ -42,68 +47,118 @@ function compareWorkspaceStructureProjects(
});
}
export function buildWorkspaceStructureProjects(input: {
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
return projectKind === "git";
}
interface WorkspaceStructureSession {
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}): WorkspaceStructureProject[] {
const workspaceList = Array.from(input.workspaces);
const emptyProjectList = Array.from(input.emptyProjects ?? []);
if (workspaceList.length === 0 && emptyProjectList.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
}
export function buildWorkspaceStructureProjects(input: {
sessions: WorkspaceStructureSession[];
}): WorkspaceStructureProject[] {
const byProject = new Map<
string,
WorkspaceStructureProject & {
{
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const emptyProject of emptyProjectList) {
byProject.set(emptyProject.projectId, {
projectKey: emptyProject.projectId,
projectName:
emptyProject.projectDisplayName || projectDisplayNameFromProjectId(emptyProject.projectId),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
workspaceKeys: [],
workspaces: [],
});
}
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
const placement = {
serverId: session.serverId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
const existing = byProject.get(projectKey);
for (const workspace of workspaceList) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
emptyProject.projectCustomName ??
emptyProject.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
hosts: new Map([[session.serverId, placement]]),
workspaces: [],
});
continue;
}
existing.hosts.set(session.serverId, placement);
}
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
hosts: new Map([
[
session.serverId,
{
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
],
]),
workspaces: [
{
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
},
],
});
continue;
}
existing.hosts.set(session.serverId, {
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${input.serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
existing.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
});
}
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return Object.assign({}, project, {
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
});
},
);
const projects: WorkspaceStructureProject[] = [];
for (const raw of byProject.values()) {
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
projects.push({
projectKey: raw.projectKey,
projectName: raw.projectName,
projectKind: raw.projectKind,
iconWorkingDir: raw.iconWorkingDir,
hosts: Array.from(raw.hosts.values()),
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
});
}
projects.sort(compareWorkspaceStructureProjects);
return projects;
+28
View File
@@ -0,0 +1,28 @@
import type { DaemonServerInfo } from "@/stores/session-store";
export type HostFeatureName = keyof NonNullable<DaemonServerInfo["features"]>;
export interface HostFeatureSessionState {
sessions: Record<
string,
| {
serverInfo: DaemonServerInfo | null;
}
| undefined
>;
}
export function hostSupportsFeature(
serverInfo: DaemonServerInfo | null | undefined,
feature: HostFeatureName,
): boolean {
return serverInfo?.features?.[feature] === true;
}
export function selectHostFeature(
state: HostFeatureSessionState,
serverId: string,
feature: HostFeatureName,
): boolean {
return hostSupportsFeature(state.sessions[serverId]?.serverInfo, feature);
}
@@ -1201,6 +1201,48 @@ describe("HostRuntimeController", () => {
});
describe("HostRuntimeStore", () => {
it("marks the host registry loaded after boot reads storage", async () => {
const previousOverride = process.env.EXPO_PUBLIC_LOCAL_DAEMON;
process.env.EXPO_PUBLIC_LOCAL_DAEMON = "not-an-endpoint";
const store = new HostRuntimeStore({
deps: {
createClient: () => {
throw new Error("createClient should not be called");
},
connectToDaemon: async () => {
throw new Error("connectToDaemon should not be called");
},
getClientId: async () => "cid_test_runtime",
},
});
try {
let hostListNotifications = 0;
let unsubscribeHostList = () => {};
const registryLoaded = new Promise<void>((resolve) => {
unsubscribeHostList = store.subscribeHostList(() => {
hostListNotifications += 1;
if (store.isHostRegistryLoaded()) {
unsubscribeHostList();
resolve();
}
});
});
store.boot();
await registryLoaded;
expect(store.isHostRegistryLoaded()).toBe(true);
expect(hostListNotifications).toBe(2);
} finally {
if (previousOverride === undefined) {
delete process.env.EXPO_PUBLIC_LOCAL_DAEMON;
} else {
process.env.EXPO_PUBLIC_LOCAL_DAEMON = previousOverride;
}
}
});
it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({
connections: [
+23
View File
@@ -1345,6 +1345,7 @@ export class HostRuntimeStore {
private hostListListeners = new Set<() => void>();
private version = 0;
private hostListVersion = 0;
private hostRegistryLoaded = false;
private hosts: HostProfile[] = [];
private hostRegistryStatus: HostRegistryStatus = "loading";
private deps: HostRuntimeControllerDeps;
@@ -1380,6 +1381,10 @@ export class HostRuntimeStore {
return this.hostListVersion;
}
isHostRegistryLoaded(): boolean {
return this.hostRegistryLoaded;
}
boot(): void {
if (this.bootStarted) {
return;
@@ -1391,6 +1396,7 @@ export class HostRuntimeStore {
private async runBoot(): Promise<void> {
const override = readConfiguredLocalDaemonOverride();
await this.loadFromStorage();
this.markHostRegistryLoaded();
let isE2E: string | null = null;
try {
@@ -1454,6 +1460,14 @@ export class HostRuntimeStore {
}
}
private markHostRegistryLoaded(): void {
if (this.hostRegistryLoaded) {
return;
}
this.hostRegistryLoaded = true;
this.emitHostList();
}
private async bootstrapDefaultLocalhost(): Promise<void> {
const connection = connectionFromListen(LOCALHOST_FALLBACK_ENDPOINT);
if (!connection || registryHasConnection(this.hosts, connection)) {
@@ -2235,6 +2249,15 @@ export function useHostRegistryStatus(): HostRegistryStatus {
);
}
export function useHostRegistryLoaded(): boolean {
const store = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribeHostList(onStoreChange),
() => store.isHostRegistryLoaded(),
() => store.isHostRegistryLoaded(),
);
}
export interface HostMutations {
upsertDirectConnection: (input: {
serverId: string;
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { CommunityLinks } from "@/components/community-links";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useHostChooser } from "@/hosts/host-chooser";
import { usePanelStore } from "@/stores/panel-store";
import {
useIsCompactFormFactor,
@@ -16,7 +17,7 @@ import {
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
@@ -24,14 +25,16 @@ import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import type { Href } from "expo-router";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
export function OpenProjectScreen() {
const { t } = useTranslation();
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const client = useHostRuntimeClient(serverId);
const openProject = useOpenProject(serverId);
const openProjectPicker = useOpenProjectPicker();
const chooseHost = useHostChooser();
const localServerId = useLocalDaemonServerId();
const [importServerId, setImportServerId] = useState<string | null>(null);
const importClient = useHostRuntimeClient(importServerId ?? "");
const openImportedProject = useOpenProject(importServerId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
@@ -50,22 +53,38 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []);
const handleOpenImportSession = useCallback(() => {
chooseHost({
title: "Import from host",
onChooseHost: (serverId) => {
setImportServerId(serverId);
setIsImportSheetOpen(true);
},
});
}, [chooseHost]);
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
const handleImported = useCallback(
(agent: { id: string; cwd: string }) => {
if (!importServerId) return;
void (async () => {
await openProject(agent.cwd);
router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href);
const result = await openImportedProject(agent.cwd);
if (result.ok) {
router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href);
}
})();
},
[openProject, router, serverId],
[importServerId, openImportedProject, router],
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
}, [router, serverId]);
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
},
});
}, [chooseHost, router]);
return (
<View style={styles.container}>
@@ -98,7 +117,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{isLocalDaemon ? (
{localServerId ? (
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
@@ -119,8 +138,8 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
/>
<ImportSessionSheet
visible={isImportSheetOpen}
client={client}
serverId={serverId}
client={importClient}
serverId={importServerId}
onClose={handleCloseImportSession}
onImported={handleImported}
/>
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
@@ -216,7 +217,6 @@ function ProjectSettingsBody({
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId],
);
const projectIconDataByKey = useProjectIconDataByProjectKey({
serverId: null,
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
@@ -942,7 +942,7 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
}
return (
<View testID="host-indicator" style={styles.hostIndicator}>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
@@ -950,18 +950,6 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
);
}
function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
let color: string;
if (status === "online") color = theme.colors.palette.green[400];
else if (status === "connecting") color = theme.colors.palette.amber[500];
else color = theme.colors.palette.red[500];
const dotStyle = useMemo(() => [styles.hostStatusDot, { backgroundColor: color }], [color]);
return <View style={dotStyle} />;
}
interface HostPickerProps {
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry;
@@ -970,52 +958,42 @@ interface HostPickerProps {
function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const hostOptions = useMemo(
() => hosts.map((host) => ({ serverId: host.serverId, label: host.serverName })),
[hosts],
);
const handleOpen = useCallback(() => setOpen(true), []);
const hostOptionTestID = useCallback((serverId: string) => `host-picker-item-${serverId}`, []);
return (
<DropdownMenu>
<DropdownMenuTrigger
<SharedHostPicker
hosts={hostOptions}
value={selectedHost.serverId}
onSelect={onSelectHost}
open={open}
onOpenChange={setOpen}
anchorRef={triggerRef}
searchable={false}
title={t("settings.project.switchHost")}
desktopPlacement="bottom-start"
desktopMinWidth={240}
hostOptionTestID={hostOptionTestID}
>
<Pressable
ref={triggerRef}
accessibilityLabel={t("settings.project.switchHost")}
testID="host-picker"
style={styles.hostIndicator}
onPress={handleOpen}
>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
<ChevronDown size={ICON_SIZE} color={styles.chevronColor.color} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" minWidth={240}>
{hosts.map((host) => (
<HostPickerItem
key={host.serverId}
host={host}
isSelected={host.serverId === selectedHost.serverId}
onSelectHost={onSelectHost}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface HostPickerItemProps {
host: ProjectHostEntry;
isSelected: boolean;
onSelectHost: (serverId: string) => void;
}
function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps) {
const handleSelect = useCallback(
() => onSelectHost(host.serverId),
[host.serverId, onSelectHost],
);
return (
<DropdownMenuItem
testID={`host-picker-item-${host.serverId}`}
selected={isSelected}
onSelect={handleSelect}
>
{host.serverName}
</DropdownMenuItem>
</Pressable>
</SharedHostPicker>
);
}
@@ -1353,11 +1331,6 @@ const styles = StyleSheet.create((theme) => ({
alignSelf: "flex-start",
minWidth: 0,
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostName: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -36,7 +36,6 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
[projects],
);
const iconDataByProjectKey = useProjectIconDataByProjectKey({
serverId: null,
projects: iconTargets,
});
+160 -27
View File
@@ -1,64 +1,146 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
} from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
import { useHosts } from "@/runtime/host-runtime";
import { type HostProfile } from "@/types/host-connection";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
export function SessionsScreen() {
const isFocused = useIsFocused();
if (!isFocused) {
return <View style={styles.container} />;
}
return <SessionsScreenContent serverId={serverId} />;
return <SessionsScreenContent />;
}
function SessionsScreenContent({ serverId }: { serverId: string }) {
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } =
const hosts = useHosts();
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const historyServerId = selectedHost === ALL_HOSTS_OPTION_ID ? null : selectedHost;
const { agents, hasMore, isInitialLoad, isLoadingMore, isError, loadMore, refreshAll } =
useAgentHistory({
serverId,
serverId: historyServerId,
});
// Track user-initiated refresh to avoid showing spinner on background revalidation
useEffect(() => {
if (
selectedHost !== ALL_HOSTS_OPTION_ID &&
!hosts.some((host) => host.serverId === selectedHost)
) {
setSelectedHost(ALL_HOSTS_OPTION_ID);
}
}, [hosts, selectedHost]);
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
refreshAll();
void refreshAll().finally(() => setIsManualRefresh(false));
}, [refreshAll]);
// Reset manual refresh flag when revalidation completes
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh]);
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [agents]);
const emptyText =
selectedHost === ALL_HOSTS_OPTION_ID ? t("sessions.empty") : "No sessions for this host";
const showHostFilter = hosts.length > 1;
const showLoadError = isError && sortedAgents.length === 0;
const handleBack = useCallback(() => {
router.navigate(buildHostOpenProjectRoute(serverId));
}, [serverId]);
router.navigate(buildOpenProjectRoute());
}, []);
const listFooterComponent = useMemo(
() =>
hasMore ? (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? t("common.loading") : t("sessions.actions.loadMore")}
{isLoadingMore ? "Loading..." : t("sessions.actions.loadMore")}
</Button>
</View>
) : null,
@@ -68,27 +150,45 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<SessionsHostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
/>
</View>
) : null}
{isInitialLoad ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
{!isInitialLoad && sortedAgents.length === 0 ? (
{!isInitialLoad && showLoadError ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t("sessions.empty")}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
{t("common.actions.back")}
<Text style={styles.emptyText}>Unable to load sessions</Text>
<Button variant="ghost" onPress={handleRefresh}>
Try again
</Button>
</View>
) : null}
{!isInitialLoad && sortedAgents.length > 0 ? (
{!isInitialLoad && !showLoadError && sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{emptyText}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
</View>
) : null}
{!isInitialLoad && !showLoadError && sortedAgents.length > 0 ? (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
isRefreshing={isManualRefresh}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
showAttentionIndicator={false}
showHostColumn
/>
) : null}
</View>
@@ -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",
+46 -211
View File
@@ -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(
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[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 ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
{t("settings.hostPicker.local")}
</Text>
) : undefined,
[isLocal, t],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={`settings-host-picker-item-${serverId}`}
/>
);
}
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const leadingSlot = useMemo(
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label={t("settings.addHost")}
leadingSlot={leadingSlot}
active={active}
onPress={onPress}
testID="settings-add-host"
/>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
localServerId: string | null;
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
}
@@ -1017,66 +897,19 @@ interface HostPickerProps {
* <Combobox>. 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<View | null>(null);
const activeHost =
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
const options = useMemo<ComboboxOption[]>(() => {
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 <AddHostOption active={active} onPress={onPress} />;
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={localServerId !== null && option.id === localServerId}
selected={selected}
active={active}
onPress={onPress}
/>
);
},
[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 (
<>
<SharedHostPicker
hosts={sortedHosts}
value={activeServerId ?? ""}
onSelect={onSelectHost}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
searchable={false}
title={t("settings.hostPicker.switchHost")}
desktopMinWidth={240}
addHostTestID="settings-add-host"
hostLocalMarkerTestID={hostLocalMarkerTestID}
hostOptionTestID={hostOptionTestID}
>
<ComboboxTrigger
ref={triggerRef}
style={triggerStyle}
@@ -1095,24 +944,12 @@ function HostPicker({
accessibilityLabel={t("settings.hostPicker.switchHost")}
testID="settings-host-picker"
>
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{activeHost ? <HostStatusDotSlot serverId={activeHost.serverId} /> : null}
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
{activeHost?.label ?? t("settings.groups.host")}
</Text>
</ComboboxTrigger>
<Combobox
options={options}
value={activeServerId ?? ""}
onSelect={handleSelect}
renderOption={renderOption}
searchable={false}
title={t("settings.hostPicker.switchHost")}
desktopMinWidth={240}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
/>
</>
</SharedHostPicker>
);
}
@@ -1189,7 +1026,6 @@ function SettingsSidebar({
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
localServerId={localServerId}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
/>
@@ -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<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(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<string | null>(
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",
@@ -4,7 +4,6 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -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<KeyboardShortcutsState>((set, get) => ({
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -58,7 +55,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((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) => {
@@ -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();
},
};
}
@@ -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<ProjectPickerStoreState>((set) => ({
request: null,
open: (serverId) => set({ request: { serverId } }),
close: () => set({ request: null }),
}));
@@ -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],
);
}
@@ -10,7 +10,7 @@ import {
selectWorkspaceDirectory,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceOrderByScopeForServer,
selectWorkspaceOrderByScope,
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
@@ -86,15 +86,15 @@ function trackSelector<S, T>(
function emptySidebarOrder(): SidebarOrderSnapshot {
return {
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
projectOrder: [],
workspaceOrderByProject: {},
};
}
function selectWorkspaceStructureProjectKeys(
state: Parameters<typeof selectWorkspaceStructureProjects>[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<typeof composeWorkspaceStructure> {
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"]);
@@ -23,8 +23,8 @@ export interface SessionsSnapshot {
}
export interface SidebarOrderSnapshot {
projectOrderByServerId: Record<string, string[]>;
workspaceOrderByServerAndProject: Record<string, string[]>;
projectOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
}
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<T>(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<WorkspaceDescriptor>;
emptyProjects: Iterable<EmptyProjectDescriptor>;
}> = [];
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<string, string[]> {
if (!serverId) {
return {};
}
const prefix = `${serverId.trim()}::`;
const relevantOrderByScope: Record<string, string[]> = {};
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<string, string[]> {
return state.workspaceOrderByProject ?? {};
}
export function composeWorkspaceStructure(input: {
serverId: string | null;
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByScope: Record<string, readonly string[]>;
}): 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,
@@ -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"],
},
});
});
});
+104 -50
View File
@@ -3,12 +3,24 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface SidebarOrderStoreState {
projectOrderByServerId: Record<string, string[]>;
workspaceOrderByServerAndProject: Record<string, string[]>;
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<string, string[]>;
getProjectOrder: () => string[];
setProjectOrder: (keys: string[]) => void;
getWorkspaceOrder: (projectKey: string) => string[];
setWorkspaceOrder: (projectKey: string, keys: string[]) => void;
}
interface SidebarOrderPersistedState {
projectOrder?: string[];
workspaceOrderByProject?: Record<string, string[]>;
projectOrderByServerId?: Record<string, string[]>;
workspaceOrderByServerAndProject?: Record<string, string[]>;
}
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<string, string[]> | undefined,
): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
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<string, string[]>;
} {
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<SidebarOrderStoreState>()(
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<SidebarOrderStoreState>()(
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,
},
),
);
@@ -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<Promise<void>> {
reads: string[];
}
function createMemoryStorage(entries: Record<string, string | null>): 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"]);
});
});
+84 -24
View File
@@ -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<string, SidebarGroupMode>;
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readLegacyGroupMode(persistedState: Record<string, unknown>): 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<SidebarViewStoreState>()(
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,
},
),
);
+43 -1
View File
@@ -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", () => {
+19
View File
@@ -56,6 +56,25 @@ export function normalizeHostLabel(value: string | null | undefined, serverId: s
return trimmed.length > 0 ? trimmed : serverId;
}
export function orderHostsLocalFirst<T extends { serverId: string }>(
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;
@@ -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();
});
});
-20
View File
@@ -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;
}
@@ -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);
}
@@ -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" });
});
});
+74 -32
View File
@@ -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<typeof buildOpenProjectRoute> | "/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;
}
+1 -3
View File
@@ -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 }],
}),
});
}
@@ -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> = {}): SidebarWorkspaceEntry {
return {
@@ -11,6 +14,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): 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<SidebarProjectEntry> = {}): 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" },
},
});
});
});
@@ -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),
};
}
@@ -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],

Some files were not shown because too many files have changed in this diff Show More