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:
@@ -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,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);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user