Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 98c0033f22 test(daemon): make self-update paths platform-stable 2026-06-26 13:25:17 +07:00
Mohamed Boudra 444214cbe2 feat(daemon): harden remote self-update
Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.
2026-06-26 13:02:35 +07:00
Davy 7f6403f517 fix: use npm install@latest and global concurrency guard
Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard
2026-06-26 12:54:17 +07:00
Davy be8c2a54ce fix: clean up progress subscription on unmount, use typed phase cast
Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }
2026-06-26 12:53:23 +07:00
Davy c6c030ab26 feat: add remote daemon self-update from client
Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.
2026-06-26 12:53:23 +07:00
Mohamed Boudra 8c3b709794 fix(server): honor X-Forwarded-Proto so daemon web UI auto-connects behind HTTPS reverse proxy (#1739)
* fix(server): honor forwarded proto for daemon web UI

* fix(server): configure trusted daemon proxies

* docs: document daemon web UI reverse proxy setup

* docs: consolidate web UI proxy guidance
2026-06-26 13:26:50 +08:00
Mohamed Boudra c5c2ace698 docs: document self-hosting the web UI and community projects
Documents the daemon-served web UI from #1635: enabling it, reverse
proxy, TLS, tunnels, and the auth/exposure model. Adds a community
projects page for community-built self-hosting tooling.
2026-06-26 11:35:36 +07:00
Mohamed Boudra a49c658d1f 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
2026-06-26 12:16:49 +08:00
Mohamed Boudra 1109e453bc Merge branch 'main' of github.com:getpaseo/paseo 2026-06-26 11:01:38 +07:00
Mohamed Boudra 4e2c06ec71 Serve the web client from the daemon (#1635)
* Serve the web client from the daemon

Keep the bundled browser UI opt-in and exclude it from desktop packaging so desktop builds do not ship a duplicate renderer.

* Escape daemon web UI bootstrap hint

* Fix bundled web UI dist path
2026-06-26 12:00:11 +08:00
Mohamed Boudra d4cdd4d749 fix(app): let native user messages use font metrics 2026-06-26 10:50:16 +07:00
paseo-ai[bot] 3d6b4adc68 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-25 17:24:07 +00:00
164 changed files with 10264 additions and 2874 deletions
+1
View File
@@ -50,6 +50,7 @@ The heart of Paseo. A Node.js process that:
- Streams agent output in real time via a timeline model
- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
- Optionally connects outbound to a relay for remote access
- Optionally serves the browser web client from the same HTTP server (self-hosting guide: [public-docs/web-ui.md](../public-docs/web-ui.md))
All paths are under `packages/server/src/`.
+1
View File
@@ -150,6 +150,7 @@ Single file, validated with `PersistedConfigSchema`.
daemon: {
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
+50
View File
@@ -236,6 +236,56 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
}
```
## Bundled daemon web UI
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it for a running daemon with:
```bash
paseo daemon start --web-ui
```
Or set the environment variable:
```bash
PASEO_WEB_UI_ENABLED=true paseo daemon start
```
Or persist it in `config.json`:
```json
{
"features": {
"webUi": {
"enabled": true
}
}
}
```
When enabled, opening the daemon HTTP origin (for example `http://localhost:6767/`) serves the web app. The same HTTP server continues to serve `/api/*`, `/mcp/*`, `/public/*`, the WebSocket upgrade, and service-proxy routes. Static files load without daemon bearer auth; API and WebSocket calls still enforce auth.
The served app auto-bootstraps a connection to the same origin, so opening `http://localhost:6767/` directly usually skips the Add Host step.
Build the artifact for packaging or measurement with:
```bash
npm run build:daemon-web-ui
```
This exports the normal browser web app (not the Electron-flavored desktop renderer) and copies it into `packages/server/dist/server/web-ui`, precompressing `.html`, `.js`, `.css`, and JSON assets as `.br` and `.gz`.
Measured bundle size for a standard Expo web export:
- raw: 10.77 MiB
- gzip: 2.55 MiB
- brotli: 1.93 MiB
The desktop-managed daemon disables the bundled web UI by default (`PASEO_WEB_UI_ENABLED=false`) because the desktop app already ships the renderer as `app-dist`. Shipping the same assets again inside `@getpaseo/server` would duplicate the ~10.8 MiB install. Desktop packaging also excludes `node_modules/@getpaseo/server/dist/server/web-ui/**` from the packaged app.
## Built workspace packages
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
+14
View File
@@ -67,6 +67,19 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly:
```json
{
"version": 1,
"daemon": {
"trustedProxies": ["loopback", "172.16.0.0/12"]
}
}
```
`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers.
Nginx example:
```nginx
@@ -77,6 +90,7 @@ server {
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
+1 -1
View File
@@ -1 +1 @@
sha256-riRET+7Y8oJnMrydvzliE7/pe8T3/75EcIwGXw8vnCM=
sha256-7Xru5RdmXKX9U5tv4jn1wXChw3kfBuSnb2oRA+7HR7Q=
+1
View File
@@ -55,6 +55,7 @@
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
+52 -42
View File
@@ -403,10 +403,14 @@ async function waitForPairingOfferFromDaemon(args: {
);
}
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
@@ -440,7 +444,7 @@ async function applyPaseoHomeFork(targetHome: string): Promise<void> {
}
}
async function resolveDictationConfig(): Promise<DictationConfig> {
async function logSpeechHarnessConfig(): Promise<void> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -451,23 +455,20 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
// Fork PRs run without secrets and usually without local models. Don't crash
// the whole Playwright run — disable dictation/voice and let tests that need
// them gate on PASEO_DICTATION_ENABLED.
// Default app E2E does not cover speech flows. Keep speech disabled here so
// unrelated tests never start background local-model downloads.
if (!openAiUsable && !hasDefaultLocalModelsDir) {
console.warn(
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
"[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " +
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
);
return { openAiUsable: false, localModelsDir: null };
return;
}
const dictationProvider = openAiUsable ? "openai" : "local";
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
`[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`,
);
return { openAiUsable, localModelsDir };
}
interface RelayStreamState {
@@ -656,39 +657,40 @@ interface DaemonSpawnArgs {
paseoHome: string;
fakeEditorBinDir: string;
editorRecordPath: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const env: NodeJS.ProcessEnv = {
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
// Default app E2E does not cover speech flows. Keep these disabled so
// unrelated tests never start background local-model downloads.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete env[key];
}
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_NODE_ENV: "development",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
@@ -720,6 +722,15 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
return child;
}
async function removeTempTree(targetPath: string): Promise<void> {
await rm(targetPath, {
recursive: true,
force: true,
maxRetries: 40,
retryDelay: 250,
});
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
@@ -731,13 +742,13 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
await removeTempTree(paseoHome);
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeEditorBinDir) {
await rm(fakeEditorBinDir, { recursive: true, force: true });
await removeTempTree(fakeEditorBinDir);
fakeEditorBinDir = null;
}
}
@@ -761,7 +772,7 @@ export default async function globalSetup() {
const cleanup = () => performCleanup(shouldRemovePaseoHome);
const dictation = await resolveDictationConfig();
await logSpeechHarnessConfig();
try {
const relayPort = await startRelay(new Set([port, metroPort]));
@@ -777,7 +788,6 @@ export default async function globalSetup() {
paseoHome,
fakeEditorBinDir,
editorRecordPath,
dictation,
buffer: daemonLineBuffer,
});
+3 -4
View File
@@ -3,11 +3,12 @@ import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { getE2EDaemonPort } from "./daemon-port";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import {
buildHostAgentDetailRoute,
buildHostSessionsRoute,
buildHostWorkspaceRoute,
buildSessionsRoute,
} from "@/utils/host-routes";
export interface ArchiveTabAgent {
@@ -219,9 +220,7 @@ export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
@@ -0,0 +1,17 @@
import { expect, type Page } from "@playwright/test";
export async function expectAppRoute(
page: Page,
expectedRoute: string,
options?: { timeout?: number },
): Promise<void> {
await expect
.poll(
() => {
const current = new URL(page.url());
return `${current.pathname}${current.search}`;
},
{ timeout: options?.timeout },
)
.toBe(expectedRoute);
}
+16 -14
View File
@@ -1,7 +1,13 @@
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import {
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
@@ -33,12 +39,12 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
if (section === "projects") {
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await expectAppRoute(page, buildProjectsSettingsRoute());
return;
}
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
@@ -55,9 +61,7 @@ export async function openSettingsHostSection(
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, section));
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -84,13 +88,13 @@ export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
export async function openCompactSettings(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
export async function openCompactSettings(page: Page, expectedStartRoute: string): Promise<void> {
await expectAppRoute(page, expectedStartRoute, { timeout: 15_000 });
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
@@ -149,7 +153,7 @@ export async function expectSettingsHostPickerLabel(page: Page, label: string):
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
@@ -189,7 +193,7 @@ export async function removeCurrentHostFromSettings(page: Page): Promise<void> {
await page.getByTestId("host-page-remove-host-button").click();
await expect(page.getByTestId("remove-host-confirm-modal")).toBeVisible();
await page.getByTestId("remove-host-confirm").click();
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
}
export async function expectSettingsBackButton(page: Page): Promise<void> {
@@ -201,9 +205,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
}
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
);
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "connections"));
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
+8 -7
View File
@@ -156,14 +156,15 @@ class StartupAssertions {
this.page = page;
}
async expectsSavedHostShell(input: { label: string }): Promise<this> {
async expectsSavedHostShell(input: { serverId: string; label: string }): Promise<this> {
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
await this.page.getByTestId("sidebar-hosts-trigger").click();
const hostRow = this.page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(hostRow).toBeVisible({ timeout: 15_000 });
await expect(hostRow).toContainText(input.label);
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
return this;
}
+2 -4
View File
@@ -1,7 +1,7 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
import { escapeRegex } from "./regex";
import { expectAppRoute } from "./route-assertions";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
@@ -59,9 +59,7 @@ export async function switchWorkspaceViaSidebar(input: {
await row.click();
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
timeout: 30_000,
});
await expectAppRoute(input.page, targetWorkspaceRoute, { timeout: 30_000 });
}
/**
+18 -12
View File
@@ -1,5 +1,10 @@
import { test, expect } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
buildHostWorkspaceRoute,
buildOpenProjectRoute,
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
@@ -36,6 +41,7 @@ import {
removeCurrentHostFromSettings,
} from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { expectAppRoute } from "./helpers/route-assertions";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -121,7 +127,7 @@ test.describe("Settings — compact master-detail", () => {
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
await expectCompactSettingsList(page);
@@ -133,10 +139,10 @@ test.describe("Settings — compact master-detail", () => {
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "diagnostics");
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
await expectDiagnosticsContent(page);
await expectSettingsSidebarHidden(page);
await expectSettingsBackButton(page);
@@ -144,10 +150,10 @@ test.describe("Settings — compact master-detail", () => {
test("back from a section detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "about");
await expect(page).toHaveURL(/\/settings\/about$/);
await expectAppRoute(page, buildSettingsSectionRoute("about"));
await goBackInSettings(page);
await expectCompactSettingsList(page);
@@ -158,7 +164,7 @@ test.describe("Settings — compact master-detail", () => {
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettingsHost(page);
await expectSettingsBackButton(page);
@@ -167,11 +173,11 @@ test.describe("Settings — compact master-detail", () => {
test("back from a host detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettingsHost(page);
await goBackInSettings(page);
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expectSettingsSidebarVisible(page);
});
@@ -188,11 +194,11 @@ test.describe("Settings — compact master-detail", () => {
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
]);
await gotoAppShell(page);
await openCompactSettings(page);
await openCompactSettings(page, buildOpenProjectRoute());
await selectSettingsHost(page, secondaryServerId);
await expect(page).toHaveURL(/\/settings$/);
await expectAppRoute(page, buildSettingsRoute());
await expectSettingsSidebarVisible(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
@@ -206,7 +212,7 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "remove-host-compact-" });
await openWorkspace(page, workspace);
await openCompactSettings(page);
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await openSettingsHostSection(page, getServerId(), "host");
await removeCurrentHostFromSettings(page);
await closeCompactSettings(page);
+1 -1
View File
@@ -14,7 +14,7 @@ test.describe("Startup loading presentation", () => {
})
.openRoot();
await startup.expectsSavedHostShell({ label: "Dev" });
await startup.expectsSavedHostShell({ serverId: "srv_unreachable_mobile", label: "Dev" });
await startup.expectsNoSavedHostErrorStatus();
await startup.expectsNoLocalServerStartupCopy();
});
@@ -33,6 +33,7 @@ import {
import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -105,12 +106,8 @@ async function expectWorkspaceLocation(
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
},
): Promise<void> {
await expect(page).toHaveURL(
buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId),
{
timeout: 30_000,
},
);
const workspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId);
await expectAppRoute(page, workspaceRoute, { timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: input.workspace.workspaceName,
subtitle: input.workspace.projectDisplayName,
+80 -44
View File
@@ -34,6 +34,7 @@ import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
@@ -79,6 +80,7 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHosts,
@@ -88,11 +90,10 @@ import { applyAppearance } from "@/screens/settings/appearance/apply-appearance"
import { usePanelStore } from "@/stores/panel-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostRootRoute,
buildOpenProjectRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
@@ -427,12 +428,8 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname();
const activeServerId = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
[daemons, pathname],
);
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState();
@@ -494,12 +491,10 @@ function AppContainer({
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenter />
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
@@ -731,7 +726,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId));
router.replace(buildOpenProjectRoute());
return;
})
.catch((error) => {
@@ -761,48 +756,87 @@ interface OpenProjectEventPayload {
path?: unknown;
}
function OpenProjectListener() {
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
interface PendingOpenProjectRequest {
id: number;
serverId: string;
path: string;
}
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
let nextOpenProjectRequestId = 1;
function OpenProjectListener() {
const chooseHost = useHostChooser();
const hostRegistryLoaded = useHostRegistryLoaded();
const [request, setRequest] = useState<PendingOpenProjectRequest | null>(null);
const [pendingPath, setPendingPath] = useState<string | null>(null);
const openProject = useOpenProject(request?.serverId ?? null);
const openPathOnChosenHost = useCallback(
(path: string) => {
const nextPath = path.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
if (!hostRegistryLoaded) {
setPendingPath(nextPath);
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
setRequest({
id: nextOpenProjectRequestId++,
serverId,
path: nextPath,
});
},
});
},
[chooseHost, hostRegistryLoaded],
);
useEffect(() => {
if (!hostRegistryLoaded || !pendingPath) {
return;
}
const nextPath = pendingPath;
setPendingPath(null);
openPathOnChosenHost(nextPath);
}, [hostRegistryLoaded, openPathOnChosenHost, pendingPath]);
useEffect(() => {
if (!request) {
return;
}
let cancelled = false;
void openProject(request.path).then((result) => {
if (cancelled) {
return null;
}
void openProject(pathToOpen).catch(() => undefined);
if (!result.ok) {
setRequest((current) => (current?.id === request.id ? null : current));
return null;
}
setRequest((current) => (current?.id === request.id ? null : current));
return null;
});
return () => {
cancelled = true;
};
}, [openProject, request]);
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
openPathOnChosenHost(pending);
}
return;
})
@@ -814,7 +848,7 @@ function OpenProjectListener() {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
maybeOpenProject(nextPath);
openPathOnChosenHost(nextPath);
})
.then((dispose) => {
if (disposed) {
@@ -826,13 +860,11 @@ function OpenProjectListener() {
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [client, openProject, serverId]);
}, [openPathOnChosenHost]);
return null;
}
@@ -842,9 +874,11 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const params = useGlobalSearchParams<{ open?: string | string[] }>();
const hosts = useHosts();
const storeReady = useStoreReady();
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const routeHasKnownHost =
routeServerId !== null && hosts.some((host) => host.serverId === routeServerId);
const shouldShowAppChrome =
storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId);
storeReady && (pathname === "/open-project" || pathname === "/sessions" || routeHasKnownHost);
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
@@ -899,6 +933,8 @@ function RootStack() {
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
+7 -36
View File
@@ -1,39 +1,10 @@
import { Redirect } from "expo-router";
import { useHostRouteServerId } from "@/navigation/host-route-context";
import {
resolveHostIndexRoute,
resolveWorkspaceSelectionStatus,
} from "@/navigation/host-runtime-bootstrap";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export default function HostIndexRoute() {
const serverId = useHostRouteServerId();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionWorkspaceId =
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
if (!serverId || !isWorkspaceSelectionLoaded) {
return <StartupSplashScreen />;
}
return (
<Redirect
href={resolveHostIndexRoute({
serverId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces,
workspaceExists: workspaceSelectionExists,
}),
})}
/>
);
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
if (!serverId) return null;
// COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildOpenProjectRoute()} />;
}
@@ -1,18 +1,12 @@
import { useLocalSearchParams } from "expo-router";
import { Redirect } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export default function HostOpenProjectRoute() {
// COMPAT(hostOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
return (
<HostRouteBootstrapBoundary>
<HostOpenProjectRouteContent />
<Redirect href={buildOpenProjectRoute()} />
</HostRouteBootstrapBoundary>
);
}
function HostOpenProjectRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <OpenProjectScreen serverId={serverId} />;
}
+5 -16
View File
@@ -1,18 +1,7 @@
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
import { Redirect } from "expo-router";
import { buildSessionsRoute } from "@/utils/host-routes";
export default function HostAgentsRoute() {
return (
<HostRouteBootstrapBoundary>
<HostAgentsRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostAgentsRouteContent() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <SessionsScreen serverId={serverId} />;
export default function HostSessionsRoute() {
// COMPAT(hostSessionsRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildSessionsRoute()} />;
}
@@ -24,6 +24,10 @@ import {
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb } from "@/constants/platform";
@@ -63,7 +67,7 @@ function stripOpenSearchParamFromBrowserUrl() {
return;
}
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
replaceBrowserRouteWithCanonicalHostWorkspaceRoute(`${url.pathname}${url.search}${url.hash}`);
}
function clearConsumedOpenIntent(input: {
@@ -102,6 +106,13 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
}
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useEffect(() => {
if (!openValue) {
return;
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { OpenProjectScreen } from "@/screens/open-project-screen";
export default function OpenProjectRoute() {
return (
<HostRouteBootstrapBoundary>
<OpenProjectScreen />
</HostRouteBootstrapBoundary>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function SessionsRoute() {
return (
<HostRouteBootstrapBoundary>
<SessionsScreen />
</HostRouteBootstrapBoundary>
);
}
+3 -2
View File
@@ -4,10 +4,11 @@ import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string }>();
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
return <SettingsScreen view={view} />;
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
}
+101 -21
View File
@@ -33,6 +33,7 @@ interface AgentListProps {
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
showHostColumn?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
@@ -152,11 +153,62 @@ function WorkspaceTitlePrefix({
);
}
function SessionRowBadges({
agent,
archivedIcon,
pendingPermissionCount,
showDesktopAttention,
}: {
agent: AggregatedAgent;
archivedIcon: ReactElement;
pendingPermissionCount: number;
showDesktopAttention: boolean;
}) {
const { t } = useTranslation();
return (
<>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{showDesktopAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
</>
);
}
function SessionRowTrailingAttention({
isMobile,
showAttentionIndicator,
requiresAttention,
}: {
isMobile: boolean;
showAttentionIndicator: boolean;
requiresAttention: boolean | undefined;
}) {
const { t } = useTranslation();
if (!isMobile || !showAttentionIndicator || !requiresAttention) {
return null;
}
return (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
);
}
function SessionRow({
agent,
isMobile,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
onPress,
onLongPress,
}: {
@@ -164,6 +216,7 @@ function SessionRow({
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
showHostColumn: boolean;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}) {
@@ -200,6 +253,8 @@ function SessionRow({
() => <Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />,
[theme.fontSize.xs, theme.colors.foregroundMuted],
);
const showDesktopAttention =
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
return (
<Pressable
@@ -223,20 +278,14 @@ function SessionRow({
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || t("agentList.fallbackTitle")}
</Text>
{agent.archivedAt ? (
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
) : null}
{pendingPermissionCount > 0 ? (
<SessionBadge
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
tone="warning"
/>
) : null}
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
) : null}
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
pendingPermissionCount={pendingPermissionCount}
showDesktopAttention={showDesktopAttention}
/>
</View>
{isMobile && (
{isMobile ? (
<View style={styles.rowMetaRow}>
<Text
style={styles.sessionMetaText}
@@ -263,10 +312,18 @@ function SessionRow({
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
)}
) : null}
</View>
{!isMobile && (
{!isMobile ? (
<View style={styles.rowColumns}>
<Text
style={styles.columnMeta}
@@ -275,6 +332,11 @@ function SessionRow({
>
{projectName}
</Text>
{showHostColumn ? (
<Text style={styles.columnMetaHost} numberOfLines={1}>
{agent.serverLabel}
</Text>
) : null}
<Text
style={styles.columnMeta}
numberOfLines={1}
@@ -286,12 +348,12 @@ function SessionRow({
{timeAgo}
</Text>
</View>
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
</View>
) : null}
<SessionRowTrailingAttention
isMobile={isMobile}
showAttentionIndicator={showAttentionIndicator}
requiresAttention={agent.requiresAttention}
/>
</Pressable>
);
}
@@ -304,6 +366,7 @@ export function AgentList({
onAgentSelect,
listFooterComponent,
showAttentionIndicator = true,
showHostColumn = false,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -428,12 +491,21 @@ export function AgentList({
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
showHostColumn={showHostColumn}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
[
handleAgentLongPress,
handleAgentPress,
isMobile,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
t,
],
);
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
@@ -640,6 +712,14 @@ const styles = StyleSheet.create((theme) => ({
width: 72,
textAlign: "right" as const,
},
columnMetaHost: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 120,
marginLeft: theme.spacing[4],
textAlign: "right" as const,
},
badge: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,31 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type HostRuntimeConnectionStatus,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
function hostStatusDotColor(status: HostRuntimeConnectionStatus, theme: Theme) {
if (status === "online") return theme.colors.palette.green[400];
if (status === "connecting") return theme.colors.palette.amber[500];
return theme.colors.palette.red[500];
}
export function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const status = useHostRuntimeConnectionStatus(serverId);
const backgroundColor = hostStatusDotColor(status, theme);
const dotStyle = useMemo(() => [styles.dot, { backgroundColor }], [backgroundColor]);
return <View style={dotStyle} />;
}
const styles = StyleSheet.create(() => ({
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
}));
@@ -0,0 +1,19 @@
export const ADD_HOST_OPTION_ID = "__add_host__";
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
export function getHostPickerLabel(
hosts: Array<{ label: string; serverId: string }>,
value: string,
config?: { includeAllHost?: boolean; includeAddHost?: boolean },
): string {
if (config?.includeAllHost && value === ALL_HOSTS_OPTION_ID) {
return "All hosts";
}
if (config?.includeAddHost && value === ADD_HOST_OPTION_ID) {
return "Add host";
}
return (
hosts.find((host) => host.serverId === value)?.label ??
(config?.includeAllHost ? "All hosts" : "Host")
);
}
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
const hosts = [{ serverId: "host-a", label: "Host A" }];
it.each([
["host-a", undefined, "Host A"],
["missing", undefined, "Host"],
[ALL_HOSTS_OPTION_ID, { includeAllHost: true }, "All hosts"],
["missing", { includeAllHost: true }, "All hosts"],
[ALL_HOSTS_OPTION_ID, undefined, "Host"],
[ADD_HOST_OPTION_ID, { includeAddHost: true }, "Add host"],
])("resolves %s", (value, config, expected) => {
expect(getHostPickerLabel(hosts, value, config)).toBe(expected);
});
@@ -0,0 +1,302 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Plus, Server, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDot } from "@/components/host-status-dot";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { orderHostsLocalFirst } from "@/types/host-connection";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
interface HostPickerHost {
serverId: string;
label: string;
}
export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElement {
return (
<View style={styles.statusDotSlot}>
<HostStatusDot serverId={serverId} />
</View>
);
}
export interface HostPickerOptionProps {
serverId: string;
label: string;
isLocal: boolean;
selected?: boolean;
active: boolean;
onPress: () => void;
onOpenHostSettings?: (serverId: string) => void;
localMarkerTestID?: string;
testID?: string;
}
export function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
onOpenHostSettings,
localMarkerTestID,
testID,
}: HostPickerOptionProps): ReactElement {
const { theme } = useUnistyles();
const leadingSlot = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
const handleSettingsPress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onOpenHostSettings?.(serverId);
},
[onOpenHostSettings, serverId],
);
const trailingSlot = useMemo(() => {
if (!isLocal && !onOpenHostSettings) return undefined;
return (
<>
{isLocal ? (
<Text style={styles.localMarker} testID={localMarkerTestID}>
Local
</Text>
) : null}
{onOpenHostSettings ? (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</>
);
}, [
handleSettingsPress,
isLocal,
label,
localMarkerTestID,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
);
}
function SystemHostPickerOption({
active,
selected,
onPress,
kind,
testID,
}: {
active: boolean;
selected?: boolean;
onPress: () => void;
kind: "add" | "all";
testID?: string;
}): ReactElement {
const { theme } = useUnistyles();
const Icon = kind === "add" ? Plus : Server;
const label = kind === "add" ? "Add host" : "All hosts";
const leadingSlot = useMemo(
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
selected={selected}
active={active}
interactiveFeedback={false}
onPress={onPress}
testID={testID}
/>
);
}
export interface HostPickerProps {
hosts: HostPickerHost[];
value: string;
onSelect: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
anchorRef: React.RefObject<View | null>;
includeAllHost?: boolean;
includeAddHost?: boolean;
onAddHost?: () => void;
showLocalMarker?: boolean;
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
title?: string;
desktopPlacement?: "top-start" | "bottom-start";
desktopMinWidth?: number;
addHostTestID?: string;
hostOptionTestID?: (serverId: string) => string;
hostLocalMarkerTestID?: (serverId: string) => string;
children: ReactNode;
}
export function HostPicker({
hosts,
value,
onSelect,
open,
onOpenChange,
anchorRef,
includeAllHost,
includeAddHost,
onAddHost,
showLocalMarker,
onOpenHostSettings,
searchable,
title,
desktopPlacement = "top-start",
desktopMinWidth,
addHostTestID,
hostOptionTestID,
hostLocalMarkerTestID,
children,
}: HostPickerProps): ReactElement {
const localServerId = useLocalDaemonServerId();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
const options = useMemo(() => {
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
return hostOptions;
}, [orderedHosts, includeAllHost, includeAddHost]);
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost?.();
} else {
onSelect(id);
}
onOpenChange(false);
},
[onAddHost, onOpenChange, onSelect],
);
const handleOpenHostSettings = useCallback(
(serverId: string) => {
onOpenHostSettings?.(serverId);
onOpenChange(false);
},
[onOpenHostSettings, onOpenChange],
);
const renderOption = useCallback<RenderHostOption>(
({ option, selected, active, onPress }) => {
if (option.id === ADD_HOST_OPTION_ID) {
return (
<SystemHostPickerOption
kind="add"
active={active}
onPress={onPress}
testID={addHostTestID}
/>
);
}
if (option.id === ALL_HOSTS_OPTION_ID) {
return (
<SystemHostPickerOption
kind="all"
active={active}
selected={selected}
onPress={onPress}
/>
);
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={showLocalMarker === true && localServerId === option.id}
selected={selected}
active={active}
onPress={onPress}
onOpenHostSettings={onOpenHostSettings ? handleOpenHostSettings : undefined}
localMarkerTestID={hostLocalMarkerTestID?.(option.id)}
testID={hostOptionTestID?.(option.id)}
/>
);
},
[
addHostTestID,
hostLocalMarkerTestID,
hostOptionTestID,
localServerId,
onOpenHostSettings,
showLocalMarker,
handleOpenHostSettings,
],
);
return (
<>
{children}
<Combobox
options={options}
value={value}
onSelect={handleSelect}
renderOption={renderOption}
searchable={isSearchable}
searchPlaceholder="Search hosts"
title={title ?? "Host"}
open={open}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
/>
</>
);
}
const styles = StyleSheet.create((theme) => ({
statusDotSlot: {
width: theme.iconSize.sm,
height: theme.iconSize.sm,
alignItems: "center",
justifyContent: "center",
},
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
}));
+165 -355
View File
@@ -1,18 +1,7 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, History, Home, Plus, Search, Settings, X } from "lucide-react-native";
import { FolderPlus, History, Home, Plus, Search, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
type Dispatch,
memo,
type ReactElement,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
@@ -20,8 +9,6 @@ import {
useWindowDimensions,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -34,40 +21,42 @@ import Animated, {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useHostChooser } from "@/hosts/host-chooser";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
useSidebarWorkspacesList,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
selectIsAgentListOpen,
usePanelStore,
} from "@/stores/panel-store";
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostOpenProjectRoute,
buildOpenProjectRoute,
buildHostNewWorkspaceRoute,
buildHostSessionsRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
mapPathnameToServer,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -85,14 +74,9 @@ interface LeftSidebarProps {
interface SidebarSharedProps {
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
@@ -101,19 +85,14 @@ interface SidebarSharedProps {
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleNewWorkspaceNavigate: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: SidebarLabels;
renderHostOption: (input: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
newWorkspaceKeys: ShortcutKey[][] | null;
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}
interface SidebarLabels {
@@ -154,70 +133,24 @@ export const LeftSidebar = memo(function LeftSidebar({
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname();
const daemons = useHosts();
const activeDaemon = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname }),
[daemons, pathname],
);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return t("sidebar.host.noHost");
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon, t]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId
? (activeHostSnapshot?.connectionStatus ?? "connecting")
: "idle";
let activeHostStatusColor: string;
if (activeHostStatus === "online") activeHostStatusColor = theme.colors.palette.green[400];
else if (activeHostStatus === "connecting")
activeHostStatusColor = theme.colors.palette.amber[500];
else activeHostStatusColor = theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
})),
[daemons],
);
const renderHostOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<HostSwitchOption
serverId={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
/>
),
[],
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
const {
workspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarWorkspacesList({
enabled: isCompactLayout || isOpen,
});
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
});
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects });
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
);
const groupMode = useSidebarViewStore((state) => state.groupMode);
const [isManualRefresh, setIsManualRefresh] = useState(false);
@@ -232,7 +165,8 @@ export const LeftSidebar = memo(function LeftSidebar({
}
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker(activeServerId);
const openProjectPicker = useOpenProjectPicker();
const chooseHost = useHostChooser();
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
@@ -244,9 +178,13 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
if (!activeServerId) return;
router.navigate(buildHostNewWorkspaceRoute(activeServerId));
}, [activeServerId]);
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
router.push(buildHostNewWorkspaceRoute(serverId));
},
});
}, [chooseHost]);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
@@ -257,38 +195,41 @@ export const LeftSidebar = memo(function LeftSidebar({
router.push(buildSettingsRoute());
}, []);
const handleHomeMobile = useCallback(() => {
if (!activeServerId) return;
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId, showMobileAgent]);
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
if (!activeServerId) return;
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSessionsRoute(activeServerId));
}, [activeServerId]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath);
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
showMobileAgent();
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
},
[pathname],
[showMobileAgent],
);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
}, []);
const handleHomeMobile = useCallback(() => {
showMobileAgent();
router.push(buildOpenProjectRoute());
}, [showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
router.push(buildOpenProjectRoute());
}, []);
const handleViewMoreNavigate = useCallback(() => {
router.push(buildSessionsRoute());
}, []);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
@@ -305,14 +246,9 @@ export const LeftSidebar = memo(function LeftSidebar({
const sharedProps = {
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
statusWorkspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -321,8 +257,6 @@ export const LeftSidebar = memo(function LeftSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
labels,
newWorkspaceKeys,
};
@@ -339,6 +273,8 @@ export const LeftSidebar = memo(function LeftSidebar({
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
);
@@ -353,91 +289,41 @@ export const LeftSidebar = memo(function LeftSidebar({
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
);
});
interface HostPickerTriggerProps {
triggerRef: React.Ref<View>;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
hostOptionsEmpty: boolean;
hostStatusDotStyle: StyleProp<ViewStyle>;
activeHostLabel: string;
function sidebarHostOptionTestID(serverId: string): string {
return `sidebar-host-row-${serverId}`;
}
function HostPickerTrigger({
triggerRef,
setIsHostPickerOpen,
hostOptionsEmpty,
hostStatusDotStyle,
activeHostLabel,
}: HostPickerTriggerProps) {
const pressableStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
],
[],
);
const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]);
return (
<Pressable
ref={triggerRef}
style={pressableStyle}
onPress={handlePress}
disabled={hostOptionsEmpty}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
);
}
function HostSwitchOption({
serverId,
label,
selected,
active,
onPress,
}: {
serverId: string;
label: string;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const snapshot = useHostRuntimeSnapshot(serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
return (
<ComboboxItem
label={label}
description={formatConnectionStatus(connectionStatus)}
selected={selected}
active={active}
onPress={onPress}
/>
);
function sidebarHostLocalMarkerTestID(serverId: string): string {
return `sidebar-host-local-marker-${serverId}`;
}
function FooterIconButton({
buttonRef,
onPress,
testID,
accessibilityLabel,
icon: Icon,
iconSize,
theme,
}: {
onPress: () => void;
testID: string;
accessibilityLabel: string;
icon: typeof FolderPlus;
iconSize?: number;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
return (
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
@@ -449,7 +335,7 @@ function FooterIconButton({
>
{({ hovered }) => (
<Icon
size={theme.iconSize.md}
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -457,6 +343,59 @@ function FooterIconButton({
);
}
function SidebarHostPicker({
theme,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}) {
const hosts = useHosts();
const triggerRef = useRef<View | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
onOpenHostSettings(id);
},
[onOpenHostSettings],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
return (
<HostPicker
hosts={hosts}
value=""
onSelect={handleSelect}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
onOpenHostSettings={onOpenHostSettings}
searchable
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
hostLocalMarkerTestID={sidebarHostLocalMarkerTestID}
>
<FooterIconButton
buttonRef={triggerRef}
onPress={handleOpen}
testID="sidebar-hosts-trigger"
accessibilityLabel="Hosts"
icon={Server}
iconSize={theme.iconSize.sm}
theme={theme}
/>
</HostPicker>
);
}
function AddProjectTooltipContent({
newAgentKeys,
label,
@@ -489,30 +428,14 @@ function HeaderIconTooltipContent({
function SidebarFooter({
theme,
activeServerId,
activeHostLabel,
hostStatusDotStyle,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
}: {
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
hostStatusDotStyle: StyleProp<ViewStyle>;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
handleHostSelect: (nextServerId: string) => void;
renderHostOption: SidebarSharedProps["renderHostOption"];
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
@@ -523,20 +446,19 @@ function SidebarFooter({
switchHost: string;
searchHosts: string;
};
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
return (
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<HostPickerTrigger
triggerRef={hostTriggerRef}
setIsHostPickerOpen={setIsHostPickerOpen}
hostOptionsEmpty={hostOptions.length === 0}
hostStatusDotStyle={hostStatusDotStyle}
activeHostLabel={activeHostLabel}
/>
</View>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<FooterIconButton
@@ -566,33 +488,15 @@ function SidebarFooter({
theme={theme}
/>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title={labels.switchHost}
searchPlaceholder={labels.searchHosts}
desktopMinWidth={280}
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
);
}
function MobileSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
statusWorkspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -601,14 +505,14 @@ function MobileSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
insetsBottom,
isOpen,
@@ -638,21 +542,11 @@ function MobileSidebar({
}, [closeSidebar, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return;
}
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
closeSidebar,
handleViewMoreNavigate,
translateX,
windowWidth,
]);
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
@@ -752,11 +646,6 @@ function MobileSidebar({
[windowWidth, insetsTop, insetsBottom],
);
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
@@ -826,7 +715,7 @@ function MobileSidebar({
variant="compact"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -851,12 +740,13 @@ function MobileSidebar({
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
@@ -867,19 +757,12 @@ function MobileSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</Animated.View>
@@ -890,14 +773,9 @@ function MobileSidebar({
function DesktopSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
statusWorkspacePlacements,
projects,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
@@ -906,14 +784,14 @@ function DesktopSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
isOpen,
handleViewMore,
@@ -924,10 +802,6 @@ function DesktopSidebar({
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor],
);
const startWidthRef = useRef(sidebarWidth);
const resizeWidth = useSharedValue(sidebarWidth);
@@ -1007,18 +881,19 @@ function DesktopSidebar({
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<WorkspacesSectionHeader />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
@@ -1029,19 +904,12 @@ function DesktopSidebar({
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
{/* Resize handle - absolutely positioned over right border */}
@@ -1053,7 +921,7 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
function WorkspacesSectionHeader() {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
@@ -1096,7 +964,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu serverId={serverId} />
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
@@ -1208,45 +1076,15 @@ const styles = StyleSheet.create((theme) => ({
sidebarDragArea: {
position: "relative",
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
hostTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostTriggerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
justifyContent: "flex-start",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
footerHostSlot: {
flexGrow: 0,
flexShrink: 1,
minWidth: 0,
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
@@ -1261,34 +1099,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
hostPickerList: {
gap: theme.spacing[2],
},
hostPickerOption: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
hostPickerOptionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostPickerCancel: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
+1 -2
View File
@@ -361,8 +361,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
overflowWrap: "anywhere",
...(isWeb ? { lineHeight: 22, overflowWrap: "anywhere" as const } : {}),
},
imagePreviewContainer: {
flexDirection: "row",
@@ -8,57 +8,29 @@ import {
View,
type PressableStateCallbackType,
} from "react-native";
import { Folder, FolderPlus } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { getOpenProjectFailureReason, type OpenProjectFailureReason } from "@/hooks/open-project";
import { useOpenProject } from "@/hooks/use-open-project";
import type { OpenProjectFailure, OpenProjectResult } from "@/hooks/open-project";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProjectPickerStore } from "@/stores/project-picker-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { shortenPath } from "@/utils/shorten-path";
import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
buildProjectPickerOptions,
isOpenableProjectPath,
type ProjectPickerOption,
} from "./project-picker-options";
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
interface PathRowProps {
option: ProjectPickerOption;
active: boolean;
openPathLabel: string;
onSelect: (path: string) => void;
}
type ProjectPickerErrorCode = NonNullable<OpenProjectFailure["errorCode"]>;
function getProjectPickerErrorMessage(
result: OpenProjectResult | undefined,
thrownError: Error | null,
translateErrorCode: (errorCode: ProjectPickerErrorCode) => string,
translateOpenFailed: () => string,
): string | null {
if (result?.ok === false) {
if (result.errorCode) {
return translateErrorCode(result.errorCode);
}
return result.error;
}
if (thrownError) {
return thrownError.message || translateOpenFailed();
}
return null;
}
function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
function PathRow({ option, active, onSelect }: PathRowProps) {
const { theme } = useUnistyles();
const Icon = option.kind === "path" ? FolderPlus : Folder;
const { t } = useTranslation();
const path = option.path;
const displayPath = shortenPath(path);
const label = option.kind === "path" ? `${openPathLabel}: ${displayPath}` : displayPath;
const handlePress = useCallback(() => {
onSelect(path);
}, [onSelect, path]);
@@ -75,15 +47,22 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
() => [styles.rowText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const rowActionTextStyle = useMemo(
() => [styles.rowActionText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<Pressable style={pressableStyle} onPress={handlePress}>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Icon size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
</View>
<Text style={rowTextStyle} numberOfLines={1}>
{label}
{shortenPath(path)}
</Text>
{option.kind === "path" ? (
<Text style={rowActionTextStyle}>{t("projectPicker.openPath")}</Text>
) : null}
</View>
</Pressable>
);
@@ -92,10 +71,10 @@ function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const request = useProjectPickerStore((state) => state.request);
const close = useProjectPickerStore((state) => state.close);
const serverId = request?.serverId ?? null;
const open = request !== null;
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
@@ -104,25 +83,10 @@ export function ProjectPickerModal() {
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [openErrorReason, setOpenErrorReason] = useState<OpenProjectFailureReason | null>(null);
const openProject = useOpenProject(serverId);
const openProjectMutation = useMutation({
mutationFn: (path: string) => openProject(path),
onSuccess: (result) => {
if (result.ok) {
setOpen(false);
}
},
});
const { mutate: submitPath, reset: resetSubmit, isPending: isSubmitting } = openProjectMutation;
const submitResult = openProjectMutation.data;
const errorMessage = getProjectPickerErrorMessage(
submitResult,
openProjectMutation.error,
(errorCode) => t(`projectPicker.errors.${errorCode}`),
() => t("projectPicker.errors.open_failed"),
);
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, query],
queryFn: async () => {
@@ -142,56 +106,72 @@ export function ProjectPickerModal() {
retry: false,
});
const options = useMemo(() => {
return buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
});
}, [query, directorySuggestionsQuery.data, recommendedPaths]);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const handleSelectPath = useCallback(
(path: string) => {
const trimmed = path.trim();
if (!trimmed || !client || !serverId) return;
submitPath(trimmed);
},
[client, serverId, submitPath],
const options = useMemo(
() =>
buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[directorySuggestionsQuery.data, query, recommendedPaths],
);
const handleSubmitCustom = useCallback(() => {
const trimmed = query.trim();
if (!isOpenableProjectPath(trimmed)) return;
handleSelectPath(trimmed);
}, [handleSelectPath, query]);
const handleChangeQuery = useCallback(
(text: string) => {
setQuery(text);
setActiveIndex(0);
resetSubmit();
},
[resetSubmit],
);
// Reset state when opening/closing
useEffect(() => {
resetSubmit();
if (!open) {
return;
const openErrorMessage = useMemo(() => {
if (!openErrorReason) {
return null;
}
setQuery("");
return t(`projectPicker.errors.${openErrorReason}`);
}, [openErrorReason, t]);
const handleClose = useCallback(() => {
close();
}, [close]);
const handleSelectPath = useCallback(
async (path: string) => {
const trimmed = path.trim();
if (!trimmed || !client || !serverId) return;
setOpenErrorReason(null);
setIsSubmitting(true);
try {
const result = await openProject(trimmed);
if (result.ok) {
close();
return;
}
setOpenErrorReason(getOpenProjectFailureReason(result));
} finally {
setIsSubmitting(false);
}
},
[client, close, openProject, serverId],
);
const submitActiveOption = useCallback(() => {
const option = options[activeIndex];
if (!option) return;
void handleSelectPath(option.path);
}, [activeIndex, handleSelectPath, options]);
const handleChangeQuery = useCallback((text: string) => {
setQuery(text);
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [open, resetSubmit]);
setOpenErrorReason(null);
}, []);
useEffect(() => {
if (open) {
setQuery("");
setActiveIndex(0);
setOpenErrorReason(null);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}
}, [open]);
// Clamp active index
useEffect(() => {
if (!open) return;
if (activeIndex >= options.length) {
@@ -199,7 +179,6 @@ export function ProjectPickerModal() {
}
}, [activeIndex, options.length, open]);
// Keyboard navigation
useEffect(() => {
if (!open || isNative) return;
@@ -209,17 +188,13 @@ export function ProjectPickerModal() {
if (key === "Escape") {
event.preventDefault();
handleClose();
close();
return;
}
if (key === "Enter") {
event.preventDefault();
if (options.length > 0 && activeIndex < options.length) {
handleSelectPath(options[activeIndex].path);
} else if (query.trim()) {
handleSubmitCustom();
}
submitActiveOption();
return;
}
@@ -238,7 +213,7 @@ export function ProjectPickerModal() {
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelectPath, handleSubmitCustom, open, options, query]);
}, [close, open, options.length, submitActiveOption]);
const panelStyle = useMemo(
() => [
@@ -258,14 +233,14 @@ export function ProjectPickerModal() {
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const errorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const emptyTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const errorTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
if (!serverId) return null;
@@ -288,9 +263,8 @@ export function ProjectPickerModal() {
autoFocus
editable={!isSubmitting}
returnKeyType="go"
onSubmitEditing={handleSubmitCustom}
onSubmitEditing={submitActiveOption}
/>
{errorMessage ? <Text style={errorTextStyle}>{errorMessage}</Text> : null}
</View>
<ScrollView
@@ -300,17 +274,19 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && options.length === 0 ? (
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && options.length > 0 ? (
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
openPathLabel={t("projectPicker.openPath")}
onSelect={handleSelectPath}
/>
))}
@@ -353,11 +329,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
errorText: {
marginTop: theme.spacing[2],
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
results: {
flexGrow: 0,
},
@@ -383,8 +354,13 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: 20,
flex: 1,
flexShrink: 1,
},
rowActionText: {
fontSize: theme.fontSize.xs,
fontWeight: "500",
},
emptyText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
@@ -152,29 +152,27 @@ function makeHost(): HostProfile {
};
}
function setHostProfiles(hosts: HostProfile[]): void {
(
getHostRuntimeStore() as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
getHostRuntimeStore().syncHosts([makeHost()]);
setHostProfiles([makeHost()]);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrderByServerId: {
[SERVER_ID]: ["project-a", "project-b"],
},
workspaceOrderByServerAndProject: {
[`${SERVER_ID}::project-a`]: [
`${SERVER_ID}:a-main`,
`${SERVER_ID}:a-one`,
`${SERVER_ID}:a-two`,
],
[`${SERVER_ID}::project-b`]: [
`${SERVER_ID}:b-main`,
`${SERVER_ID}:b-one`,
`${SERVER_ID}:b-two`,
],
projectOrder: ["project-a", "project-b"],
workspaceOrderByProject: {
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
},
});
});
@@ -258,7 +256,7 @@ function WorkspaceSelectionProbe({
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
return (
<>
@@ -345,11 +343,11 @@ describe("sidebar workspace render isolation", () => {
container = null;
act(() => {
pathnameState.value = "/";
getHostRuntimeStore().syncHosts([]);
setHostProfiles([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
projectOrder: [],
workspaceOrderByProject: {},
});
});
});
File diff suppressed because it is too large Load Diff
@@ -5,14 +5,16 @@ import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -32,22 +34,29 @@ interface DisplayPreferenceOption<Value extends string> {
label: string;
}
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelect = useCallback(
const handleSelectMode = useCallback(
(mode: SidebarGroupMode) => {
if (!serverId) return;
setGroupMode(serverId, mode);
setGroupMode(mode);
},
[serverId, setGroupMode],
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
@@ -65,6 +74,8 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
[],
);
const showHostFilter = hosts.length > 1;
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -75,7 +86,7 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
@@ -85,9 +96,33 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelect}
onSelect={handleSelectMode}
/>
))}
{showHostFilter ? (
<>
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
))}
</>
) : null}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
@@ -129,6 +164,33 @@ function DisplayPreferenceMenuItem<Value extends string>({
);
}
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
@@ -1,9 +1,14 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
useSidebarWorkspaceEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
@@ -20,12 +25,44 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -40,11 +77,21 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
workspaces: SidebarStatusWorkspacePlacement[];
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -53,7 +100,6 @@ interface StatusWorkspaceListProps {
export function SidebarStatusWorkspaceList({
workspaces,
projectNamesByKey,
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -89,7 +135,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -106,7 +151,6 @@ export function SidebarStatusWorkspaceList({
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -121,7 +165,6 @@ function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
serverId,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
@@ -129,7 +172,6 @@ function StatusGroupList({
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
@@ -149,7 +191,6 @@ function StatusGroupList({
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
serverId={serverId}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -248,45 +289,473 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
projectName,
serverId,
shortcutNumber,
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
workspace: SidebarStatusWorkspacePlacement;
projectName: string;
serverId: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
const handlePress = useCallback(() => {
if (!serverId) return;
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!hydratedWorkspace) return null;
if (!workspaceEntry) return null;
return (
<SidebarWorkspaceRow
workspace={hydratedWorkspace}
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
onPress={handlePress}
subtitle={projectName}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) return;
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
function StatusWorkspaceRowInner({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const isTouchPlatform = platformIsNative;
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive?: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
}: {
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -350,4 +819,37 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));
+10 -4
View File
@@ -230,6 +230,7 @@ export interface ComboboxItemProps {
disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
interactiveFeedback?: boolean;
onPress: () => void;
testID?: string;
}
@@ -244,6 +245,7 @@ export function ComboboxItem({
active,
disabled,
elevated,
interactiveFeedback = true,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -269,12 +271,16 @@ export function ComboboxItem({
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.comboboxItem,
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive,
interactiveFeedback &&
hovered &&
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
interactiveFeedback &&
pressed &&
(elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
interactiveFeedback && active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
],
[elevated, active, disabled],
[elevated, active, disabled, interactiveFeedback],
);
const itemContentStyle = useMemo(
@@ -992,5 +992,6 @@ const styles = StyleSheet.create((theme) => ({
},
itemContent: {
flexShrink: 1,
minWidth: 0,
},
}));
+7 -10
View File
@@ -12,7 +12,7 @@ import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
@@ -170,15 +170,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
useEffect(() => {
if (!anyOnlineServerId) return;
router.replace(buildHostRootRoute(anyOnlineServerId));
router.replace(buildOpenProjectRoute());
}, [anyOnlineServerId, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId));
},
[router],
);
const finishOnboarding = useCallback(() => {
router.replace(buildOpenProjectRoute());
}, [router]);
const handleOpenPaseoSite = useCallback(() => {
void openExternalUrl("https://paseo.sh");
@@ -197,9 +194,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, [router]);
const handleHostSaved = useCallback(
({ profile, serverId }: { profile: HostProfile; serverId: string }) => {
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
@@ -39,6 +39,8 @@ import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
interface Rect {
x: number;
@@ -284,6 +286,7 @@ function WorkspaceHoverCardContent({
{workspace.name}
</Text>
</View>
<HostRow serverId={workspace.serverId} />
{workspace.currentBranch ? (
<CopyableInfoRow
icon={ThemedGitBranch}
@@ -327,6 +330,22 @@ function WorkspaceHoverCardContent({
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedFolder = withUnistyles(Folder);
function HostRow({ serverId }: { serverId: string }): ReactElement | null {
const hosts = useHosts();
const host = hosts.find((h) => h.serverId === serverId);
const snapshot = useHostRuntimeSnapshot(serverId);
const label = host?.label?.trim() || serverId;
const status = formatConnectionStatus(snapshot?.connectionStatus ?? "idle");
return (
<View style={styles.hostRow}>
<Text style={styles.hostRowLabel}>{label}</Text>
<Text style={styles.hostRowStatus}>{status}</Text>
</View>
);
}
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
@@ -548,6 +567,22 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
hostRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hostRowLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hostRowStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
cardMetaRow: {
flexDirection: "row",
alignItems: "center",
@@ -6,11 +6,13 @@ import { act } from "@testing-library/react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import type { HostProfile } from "@/types/host-connection";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
@@ -42,6 +44,27 @@ function workspaceDescriptor(input: {
};
}
function hostProfile(serverId = "srv"): HostProfile {
const now = "2026-04-19T00:00:00.000Z";
return {
serverId,
label: "Shortcut Host",
lifecycle: {},
connections: [],
preferredConnectionId: null,
createdAt: now,
updatedAt: now,
};
}
function setHostProfiles(hosts: HostProfile[]): void {
(
getHostRuntimeStore() as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
describe("WorkspaceShortcutTargetsSubscriber", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -58,14 +81,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
collapsedProjectKeys: new Set(),
});
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
projectOrder: [],
workspaceOrderByProject: {},
});
useSidebarViewStore.setState({
groupModeByServerId: {},
groupMode: "project",
hostFilter: null,
});
act(() => {
setHostProfiles([hostProfile()]);
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
"srv",
@@ -88,13 +113,16 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
container?.remove();
container = null;
act(() => {
setHostProfiles([]);
useSessionStore.getState().clearSession("srv");
useSessionStore.getState().clearSession("host-a");
useSessionStore.getState().clearSession("host-b");
});
});
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -105,7 +133,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("srv", "status");
useSidebarViewStore.getState().setGroupMode("status");
useSessionStore.getState().setWorkspaces(
"srv",
new Map([
@@ -158,7 +186,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
@@ -169,13 +197,52 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
it("publishes shortcut targets from the visible host filter in project and status modes", async () => {
act(() => {
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
useSessionStore
.getState()
.setWorkspaces(
"host-b",
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().setHostFilter("host-b");
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "host-b", workspaceId: "b-1" },
]);
await act(async () => {
useSidebarViewStore.getState().setGroupMode("status");
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "host-b", workspaceId: "b-1" },
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
@@ -1,9 +1,6 @@
import { useEffect, useMemo } from "react";
import {
useProjectNamesMap,
useStatusModeWorkspaceEntries,
} from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
@@ -12,28 +9,16 @@ import {
buildStatusSidebarShortcutModel,
} from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({
enabled,
serverId,
}: {
enabled: boolean;
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
// groupMode must be resolved before gating the status-mode subscriptions below.
const groupMode = useSidebarViewStore((state) =>
enabled && serverId ? state.getGroupMode(serverId) : "project",
);
// Only subscribe to agents/workspaces when the status-group view is actually active.
// In project mode (the default), these subscriptions would fire on every agent update
// (agents Map identity is replaced on every status transition) with no effect on
// the shortcut targets, causing ~15-46 wasted re-renders per agent switch.
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({
enabled,
});
const groupMode = useSidebarViewStore((state) => state.groupMode);
const isStatusMode = enabled && groupMode === "status";
const statusWorkspaces = useStatusModeWorkspaceEntries({
serverId: isStatusMode ? serverId : null,
projects,
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: workspacePlacements,
enabled: isStatusMode,
});
const projectNamesByKey = useProjectNamesMap(isStatusMode ? serverId : null);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
@@ -47,7 +32,7 @@ export function WorkspaceShortcutTargetsSubscriber({
const shortcutModel = useMemo(() => {
if (groupMode === "status") {
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces,
workspaces: statusWorkspacePlacements,
projectNamesByKey,
collapsedStatusGroupKeys,
});
@@ -63,17 +48,17 @@ export function WorkspaceShortcutTargetsSubscriber({
groupMode,
projectNamesByKey,
projects,
statusWorkspaces,
statusWorkspacePlacements,
]);
useEffect(() => {
if (!enabled || !serverId) {
if (!enabled) {
setSidebarShortcutWorkspaceTargets([]);
return;
}
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
}, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
}, [enabled, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
useEffect(() => {
return () => {
@@ -1,7 +1,12 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
const pendingWorkspaceArchivesByServer = new Map<string, Set<string>>();
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
@@ -10,6 +15,7 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
@@ -17,8 +23,11 @@ export function markWorkspaceArchivePending(input: {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Set<string>();
archives.add(pendingArchiveKey({ serverId, workspaceId }));
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
@@ -45,15 +54,34 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
if (!serverId) {
return false;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
return archives?.has(pendingArchiveKey({ serverId, workspaceId })) ?? false;
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -63,5 +91,6 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}
@@ -1,23 +0,0 @@
import { getDesktopHost } from "@/desktop/host";
export async function pickDirectory(): Promise<string | null> {
const open = getDesktopHost()?.dialog?.open;
if (typeof open !== "function") {
throw new Error("Desktop dialog open() is unavailable in this environment.");
}
const selection = await open({
directory: true,
multiple: false,
});
if (selection === null) {
return null;
}
if (typeof selection === "string") {
return selection;
}
throw new Error("Unexpected directory picker response.");
}
+57
View File
@@ -0,0 +1,57 @@
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
interface PrStatusLike {
url: string;
state: string;
isMerged: boolean;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
let state: "merged" | "open" | "closed";
if (status.isMerged || status.state === "merged") state = "merged";
else if (status.state === "open") state = "open";
else state = "closed";
return {
url: status.url,
number,
state,
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
};
}
+2 -58
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
interface UseCheckoutPrStatusQueryOptions {
serverId: string;
@@ -11,64 +12,7 @@ interface UseCheckoutPrStatusQueryOptions {
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
interface PrStatusLike {
url: string;
state: string;
isMerged: boolean;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
let state: "merged" | "open" | "closed";
if (status.isMerged || status.state === "merged") state = "merged";
else if (status.state === "open") state = "open";
else state = "closed";
return {
url: status.url,
number,
state,
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
};
}
export { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
return selectPrHintFromStatus(payload.status);
@@ -1,3 +1,13 @@
export function agentHistoryQueryKey(serverId: string | null) {
return ["agentHistory", serverId] as const;
}
const ALL_AGENT_HISTORY_QUERY_ROOT = ["allAgentHistory"] as const;
export function allAgentHistoryQueryRootKey() {
return ALL_AGENT_HISTORY_QUERY_ROOT;
}
export function allAgentHistoryQueryKey(serverIds: readonly string[]) {
return [...ALL_AGENT_HISTORY_QUERY_ROOT, ...[...serverIds].sort()] as const;
}
+15
View File
@@ -19,6 +19,21 @@ export interface OpenProjectFailure {
}
export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure;
export type OpenProjectFailureReason = "directory_not_found" | "open_failed";
export function getOpenProjectFailureReason(
result: OpenProjectResult,
): OpenProjectFailureReason | null {
if (result.ok) {
return null;
}
if (result.errorCode === "directory_not_found") {
return "directory_not_found";
}
return "open_failed";
}
export interface OpenProjectDirectlyInput {
serverId: string;
@@ -15,6 +15,7 @@ function ws(
serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj",
projectName: input.projectName ?? "Project",
projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory,
projectKind: input.projectKind ?? "git",
@@ -1,6 +1,6 @@
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
"needs_input",
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
export interface StatusGroup {
bucket: StatusBucket;
label: string;
rows: SidebarWorkspaceEntry[];
rows: SidebarStatusWorkspacePlacement[];
}
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
workspaces: SidebarStatusWorkspacePlacement[],
projectNamesByKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
for (const ws of workspaces) {
const bucket: StatusBucket = ws.statusBucket;
@@ -54,8 +54,8 @@ export function buildStatusGroups(
}
function compareStatusRows(
a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry,
a: SidebarStatusWorkspacePlacement,
b: SidebarStatusWorkspacePlacement,
projectNamesByKey: Map<string, string>,
): number {
const aTime = a.statusEnteredAt?.getTime() ?? null;
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarStatusWorkspacePlacements,
buildSidebarWorkspacePlacementModel,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
@@ -23,12 +26,20 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
hosts?: WorkspaceStructureProject["hosts"];
}): WorkspaceStructureProject {
return {
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
hosts: input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys,
};
}
@@ -36,10 +47,8 @@ function project(input: {
function sidebarProject(input: {
projectKey: string;
workspaceKeys: string[];
serverId?: string;
}): SidebarProjectEntry {
const projects = buildSidebarProjectsFromStructure({
serverId: input.serverId ?? "srv",
projects: [project({ projectKey: input.projectKey, workspaceKeys: input.workspaceKeys })],
});
const result = projects[0];
@@ -49,6 +58,31 @@ function sidebarProject(input: {
return result;
}
function workspace(input: {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
projectKind: "git",
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
@@ -107,7 +141,6 @@ describe("appendMissingOrderKeys", () => {
describe("buildSidebarProjectsFromStructure", () => {
it("creates structural workspace rows from ordered workspace keys", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [
project({
projectKey: "project-1",
@@ -131,7 +164,6 @@ describe("buildSidebarProjectsFromStructure", () => {
it("preserves the structure hook project order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
@@ -143,15 +175,126 @@ describe("buildSidebarProjectsFromStructure", () => {
it("preserves the structure hook workspace order", () => {
const projects = buildSidebarProjectsFromStructure({
serverId: "srv",
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
});
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
expect(projects[0]?.workspaces.map((placement) => placement.workspaceId)).toEqual([
"feature",
"main",
]);
});
it("resolves workspace keys by known host prefix when server ids contain colons", () => {
const projects = buildSidebarProjectsFromStructure({
projects: [
project({
projectKey: "project-1",
hosts: [
{
serverId: "relay:paseo-host",
iconWorkingDir: "/repo/project-1",
canCreateWorktree: true,
},
],
workspaceKeys: ["relay:paseo-host:ws-main"],
}),
],
});
expect(projects[0]?.workspaces[0]).toMatchObject({
workspaceKey: "relay:paseo-host:ws-main",
serverId: "relay:paseo-host",
workspaceId: "ws-main",
});
});
});
describe("shared sidebar workspace model", () => {
it("feeds project placement and status grouping from the same cross-host workspace identities", () => {
const model = buildSidebarWorkspacePlacementModel({
projects: [
project({
projectKey: "getpaseo/paseo",
projectName: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
],
workspaceKeys: ["host-a:main", "host-b:feature"],
}),
],
});
const statusRows = buildSidebarStatusWorkspacePlacements({
placements: model.workspaces,
sessions: [
{
serverId: "host-a",
workspaces: new Map([
[
"main",
workspace({
id: "main",
name: "main",
projectId: "getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
status: "done",
}),
],
]),
},
{
serverId: "host-b",
workspaces: new Map([
[
"feature",
workspace({
id: "feature",
name: "feature/status-flow",
projectId: "getpaseo/paseo",
projectDisplayName: "getpaseo/paseo",
status: "running",
statusEnteredAt: new Date("2026-06-10T00:00:00.000Z"),
}),
],
]),
},
],
});
expect(model.workspaces.map((entry) => entry.workspaceKey)).toEqual([
"host-a:main",
"host-b:feature",
]);
expect(model.projects).toEqual([
expect.objectContaining({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
],
workspaces: [
expect.objectContaining({
workspaceKey: "host-a:main",
serverId: "host-a",
name: "main",
}),
expect.objectContaining({
workspaceKey: "host-b:feature",
serverId: "host-b",
name: "feature",
}),
],
}),
]);
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
[
["host-a:main", "done", "main"],
["host-b:feature", "running", "feature/status-flow"],
],
);
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
});
});
describe("computeSidebarOrderUpdates", () => {
@@ -206,8 +349,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: false,
}),
).toEqual({ isLoading: true, isInitialLoad: true, isRevalidating: false });
@@ -217,8 +360,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: true,
}),
).toEqual({ isLoading: true, isInitialLoad: false, isRevalidating: false });
@@ -228,8 +371,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: true,
serverId: "srv",
hasHydratedWorkspaces: true,
serverIds: ["srv"],
hydratedServerIds: ["srv"],
hasProjects: true,
}),
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
@@ -239,8 +382,8 @@ describe("deriveSidebarLoadingState", () => {
expect(
deriveSidebarLoadingState({
isActive: false,
serverId: "srv",
hasHydratedWorkspaces: false,
serverIds: ["srv"],
hydratedServerIds: [],
hasProjects: false,
}),
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
@@ -1,38 +1,44 @@
import type { PrHint } from "@/git/use-pr-status-query";
import {
canCreateWorktreeForProjectKind,
type HostProjectListItem,
} from "@/projects/host-project-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import type { PrHint } from "@/git/pr-hint";
import { selectPrHintFromStatus } from "@/git/pr-hint";
import { type HostProjectListItem } from "@/projects/host-project-model";
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
function workspaceNameFromDirectory(directory: string): string {
const trimmed = directory.trim().replace(/[\\/]+$/g, "");
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
}
export type SidebarStateBucket = WorkspaceDescriptor["status"];
export interface SidebarWorkspaceEntry {
export interface SidebarWorkspacePlacement {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKey: string;
projectName: string;
projectRootPath?: string;
workspaceDirectory?: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
}
export interface SidebarStatusWorkspacePlacement extends SidebarWorkspacePlacement {
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
}
export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
// Raw user-set title (null when the name is derived from branch/directory).
// Prefills the rename input and signals whether a reset is available.
title: string | null;
// Checkout branch (null when not a git checkout or detached HEAD).
currentBranch: string | null;
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
prHint: PrHint | null;
@@ -47,52 +53,278 @@ export interface SidebarProjectEntry {
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
canCreateWorktree: boolean;
workspaces: SidebarWorkspaceEntry[];
hosts: WorkspaceStructureHostPlacement[];
workspaces: SidebarWorkspacePlacement[];
}
export interface SidebarWorkspacePlacementModel {
workspaces: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
}
export interface SidebarStatusWorkspaceSession {
serverId: string;
workspaces: Map<string, WorkspaceDescriptor>;
agents?: Map<string, Agent>;
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
return (
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey)
);
}
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
if (!currentBranch) {
return null;
}
const trimmed = currentBranch.trim();
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
}
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey,
projectName: projectNameForWorkspace(input.workspace, projectKey),
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
title: input.workspace.title ?? null,
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};
}
function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
pendingCreateAttempts: input.pendingCreateAttempts,
});
if (pendingStartedAt) {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
function getPendingInitialAgentCreateStartedAt(input: {
serverId: string;
workspaceId: string;
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
}): Date | null {
let latestStartedAt: Date | null = null;
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
if (pending.serverId !== input.serverId) continue;
if (pending.workspaceId !== input.workspaceId) continue;
if (pending.lifecycle === "abandoned") continue;
const startedAt = new Date(pending.timestamp);
if (!latestStartedAt || startedAt > latestStartedAt) {
latestStartedAt = startedAt;
}
}
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents?: Map<string, Agent>;
}): WorkspaceAgentActivity | null {
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function buildSidebarWorkspacePlacementModel(input: {
projects: readonly HostProjectListItem[];
}): SidebarWorkspacePlacementModel {
const projects = buildSidebarProjectsFromHostProjects({ projects: input.projects });
return {
projects,
workspaces: projects.flatMap((project) => project.workspaces),
projectNamesByKey: new Map(
projects.map((project) => [project.projectKey, project.projectName]),
),
};
}
function createStructuralWorkspaceEntry(input: {
serverId: string;
project: HostProjectListItem;
workspaceId: string;
}): SidebarWorkspaceEntry {
workspaceKey: string;
}): SidebarWorkspacePlacement {
const identity = resolveStructuralWorkspaceIdentity({
project: input.project,
workspaceKey: input.workspaceKey,
});
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
workspaceKey: identity.workspaceKey,
serverId: identity.serverId,
workspaceId: identity.workspaceId,
projectKey: input.project.projectKey,
projectName: input.project.projectName,
projectRootPath: input.project.iconWorkingDir,
workspaceDirectory: undefined,
projectKind: input.project.projectKind,
workspaceKind: "checkout",
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
title: null,
currentBranch: null,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
name: identity.workspaceId,
};
}
export function buildSidebarProjectsFromStructure(input: {
function resolveStructuralWorkspaceIdentity(input: {
project: HostProjectListItem;
workspaceKey: string;
}): {
workspaceKey: string;
serverId: string;
workspaceId: string;
} {
const hostsByLongestPrefix = [...input.project.hosts].sort(
(left, right) => right.serverId.length - left.serverId.length,
);
for (const host of hostsByLongestPrefix) {
const prefix = `${host.serverId}:`;
if (!input.workspaceKey.startsWith(prefix)) continue;
const workspaceId = input.workspaceKey.slice(prefix.length);
if (!workspaceId) continue;
return {
workspaceKey: input.workspaceKey,
serverId: host.serverId,
workspaceId,
};
}
const separatorIndex = input.workspaceKey.indexOf(":");
if (separatorIndex > 0) {
return {
workspaceKey: input.workspaceKey,
serverId: input.workspaceKey.slice(0, separatorIndex),
workspaceId: input.workspaceKey.slice(separatorIndex + 1),
};
}
const serverId = input.project.hosts[0]?.serverId ?? input.workspaceKey;
return {
workspaceKey: `${serverId}:${input.workspaceKey}`,
serverId,
workspaceId: input.workspaceKey,
};
}
export function buildSidebarStatusWorkspacePlacements(input: {
placements: readonly SidebarWorkspacePlacement[];
sessions: SidebarStatusWorkspaceSession[];
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
}): SidebarStatusWorkspacePlacement[] {
if (input.placements.length === 0 || input.sessions.length === 0) {
return [];
}
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
const rows: SidebarStatusWorkspacePlacement[] = [];
for (const placement of input.placements) {
const session = sessionByServerId.get(placement.serverId);
if (!session) continue;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: session.workspaces,
workspaceId: placement.workspaceId,
});
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
if (!workspace) continue;
const effectiveStatus = deriveEffectiveWorkspaceStatus({
serverId: placement.serverId,
workspace,
pendingCreateAttempts: input.pendingCreateAttempts,
agents: session.agents,
});
rows.push({
...placement,
name: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
});
}
return rows;
}
export function buildSidebarProjectsFromStructure(input: {
projects: WorkspaceStructureProject[];
}): SidebarProjectEntry[] {
return buildSidebarProjectsFromHostProjects({
projects: input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
})),
});
}
@@ -109,12 +341,11 @@ export function buildSidebarProjectsFromHostProjects(input: {
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
canCreateWorktree: project.canCreateWorktree,
workspaces: project.workspaceKeys.map((workspaceId) =>
hosts: project.hosts,
workspaces: project.workspaceKeys.map((workspaceKey) =>
createStructuralWorkspaceEntry({
serverId: project.serverId,
project,
workspaceId,
workspaceKey,
}),
),
}));
@@ -227,11 +458,14 @@ export interface SidebarLoadingState {
export function deriveSidebarLoadingState(input: {
isActive: boolean;
serverId: string | null;
hasHydratedWorkspaces: boolean;
serverIds: string[];
hydratedServerIds: string[];
hasProjects: boolean;
}): SidebarLoadingState {
const isLoading = input.isActive && Boolean(input.serverId) && !input.hasHydratedWorkspaces;
const hasRegisteredHosts = input.serverIds.length > 0;
const allHydrated =
input.serverIds.length > 0 && input.serverIds.length === input.hydratedServerIds.length;
const isLoading = input.isActive && hasRegisteredHosts && !allHydrated;
const isInitialLoad = isLoading && !input.hasProjects;
return { isLoading, isInitialLoad, isRevalidating: false };
}
@@ -1,15 +0,0 @@
import { useMemo } from "react";
import { usePathname } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { resolveActiveHost } from "@/utils/active-host";
export function useActiveHost() {
const pathname = usePathname();
const hosts = useHosts();
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
}
export function useActiveServerId(): string | null {
return useActiveHost()?.serverId ?? null;
}
@@ -1,10 +1,28 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type {
DaemonClient,
FetchAgentHistoryEntry,
FetchAgentHistoryOptions,
} from "@getpaseo/client/internal/daemon-client";
import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history";
import type { AgentHistoryClient, AgentHistoryHost } from "./use-agent-history";
import { allAgentHistoryQueryKey } from "./agent-history-query-key";
(
globalThis as unknown as {
__DEV__: boolean;
}
).__DEV__ = false;
type UseAgentHistoryModule = typeof import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
});
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
@@ -30,6 +48,17 @@ function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient
};
}
function createFailingClient(): FakeAgentHistoryClient {
const calls: FetchAgentHistoryOptions[] = [];
return {
calls,
fetchAgentHistory: async (options) => {
calls.push(options ?? {});
throw new Error("Host history failed");
},
};
}
function historyPayload(input: {
entries: FetchAgentHistoryEntry[];
hasMore?: boolean;
@@ -105,6 +134,12 @@ function historyEntry(input: {
}
describe("fetchAgentHistoryPage", () => {
it("builds the all-host query key independent of host order", () => {
expect(allAgentHistoryQueryKey(["server-b", "server-a"])).toEqual(
allAgentHistoryQueryKey(["server-a", "server-b"]),
);
});
it("requests the first page with the default limit and updated_at descending sort", async () => {
const client = createClient([
historyPayload({
@@ -206,4 +241,134 @@ describe("fetchAgentHistoryPage", () => {
expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
});
it("fetches and sorts history across hosts with host labels", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "older-a",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Older A",
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "newer-b",
cwd: "/repo/b",
updatedAt: "2026-04-02T10:00:00.000Z",
title: "Newer B",
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
});
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
"Linux box:newer-b",
"MacBook:older-a",
]);
});
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "next-a",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "next-b",
cwd: "/repo/b",
updatedAt: "2026-04-02T10:00:00.000Z",
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: { "server-b": "cursor-b" },
});
expect(page.agents.map((agent) => agent.id)).toEqual(["next-b"]);
expect(serverAClient.calls).toEqual([]);
expect(serverBClient.calls).toEqual([
{
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-b" },
} satisfies FetchAgentHistoryOptions,
]);
});
it("keeps fulfilled host history when another host fails", async () => {
const failedClient = createFailingClient();
const healthyClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "healthy-history",
cwd: "/repo/healthy",
updatedAt: "2026-04-02T10:00:00.000Z",
}),
],
hasMore: true,
nextCursor: "healthy-cursor",
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "failed-host", serverLabel: "Failed", client: failedClient },
{ serverId: "healthy-host", serverLabel: "Healthy", client: healthyClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
});
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
"Healthy:healthy-history",
]);
expect(page.pageInfoByServerId).toEqual({
"healthy-host": {
nextCursor: "healthy-cursor",
prevCursor: null,
hasMore: true,
},
});
});
it("throws when every requested host history fetch fails", async () => {
await expect(
fetchAgentHistoryBatch({
hosts: [
{ serverId: "failed-a", serverLabel: "Failed A", client: createFailingClient() },
{ serverId: "failed-b", serverLabel: "Failed B", client: createFailingClient() },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
}),
).rejects.toThrow("No connected hosts could load agent history");
});
});
+150 -37
View File
@@ -4,26 +4,28 @@ import type {
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
const AGENT_HISTORY_PAGE_LIMIT = 200;
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
const AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE = "No connected hosts could load agent history";
export interface AgentHistoryResult {
agents: AggregatedAgent[];
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
isError: boolean;
hasMore: boolean;
isLoadingMore: boolean;
refreshAll: () => void;
refreshAll: () => Promise<void>;
loadMore: () => void;
}
@@ -34,6 +36,19 @@ export interface AgentHistoryPage {
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
export interface AgentHistoryHost {
serverId: string;
serverLabel: string;
client: AgentHistoryClient;
}
interface AgentHistoryBatchPage {
agents: AggregatedAgent[];
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
}
type AgentHistoryCursorByServerId = Record<string, string | null>;
export async function fetchAgentHistoryPage(input: {
client: AgentHistoryClient;
serverId: string;
@@ -75,70 +90,167 @@ export async function fetchAgentHistoryPage(input: {
};
}
function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}
function getNextAgentHistoryPageParam(
page: AgentHistoryBatchPage,
): AgentHistoryCursorByServerId | null {
const cursorByServerId: AgentHistoryCursorByServerId = {};
for (const [serverId, pageInfo] of Object.entries(page.pageInfoByServerId)) {
if (pageInfo.hasMore && pageInfo.nextCursor) {
cursorByServerId[serverId] = pageInfo.nextCursor;
}
}
return Object.keys(cursorByServerId).length > 0 ? cursorByServerId : null;
}
export async function fetchAgentHistoryBatch(input: {
hosts: readonly AgentHistoryHost[];
cursorByServerId: AgentHistoryCursorByServerId | null;
}): Promise<AgentHistoryBatchPage> {
const cursorByServerId = input.cursorByServerId ?? {};
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
const hostsToFetch = hasCursorFilter
? input.hosts.filter((host) => Object.hasOwn(cursorByServerId, host.serverId))
: input.hosts;
const settledPages = await Promise.allSettled(
hostsToFetch.map(async (host) => {
const page = await fetchAgentHistoryPage({
client: host.client,
serverId: host.serverId,
cursor: cursorByServerId[host.serverId] ?? null,
});
return { host, page };
}),
);
const pages = settledPages.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
);
if (pages.length === 0) {
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
}
const agents = pages.flatMap(({ host, page }) =>
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
);
const pageInfoByServerId = Object.fromEntries(
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
);
return {
agents: sortByLatestActivity(agents),
pageInfoByServerId,
};
}
export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]);
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
const targetHosts = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
if (!client || !isHostRuntimeConnected(snapshot)) {
continue;
}
hosts.push({
serverId: targetServerId,
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
client,
});
}
return hosts;
}, [daemons, runtime, runtimeVersion, serverId]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
const queryKey = useMemo(
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
[serverId, targetServerIds],
);
const serverLabelById = useMemo(
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
[daemons],
);
const historyQuery = useInfiniteQuery<
AgentHistoryPage,
AgentHistoryBatchPage,
Error,
{ pages: AgentHistoryPage[] },
ReturnType<typeof agentHistoryQueryKey>,
string | null
{ pages: AgentHistoryBatchPage[] },
readonly unknown[],
AgentHistoryCursorByServerId | null
>({
queryKey,
enabled: Boolean(enabled && serverId && client && isConnected),
enabled: Boolean(enabled && targetHosts.length > 0),
staleTime: 30_000,
initialPageParam: null as string | null,
getNextPageParam: (lastPage) =>
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
initialPageParam: null,
getNextPageParam: getNextAgentHistoryPageParam,
queryFn: async ({ pageParam }) => {
if (!serverId || !client) {
if (targetHosts.length === 0) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
return fetchAgentHistoryBatch({
hosts: targetHosts,
cursorByServerId: pageParam,
});
},
});
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
historyQuery;
const {
data,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
refetch,
} = historyQuery;
const refreshAll = useCallback(() => {
if (!serverId || !client || !isConnected) {
const refreshAll = useCallback(async () => {
if (!enabled || targetHosts.length === 0) {
return;
}
void refetch();
}, [client, isConnected, refetch, serverId]);
await refetch();
}, [enabled, refetch, targetHosts.length]);
const loadMore = useCallback(() => {
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
if (!enabled || targetHosts.length === 0 || !hasNextPage || isFetchingNextPage) {
return;
}
void fetchNextPage();
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
const agents = useMemo(
() =>
(data?.pages ?? [])
.flatMap((page) => page.agents)
.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabel ?? agent.serverLabel,
}),
),
[data?.pages, serverLabel],
);
const agents = useMemo(() => {
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
const labelledAgents = historyAgents.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
}),
);
return sortByLatestActivity(labelledAgents);
}, [data?.pages, serverLabelById]);
const isInitialLoad = isLoading && agents.length === 0;
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
@@ -147,6 +259,7 @@ export function useAgentHistory(options: {
isLoading,
isInitialLoad,
isRevalidating,
isError,
hasMore: hasNextPage,
isLoadingMore: isFetchingNextPage,
refreshAll,
@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-all-agents-list";
import type { Agent } from "@/stores/session-store";
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status: "idle",
createdAt: AGENT_TIMESTAMP,
updatedAt: AGENT_TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: AGENT_TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/tmp/project",
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input?: Partial<Agent>): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
describe("useAllAgentsList", () => {
it("excludes archived agents by default", () => {
const visibleAgent = makeAgent({ id: "visible" });
const archivedAgent = makeAgent({
id: "archived",
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
});
const result = __private__.buildAllAgentsList({
agents: [visibleAgent, archivedAgent],
serverId: "server-1",
serverLabel: "Local",
includeArchived: false,
});
expect(result.map((agent) => agent.id)).toEqual(["visible"]);
});
it("includes archived agents when requested", () => {
const visibleAgent = makeAgent({ id: "visible" });
const archivedAgent = makeAgent({
id: "archived",
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
});
const result = __private__.buildAllAgentsList({
agents: [visibleAgent, archivedAgent],
serverId: "server-1",
serverLabel: "Local",
includeArchived: true,
});
expect(result.map((agent) => agent.id)).toEqual(["visible", "archived"]);
expect(result[1]?.archivedAt).toEqual(archivedAgent.archivedAt);
});
});
@@ -1,125 +0,0 @@
import { useCallback, useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatus,
useHostRuntimeIsDirectoryLoading,
} from "@/runtime/host-runtime";
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
function toAggregatedAgent(params: {
source: Agent;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
const source = params.source;
return {
id: source.id,
serverId: params.serverId,
serverLabel: params.serverLabel,
title: source.title ?? null,
status: source.status,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,
provider: source.provider,
pendingPermissionCount: source.pendingPermissions.length,
requiresAttention: source.requiresAttention,
attentionReason: source.attentionReason,
attentionTimestamp: source.attentionTimestamp ?? null,
archivedAt: source.archivedAt ?? null,
createdAt: source.createdAt,
labels: source.labels,
};
}
function buildAllAgentsList(params: {
agents: Iterable<Agent>;
serverId: string;
serverLabel: string;
includeArchived: boolean;
}): AggregatedAgent[] {
const list: AggregatedAgent[] = [];
for (const agent of params.agents) {
const aggregated = toAggregatedAgent({
source: agent,
serverId: params.serverId,
serverLabel: params.serverLabel,
});
if (!params.includeArchived && aggregated.archivedAt) {
continue;
}
list.push(aggregated);
}
list.sort((left, right) => {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
if (leftRunning && !rightRunning) {
return -1;
}
if (!leftRunning && rightRunning) {
return 1;
}
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
});
return list;
}
export function useAllAgentsList(options?: {
serverId?: string | null;
includeArchived?: boolean;
}): AggregatedAgentsResult {
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const includeArchived = options?.includeArchived ?? false;
const liveAgents = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.agents ?? null) : null,
);
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
const refreshAll = useCallback(() => {
if (!serverId || connectionStatus !== "online") {
return;
}
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
}, [runtime, serverId, connectionStatus]);
const agents = useMemo(() => {
if (!serverId || !liveAgents) {
return [];
}
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
return buildAllAgentsList({
agents: liveAgents.values(),
serverId,
serverLabel,
includeArchived,
});
}, [daemons, includeArchived, liveAgents, serverId]);
const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? "");
const isInitialLoad = isDirectoryLoading && agents.length === 0;
const isRevalidating = isDirectoryLoading && agents.length > 0;
return {
agents,
isLoading: isDirectoryLoading,
isInitialLoad,
isRevalidating,
refreshAll,
};
}
export const __private__ = {
buildAllAgentsList,
toAggregatedAgent,
};
@@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
import {
applyArchivedAgentCloseResults,
isAgentArchiving,
@@ -144,6 +144,17 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]), {
pages: [
{
agents: [
{ id: "agent-1", serverId: "server-a", archivedAt: null },
{ id: "agent-1", serverId: "server-b", archivedAt: null },
],
},
],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -174,6 +185,24 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
expect(
queryClient.getQueryState(allAgentHistoryQueryKey(["server-a", "server-b"]))?.isInvalidated,
).toBe(true);
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]))).toEqual({
pages: [
{
agents: [
{
id: "agent-1",
serverId: "server-a",
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
},
{ id: "agent-1", serverId: "server-b", archivedAt: null },
],
},
],
pageParams: [null],
});
});
it("can apply archived agent close results without invalidating cached lists", () => {
@@ -194,6 +223,10 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a"]), {
pages: [{ agents: [{ id: "agent-1", serverId: "server-a", archivedAt: null }] }],
pageParams: [null],
});
applyArchivedAgentCloseResults({
queryClient,
@@ -205,6 +238,9 @@ describe("useArchiveAgent", () => {
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(allAgentHistoryQueryKey(["server-a"]))?.isInvalidated).toBe(
false,
);
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
pages: [
{
@@ -213,5 +249,19 @@ describe("useArchiveAgent", () => {
],
pageParams: [null],
});
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a"]))).toEqual({
pages: [
{
agents: [
{
id: "agent-1",
serverId: "server-a",
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
},
],
},
],
pageParams: [null],
});
});
});
+30 -3
View File
@@ -1,8 +1,14 @@
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
const EMPTY_PENDING_ARCHIVE_AGENT_IDS = new Set<string>();
@@ -29,6 +35,7 @@ export interface AgentsListQueryData {
export interface AgentHistoryQueryAgent {
id?: string | null;
serverId?: string | null;
archivedAt?: Date | null;
}
@@ -175,7 +182,10 @@ export function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryDat
let pageChanged = false;
const agents = page.agents.map((agent) => {
if (agent.id !== input.agentId) {
if (
agent.id !== input.agentId ||
(agent.serverId != null && agent.serverId !== input.serverId)
) {
return agent;
}
pageChanged = true;
@@ -200,6 +210,10 @@ export function markAgentArchivedInHistoryCache(
agentHistoryQueryKey(input.serverId),
(current) => markAgentArchivedInHistoryPayload(current, input),
);
queryClient.setQueriesData<AgentHistoryQueryData | undefined>(
{ queryKey: allAgentHistoryQueryRootKey() },
(current) => markAgentArchivedInHistoryPayload(current, input),
);
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
@@ -218,6 +232,7 @@ interface ArchivedAgentListCacheSnapshot {
sidebarAgentsList: AgentsListQueryData | undefined;
allAgents: AgentsListQueryData | undefined;
agentHistory: AgentHistoryQueryData | undefined;
allAgentHistory: Array<[QueryKey, AgentHistoryQueryData | undefined]>;
}
interface ArchiveAgentMutationContext {
@@ -268,6 +283,9 @@ function getArchivedAgentListCacheSnapshot(
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
agentHistoryQueryKey(serverId),
),
allAgentHistory: queryClient.getQueriesData<AgentHistoryQueryData | undefined>({
queryKey: allAgentHistoryQueryRootKey(),
}),
};
}
@@ -295,6 +313,9 @@ function restoreArchivedAgentListCacheSnapshot(
);
restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
for (const [queryKey, querySnapshot] of snapshot.allAgentHistory) {
restoreCachedQuerySnapshot(queryClient, queryKey, querySnapshot);
}
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
@@ -360,6 +381,9 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
void input.queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void input.queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
}
}
@@ -449,6 +473,9 @@ export function useArchiveAgent() {
void queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
},
});
+5 -15
View File
@@ -4,14 +4,13 @@ import { router, usePathname, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -20,7 +19,6 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import { isWeb } from "@/constants/platform";
const EMPTY_AGENTS: AggregatedAgent[] = [];
@@ -139,7 +137,6 @@ function resolveActionShortcutKeys(
export function useCommandCenter() {
const { t } = useTranslation();
const pathname = usePathname();
const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -153,11 +150,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = open ? routeActiveServerId : null;
const { agents } = useAllAgentsList({
serverId: activeServerId,
});
const { agents } = useAggregatedAgents();
const agentResults = useMemo(() => {
if (!open || agents.length === 0) {
@@ -172,10 +165,7 @@ export function useCommandCenter() {
return buildSettingsRoute();
}, []);
const homeRoute = useMemo<Href | undefined>(() => {
if (!routeActiveServerId) return undefined;
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
}, [routeActiveServerId]);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute() as Href, []);
const actionItems = useMemo(() => {
if (!open) {
@@ -239,7 +229,7 @@ export function useCommandCenter() {
[pathname, setOpen],
);
const openProjectPicker = useOpenProjectPicker(activeServerId);
const openProjectPicker = useOpenProjectPicker();
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {
@@ -23,7 +23,6 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
import { isNative } from "@/constants/platform";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
type ActiveWorkspaceSelection,
navigateToLastWorkspace,
@@ -55,8 +54,7 @@ export function useKeyboardShortcuts({
step: 0,
timeoutId: null,
});
const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
const openProjectPickerAction = useOpenProjectPicker();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
@@ -1,30 +1,15 @@
import { useCallback } from "react";
import { pickDirectory } from "@/desktop/pick-directory";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useIsLocalDaemon } from "./use-is-local-daemon";
import { useOpenProject } from "./use-open-project";
import { useHostChooser } from "@/hosts/host-chooser";
import { useProjectPickerStore } from "@/stores/project-picker-store";
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
const normalizedServerId = serverId?.trim() ?? "";
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
const openProject = useOpenProject(serverId);
export function useOpenProjectPicker(): () => void {
const chooseHost = useHostChooser();
const openProjectPicker = useProjectPickerStore((state) => state.open);
return useCallback(async () => {
if (!normalizedServerId) {
return;
}
if (!isLocalDaemon) {
setProjectPickerOpen(true);
return;
}
const path = await pickDirectory();
if (path === null) {
return;
}
await openProject(path);
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
return useCallback(() => {
chooseHost({
title: "Choose host",
onChooseHost: openProjectPicker,
});
}, [chooseHost, openProjectPicker]);
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { openProjectDirectly } from "@/hooks/open-project";
import { getOpenProjectFailureReason, openProjectDirectly } from "@/hooks/open-project";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
@@ -132,3 +132,21 @@ describe("openProjectDirectly", () => {
expect(session.hydrated).toEqual([]);
});
});
describe("getOpenProjectFailureReason", () => {
it("keeps the known directory-not-found failure reason", () => {
expect(
getOpenProjectFailureReason({
ok: false,
errorCode: "directory_not_found",
error: "Directory not found: /missing",
}),
).toBe("directory_not_found");
});
it("uses the generic failure reason for untyped project-open failures", () => {
expect(getOpenProjectFailureReason({ ok: false, errorCode: null, error: "boom" })).toBe(
"open_failed",
);
});
});
@@ -18,6 +18,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
serverId: "srv",
workspaceId,
projectKey,
projectName: projectKey,
projectRootPath: `/repo/${projectKey}`,
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
projectKind: "git",
@@ -43,7 +44,7 @@ function project(projectKey: string): SidebarProjectEntry {
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/repo/${projectKey}`,
canCreateWorktree: true,
hosts: [],
workspaces: [workspace(projectKey, `${projectKey}-main`)],
};
}
@@ -1,22 +1,24 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo } from "react";
import equal from "fast-deep-equal";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarProjectsFromHostProjects,
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
} from "./sidebar-workspaces-view-model";
export {
@@ -24,131 +26,21 @@ export {
applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure,
buildSidebarStatusWorkspacePlacements,
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
type SidebarLoadingState,
type SidebarOrderUpdates,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspacePlacement,
type SidebarWorkspacePlacementModel,
type SidebarProjectEntry,
type SidebarStateBucket,
type SidebarWorkspaceEntry,
} from "./sidebar-workspaces-view-model";
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
if (!currentBranch) {
return null;
}
const trimmed = currentBranch.trim();
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
}
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): SidebarWorkspaceEntry {
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
title: input.workspace.title ?? null,
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
agents?: Map<string, Agent>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
pendingCreateAttempts: input.pendingCreateAttempts,
});
if (pendingStartedAt) {
return { status: "running", enteredAt: pendingStartedAt };
}
const rootAgentActivity = getRootAgentWorkspaceActivity({
workspace: input.workspace,
agents: input.agents,
});
if (rootAgentActivity && rootAgentActivity.status !== "done") {
return rootAgentActivity;
}
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
function getPendingInitialAgentCreateStartedAt(input: {
serverId: string;
workspaceId: string;
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
}): Date | null {
let latestStartedAt: Date | null = null;
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
if (pending.serverId !== input.serverId) continue;
if (pending.workspaceId !== input.workspaceId) continue;
if (pending.lifecycle === "abandoned") continue;
const startedAt = new Date(pending.timestamp);
if (!latestStartedAt || startedAt > latestStartedAt) {
latestStartedAt = startedAt;
}
}
return latestStartedAt;
}
function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents: Map<string, Agent> | undefined;
}): WorkspaceAgentActivity | null {
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
latest = { status, enteredAt };
}
}
return latest;
}
export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
@@ -182,9 +74,13 @@ export function useSidebarWorkspaceEntry(
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
const EMPTY_PROJECT_NAMES = new Map<string, string>();
export interface SidebarWorkspacesListResult {
workspacePlacements: SidebarWorkspacePlacement[];
projects: SidebarProjectEntry[];
projectNamesByKey: Map<string, string>;
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
@@ -192,121 +88,122 @@ export interface SidebarWorkspacesListResult {
}
export function useSidebarWorkspacesList(options?: {
serverId?: string | null;
hostFilter?: string | null;
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
const allHosts = useHosts();
const hostRegistryLoaded = useHostRegistryLoaded();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const isActive = Boolean(serverId) && options?.enabled !== false;
const persistedProjectOrder = useSidebarOrderStore((state) =>
isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const hostProjects = useHostProjects(isActive ? serverId : null);
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilter = options?.hostFilter ?? storeHostFilter;
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
const effectiveHostFilter =
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
const isActive = options?.enabled !== false;
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
);
const projects = useMemo(() => {
if (!serverId || hostProjects.length === 0) {
return EMPTY_PROJECTS;
const serverIds = useMemo(() => {
if (effectiveHostFilter) {
return allServerIds.filter((id) => id === effectiveHostFilter);
}
return buildSidebarProjectsFromHostProjects({
projects: hostProjects,
});
}, [hostProjects, serverId]);
return allServerIds;
}, [allServerIds, effectiveHostFilter]);
useEffect(() => {
if (!serverId) {
if (!hostRegistryLoaded) {
return;
}
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
reconcileHostFilter(allServerIds);
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useStoreWithEqualityFn(
useSessionStore,
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
shallow,
);
const hostProjects = useHostProjects(serverIds);
const sidebarModel = useMemo(
() =>
buildSidebarWorkspacePlacementModel({
projects: hostProjects,
}),
[hostProjects],
);
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
const workspacePlacements =
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
const projectNamesByKey =
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES;
useEffect(() => {
if (!serverId) {
return;
}
const orderStore = useSidebarOrderStore.getState();
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey),
getWorkspaceOrder: (projectKey) =>
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER,
});
if (updates.projectOrder) {
orderStore.setProjectOrder(serverId, updates.projectOrder);
orderStore.setProjectOrder(updates.projectOrder);
}
for (const { projectKey, order } of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(serverId, projectKey, order);
orderStore.setWorkspaceOrder(projectKey, order);
}
}, [persistedProjectOrder, projects, serverId]);
}, [persistedProjectOrder, projects]);
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== "online") {
return;
}
const client = runtime.getClient(serverId);
if (!client) {
return;
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
if (!isActive) return;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
if (snapshot?.connectionStatus !== "online") continue;
const client = runtime.getClient(serverId);
if (!client) continue;
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const workspaces = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
next.set(workspace.id, workspace);
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
}, [connectionStatus, isActive, runtime, serverId]);
})();
}
}, [isActive, runtime, serverIds]);
const loadingState = deriveSidebarLoadingState({
isActive,
serverId,
hasHydratedWorkspaces,
serverIds,
hydratedServerIds,
hasProjects: projects.length > 0,
});
return {
workspacePlacements,
projects,
projectNamesByKey,
...loadingState,
refreshAll,
};
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
areStatusModeSessionsEqual,
selectStatusModeSessions,
type StatusModeSession,
} from "./use-status-mode-workspaces";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
function workspaceMap(): Map<string, WorkspaceDescriptor> {
return new Map();
}
function agentMap(): Map<string, Agent> {
return new Map();
}
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
return {
workspaces: input?.workspaces ?? workspaceMap(),
agents: input?.agents ?? agentMap(),
};
}
describe("status mode session selection", () => {
it("selects only sessions needed by visible placements", () => {
const hostA = statusSession();
const hostB = statusSession();
const unusedHost = statusSession();
expect(
selectStatusModeSessions(
{
"host-a": hostA,
"host-b": hostB,
unused: unusedHost,
},
["host-b", "missing", "host-a"],
),
).toEqual([
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
]);
});
it("keeps selector output equal when only wrapper objects change", () => {
const workspaces = workspaceMap();
const agents = agentMap();
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
"host-a",
]);
expect(previous).not.toBe(next);
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
});
it("detects workspace or agent map changes for selected hosts", () => {
const agents = agentMap();
const previous = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
["host-a"],
);
const next = selectStatusModeSessions(
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
["host-a"],
);
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
});
});
@@ -1,63 +1,100 @@
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
buildSidebarStatusWorkspacePlacements,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspacePlacement,
} from "./use-sidebar-workspaces-list";
const EMPTY_WORKSPACES: SidebarWorkspaceEntry[] = [];
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
typeof useCreateFlowStore.getState
>["pendingByDraftId"] = {};
export function useStatusModeWorkspaceEntries(input: {
serverId: string | null;
projects: SidebarProjectEntry[];
}): SidebarWorkspaceEntry[] {
const workspaces = useSessionStore((state) =>
input.serverId ? state.sessions[input.serverId]?.workspaces : undefined,
interface StatusModeSessionSource {
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
}
export interface StatusModeSession {
serverId: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
}
export function selectStatusModeSessions(
sessions: Record<string, StatusModeSessionSource | undefined>,
serverIds: readonly string[],
): StatusModeSession[] {
const statusSessions: StatusModeSession[] = [];
for (const serverId of serverIds) {
const session = sessions[serverId];
if (!session) {
continue;
}
statusSessions.push({
serverId,
workspaces: session.workspaces,
agents: session.agents,
});
}
return statusSessions;
}
export function areStatusModeSessionsEqual(
left: readonly StatusModeSession[],
right: readonly StatusModeSession[],
): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
const leftSession = left[index];
const rightSession = right[index];
if (
!leftSession ||
!rightSession ||
leftSession.serverId !== rightSession.serverId ||
leftSession.workspaces !== rightSession.workspaces ||
leftSession.agents !== rightSession.agents
) {
return false;
}
}
return true;
}
export function useStatusModeWorkspacePlacements(input: {
placements: SidebarWorkspacePlacement[];
enabled?: boolean;
}): SidebarStatusWorkspacePlacement[] {
const isEnabled = input.enabled !== false && input.placements.length > 0;
const serverIds = useMemo(
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
[input.placements],
);
const agents = useSessionStore((state) =>
input.serverId ? state.sessions[input.serverId]?.agents : undefined,
const statusSessions = useStoreWithEqualityFn(
useSessionStore,
(state) =>
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
areStatusModeSessionsEqual,
);
const pendingCreateAttempts = useCreateFlowStore((state) =>
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
return useMemo(() => {
if (!input.serverId || input.projects.length === 0 || !workspaces) {
if (!isEnabled) {
return EMPTY_WORKSPACES;
}
const entries: SidebarWorkspaceEntry[] = [];
for (const placedWorkspace of input.projects.flatMap((project) => project.workspaces)) {
const workspace = workspaces.get(placedWorkspace.workspaceId);
entries.push(
workspace
? createSidebarWorkspaceEntry({
serverId: input.serverId,
workspace,
pendingCreateAttempts,
agents,
})
: placedWorkspace,
);
}
return entries;
}, [agents, input.projects, input.serverId, pendingCreateAttempts, workspaces]);
}
export function useProjectNamesMap(serverId: string | null): Map<string, string> {
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
return useMemo(() => {
const map = new Map<string, string>();
if (!serverId || !workspaces) return map;
for (const workspace of workspaces.values()) {
const key = workspace.project?.projectKey ?? workspace.projectId;
if (!map.has(key)) {
map.set(key, workspace.projectCustomName ?? workspace.projectDisplayName);
}
}
return map;
}, [serverId, workspaces]);
return buildSidebarStatusWorkspacePlacements({
placements: input.placements,
sessions: statusSessions,
pendingCreateAttempts,
});
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
}
+354
View File
@@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { isNative } from "@/constants/platform";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
export interface ChooseHostInput {
title?: string;
filter?: HostFilter;
onChooseHost: HostChoiceHandler;
onNoHosts?: () => void;
}
interface HostChoiceRequest {
id: number;
title: string;
serverIds: string[];
onChooseHost: HostChoiceHandler;
}
interface HostChooserState {
request: HostChoiceRequest | null;
open: (request: Omit<HostChoiceRequest, "id">) => void;
close: () => void;
}
let nextRequestId = 1;
const useHostChooserStore = create<HostChooserState>((set) => ({
request: null,
open: (request) => {
set({ request: { ...request, id: nextRequestId++ } });
},
close: () => set({ request: null }),
}));
function matchesHostQuery(host: HostProfile, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return (
host.label.toLowerCase().includes(normalized) ||
host.serverId.toLowerCase().includes(normalized)
);
}
export function useHostChooser() {
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
return useCallback(
(input: ChooseHostInput) => {
const availableHosts = orderHostsLocalFirst(hosts, localServerId).filter(
input.filter ?? (() => true),
);
if (availableHosts.length === 0) {
(input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))();
return false;
}
if (availableHosts.length === 1) {
void input.onChooseHost(availableHosts[0].serverId);
return true;
}
open({
title: input.title ?? "Choose host",
serverIds: availableHosts.map((host) => host.serverId),
onChooseHost: input.onChooseHost,
});
return true;
},
[hosts, localServerId, open],
);
}
function HostChooserRow({
host,
active,
onChooseHost,
}: {
host: HostProfile;
active: boolean;
onChooseHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => onChooseHost(host.serverId), [host.serverId, onChooseHost]);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(active || hovered || pressed) && styles.rowActive,
],
[active],
);
return (
<Pressable
accessibilityRole="button"
onPress={handlePress}
style={rowStyle}
testID={`host-chooser-row-${host.serverId}`}
>
<View style={styles.rowIconSlot}>
<HostStatusDotSlot serverId={host.serverId} />
</View>
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{host.label}
</Text>
<Text style={styles.rowSubtitle} numberOfLines={1}>
{host.serverId}
</Text>
</View>
<Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
export function HostChooserModal() {
const { theme } = useUnistyles();
const hosts = useHosts();
const request = useHostChooserStore((state) => state.request);
const close = useHostChooserStore((state) => state.close);
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const requestHosts = useMemo(() => {
if (!request) return [];
const hostByServerId = new Map(hosts.map((host) => [host.serverId, host] as const));
return request.serverIds.flatMap((serverId) => {
const host = hostByServerId.get(serverId);
return host ? [host] : [];
});
}, [hosts, request]);
const options = useMemo(
() => requestHosts.filter((host) => matchesHostQuery(host, query)),
[query, requestHosts],
);
const activeOptionIndex = options.length === 0 ? 0 : Math.min(activeIndex, options.length - 1);
useEffect(() => {
if (!request) return;
setQuery("");
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [request]);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(0);
}, []);
const chooseHost = useCallback(
(serverId: string) => {
const currentRequest = request;
close();
if (!currentRequest) return;
void currentRequest.onChooseHost(serverId);
},
[close, request],
);
useEffect(() => {
if (!request || isNative || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key === "Enter") {
const host = options[activeOptionIndex];
if (!host) return;
event.preventDefault();
chooseHost(host.serverId);
return;
}
if (options.length === 0) return;
event.preventDefault();
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
if (next < 0) {
setActiveIndex(options.length - 1);
return;
}
if (next >= options.length) {
setActiveIndex(0);
return;
}
setActiveIndex(next);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [activeOptionIndex, chooseHost, close, options, request]);
if (!request) return null;
return (
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={close} />
<View style={styles.panel}>
<View style={styles.header}>
<Text style={styles.title}>{request.title}</Text>
<TextInput
ref={inputRef}
value={query}
onChangeText={handleQueryChange}
placeholder="Search hosts..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
testID="host-chooser-search"
/>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
{options.map((host, index) => (
<HostChooserRow
key={host.serverId}
host={host}
active={index === activeOptionIndex}
onChooseHost={chooseHost}
/>
))}
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
width: 640,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
input: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
padding: 0,
outlineStyle: "none",
} as object,
results: {
maxHeight: 420,
},
resultsContent: {
paddingVertical: theme.spacing[2],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
minHeight: 56,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
rowActive: {
backgroundColor: theme.colors.surface1,
},
rowIconSlot: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
rowText: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
}));
+24
View File
@@ -1745,6 +1745,30 @@ export const ar: TranslationResources = {
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "منطقة الخطر",
remove: {
title: "إزالة المضيف",
+24
View File
@@ -1753,6 +1753,30 @@ export const en = {
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Danger zone",
remove: {
title: "Remove host",
+24
View File
@@ -1786,6 +1786,30 @@ export const es: TranslationResources = {
dialogFailedMessage:
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zona de peligro",
remove: {
title: "Eliminar host",
+24
View File
@@ -1791,6 +1791,30 @@ export const fr: TranslationResources = {
dialogFailedMessage:
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Zone dangereuse",
remove: {
title: "Supprimer l'hôte",
+25
View File
@@ -1765,6 +1765,31 @@ export const ja: TranslationResources = {
"再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。",
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
},
update: {
title: "デーモンを更新",
hint: "デーモンを最新バージョンに更新して再起動します",
confirm: "更新",
confirmTitle: "{{name}}を更新",
confirmMessage:
"これによりデーモンが最新バージョンに更新され、再起動されます。実行中のエージェントは一時的に中断されます。",
updating: "更新中...",
phaseStarting: "更新を準備中...",
phaseDownloading: "パッケージをダウンロード中...",
phaseInstalling: "インストール中...",
phaseComplete: "更新が完了しました。再起動中...",
unableToReconnectTitle: "再接続できません",
unableToReconnectMessage:
"{{name}}が更新後にオンラインに戻りませんでした。デーモンが再起動されたことを確認してください。",
unavailableTitle: "ホストが利用できません",
unavailableMessage:
"このホストは接続されていません。更新する前にオンラインになるまでお待ちください。",
offlineTitle: "ホストオフライン",
offlineMessage:
"このホストはオフラインです。オンラインに戻るまで待ってから更新してください。",
requestFailedTitle: "更新に失敗しました",
requestFailedMessage: "デーモンの更新に失敗しました: {{error}}",
dialogFailedMessage: "更新確認ダイアログを開けませんでした。",
},
dangerZone: "危険ゾーン",
remove: {
title: "ホストを削除",
+25
View File
@@ -1771,6 +1771,31 @@ export const ptBR: TranslationResources = {
"Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
},
update: {
title: "Atualizar daemon",
hint: "Atualiza o daemon para a versão mais recente e o reinicia",
confirm: "Atualizar",
confirmTitle: "Atualizar {{name}}",
confirmMessage:
"Isso vai atualizar o daemon para a versão mais recente e reiniciá-lo. Agentes em execução serão interrompidos brevemente.",
updating: "Atualizando...",
phaseStarting: "Preparando atualização...",
phaseDownloading: "Baixando pacotes...",
phaseInstalling: "Instalando...",
phaseComplete: "Atualização concluída, reiniciando...",
unableToReconnectTitle: "Não foi possível reconectar",
unableToReconnectMessage:
"{{name}} não voltou a ficar online após a atualização. Verifique se o daemon reiniciou.",
unavailableTitle: "Host indisponível",
unavailableMessage:
"Este host não está conectado. Aguarde até ele ficar online antes de atualizar.",
offlineTitle: "Host offline",
offlineMessage:
"Este host está offline. Aguarde até ele voltar a ficar online antes de atualizar.",
requestFailedTitle: "Falha na atualização",
requestFailedMessage: "Falha ao atualizar o daemon: {{error}}",
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação da atualização.",
},
dangerZone: "Zona de perigo",
remove: {
title: "Remover host",
+24
View File
@@ -1777,6 +1777,30 @@ export const ru: TranslationResources = {
"Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.",
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "Опасная зона",
remove: {
title: "Удалить хост",
+24
View File
@@ -1722,6 +1722,30 @@ export const zhCN: TranslationResources = {
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
dialogFailedMessage: "无法打开重启确认对话框。",
},
update: {
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
confirmTitle: "Update {{name}}",
confirmMessage:
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
updating: "Updating...",
phaseStarting: "Preparing update...",
phaseDownloading: "Downloading packages...",
phaseInstalling: "Installing...",
phaseComplete: "Update complete, restarting...",
unableToReconnectTitle: "Unable to reconnect",
unableToReconnectMessage:
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
unavailableTitle: "Host unavailable",
unavailableMessage:
"This host is not connected. Wait for it to come online before updating.",
offlineTitle: "Host offline",
offlineMessage: "This host is offline. Wait until it is back online before updating.",
requestFailedTitle: "Update failed",
requestFailedMessage: "Failed to update the daemon: {{error}}",
dialogFailedMessage: "Unable to open the update confirmation dialog.",
},
dangerZone: "危险区域",
remove: {
title: "移除 Host",
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveHostIndexRoute,
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRoute,
@@ -339,14 +338,14 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "render" });
});
it("sends removed host routes to a saved host instead of welcome", () => {
it("sends removed host routes to global project selection instead of welcome", () => {
expect(
resolveStartupRoute({
...baseHostInput,
route: { kind: "host", serverId: "server-removed" },
hosts: [{ serverId: "server-next" }],
}),
).toEqual({ kind: "redirect", href: "/h/server-next/open-project" });
).toEqual({ kind: "redirect", href: "/open-project" });
});
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
@@ -358,45 +357,3 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "redirect", href: "/welcome" });
});
});
describe("resolveHostIndexRoute", () => {
it("restores the remembered workspace when the host index is reopened for the same host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "unknown",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("opens project selection when the remembered workspace is proven missing", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual("/h/server-saved/open-project");
});
it("opens project selection when the remembered workspace belongs to another host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/open-project");
});
});
@@ -2,9 +2,9 @@ import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-worksp
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type { Href } from "expo-router";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
buildOpenProjectRoute,
} from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
@@ -174,20 +174,6 @@ export function resolveWorkspaceSelectionStatus(input: {
return input.hasHydratedWorkspaces ? "missing" : "unknown";
}
export function resolveHostIndexRoute(input: {
serverId: string;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
}): Href {
if (
input.workspaceSelection?.serverId === input.serverId &&
shouldRestoreWorkspaceSelection(input)
) {
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
}
return buildHostOpenProjectRoute(input.serverId);
}
function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === "";
}
@@ -244,7 +230,7 @@ function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): Star
const fallbackServerId = input.hosts[0]?.serverId ?? null;
if (fallbackServerId) {
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
return { kind: "redirect", href: buildOpenProjectRoute() };
}
return { kind: "redirect", href: WELCOME_ROUTE };
+90 -14
View File
@@ -1,14 +1,16 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
export interface HostProjectListItem {
serverId: string;
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
canCreateWorktree: boolean;
}
export interface HostProjectRouteContext {
@@ -30,17 +32,15 @@ export function canCreateWorktreeForProjectKind(
}
export function buildHostProjectList(input: {
serverId: string;
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
serverId: input.serverId,
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
}));
}
@@ -51,13 +51,18 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
return null;
}
return {
serverId: route.serverId,
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKind: "git",
iconWorkingDir,
hosts: [
{
serverId: route.serverId,
iconWorkingDir,
canCreateWorktree: true,
},
],
workspaceKeys: [],
canCreateWorktree: true,
};
}
@@ -73,29 +78,100 @@ export function hostProjectFromWorkspace(input: {
if (!projectKey || !iconWorkingDir) {
return null;
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
serverId: input.serverId,
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKind: input.workspace.projectKind,
iconWorkingDir,
workspaceKeys: [input.workspace.id],
canCreateWorktree: canCreateWorktreeForProjectKind(input.workspace.projectKind),
hosts: [
{
serverId: input.serverId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
};
}
function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree);
}
export function getHostProjectSourceDirectory(
project: HostProjectListItem,
serverId: string,
): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
}
export function canCreateWorkspaceForHostProject(input: {
project: HostProjectListItem;
serverId: string;
allowAllProjects: boolean;
}): boolean {
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
if (!host) {
return false;
}
return input.allowAllProjects || host.canCreateWorktree;
}
export function filterWorkspaceProjectsForHost(input: {
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem[] {
return input.projects.filter((project) =>
canCreateWorkspaceForHostProject({
project,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
}),
);
}
export function resolveInitialWorkspaceProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem | null {
const candidates = [input.routeProject, input.lastActiveProject];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
})
) {
return hydratedProject;
}
}
return input.projects[0] ?? null;
}
export function resolveInitialWorktreeProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
}): HostProjectListItem | null {
if (input.routeProject?.canCreateWorktree) {
if (input.routeProject && projectCanCreateWorktree(input.routeProject)) {
return input.routeProject;
}
if (input.lastActiveProject?.canCreateWorktree) {
if (input.lastActiveProject && projectCanCreateWorktree(input.lastActiveProject)) {
return input.lastActiveProject;
}
return input.projects.find((project) => project.canCreateWorktree) ?? null;
return input.projects.find((project) => projectCanCreateWorktree(project)) ?? null;
}
export function resolveSelectedHostProject(input: {
+124 -17
View File
@@ -3,9 +3,13 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
@@ -17,19 +21,31 @@ function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceS
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: input.projectKind !== "directory",
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
serverId: input.serverId ?? "host-a",
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
canCreateWorktree: input.canCreateWorktree ?? true,
};
}
@@ -71,7 +87,6 @@ describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
serverId: "host-a",
projects: [
structureProject({
projectKey: "project-b",
@@ -79,6 +94,7 @@ describe("host project list", () => {
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
}),
structureProject({
projectKey: "project-a",
@@ -86,27 +102,26 @@ describe("host project list", () => {
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
}),
],
}),
).toEqual([
{
serverId: "host-a",
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
workspaceKeys: ["workspace-b"],
canCreateWorktree: false,
},
{
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
},
]);
});
@@ -129,14 +144,24 @@ describe("host project list", () => {
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: { ...routeProject, projectKind: "directory", canCreateWorktree: false },
routeProject: {
...routeProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
},
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
canCreateWorktree: false,
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
},
projects: [
{ ...firstProject, projectKind: "directory", canCreateWorktree: false },
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
@@ -148,11 +173,92 @@ describe("host project list", () => {
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [{ ...firstProject, projectKind: "directory", canCreateWorktree: false }],
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
],
}),
).toBeNull();
});
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
});
expect(
filterWorkspaceProjectsForHost({
projects: [hostAOnly, hostBOnly],
serverId: "host-b",
allowAllProjects: false,
}).map((project) => project.projectKey),
).toEqual(["host-b-project"]);
});
it("allows directory projects only when workspace multiplicity is supported", () => {
const directoryProject = hostProject({
projectKey: "directory-project",
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
});
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: false,
}),
).toBe(false);
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: true,
}),
).toBe(true);
});
it("falls back when the route project is not available on the selected host", () => {
const selectedHostProject = hostProject({
projectKey: "selected-host-project",
hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
],
});
expect(
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject: null,
projects: [selectedHostProject],
serverId: "host-b",
allowAllProjects: false,
}),
).toEqual(selectedHostProject);
});
it("resolves the selected host project source directory", () => {
const project = hostProject({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
});
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
@@ -173,26 +279,24 @@ describe("host project list", () => {
sourceDirectory: "/repo/a",
}),
).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: [],
canCreateWorktree: true,
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
serverId: "host-a",
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
canCreateWorktree: true,
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["host-a:workspace-a"],
});
expect(
@@ -200,6 +304,9 @@ describe("host project list", () => {
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({ projectKind: "directory", canCreateWorktree: false });
).toMatchObject({
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
});
});
});
+9 -5
View File
@@ -4,21 +4,25 @@ import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-
export {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
type HostProjectRouteContext,
} from "@/projects/host-project-model";
export function useHostProjects(serverId: string | null): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverId);
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverIds);
return useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
if (workspaceStructure.projects.length === 0) {
return [];
}
return buildHostProjectList({ serverId, projects: workspaceStructure.projects });
}, [serverId, workspaceStructure.projects]);
return buildHostProjectList({ projects: workspaceStructure.projects });
}, [workspaceStructure.projects]);
}
+9 -12
View File
@@ -4,7 +4,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
export interface ProjectIconRequestTarget {
serverId?: string | null;
serverId: string;
projectKey: string;
iconWorkingDir: string;
}
@@ -21,24 +21,19 @@ function useStableProjectIconData(
}
export function useProjectIconDataByProjectKey(input: {
serverId: string | null;
projects: readonly ProjectIconRequestTarget[];
}): Map<string, string | null> {
const projectIconRequests = useMemo(() => {
const unique = new Map<string, { serverId: string; cwd: string }>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
if (!serverId) {
continue;
}
const cwd = project.iconWorkingDir.trim();
if (!cwd) {
continue;
}
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
unique.set(`${project.serverId}:${cwd}`, { serverId: project.serverId, cwd });
}
return Array.from(unique.values());
}, [input.projects, input.serverId]);
}, [input.projects]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -83,15 +78,17 @@ export function useProjectIconDataByProjectKey(input: {
const byProject = new Map<string, string | null>();
for (const project of input.projects) {
const serverId = project.serverId || input.serverId;
const cwd = project.iconWorkingDir.trim();
if (!cwd || !serverId) {
if (!cwd) {
byProject.set(project.projectKey, null);
continue;
}
byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null);
byProject.set(
project.projectKey,
iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null,
);
}
return byProject;
}, [input.projects, input.serverId, projectIconData, projectIconRequests]);
}, [input.projects, projectIconData, projectIconRequests]);
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
getProjectRemoveReadiness,
removeProjectFromHosts,
type ProjectRemoveProject,
} from "./project-remove";
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
};
function createProjectRemoveClient() {
const removedProjectKeys: string[] = [];
return {
removedProjectKeys,
client: {
async removeProject(projectKey: string): Promise<{ removedWorkspaceIds: string[] }> {
removedProjectKeys.push(projectKey);
return { removedWorkspaceIds: [] };
},
},
};
}
describe("project remove policy", () => {
it("requires every host to support project removal", () => {
const readiness = getProjectRemoveReadiness({
project,
supportsProjectRemove: (serverId) => serverId === "host-a",
});
expect(readiness).toEqual({
kind: "needs_host_update",
serverIds: ["host-b"],
});
});
it("removes the project from every participating host", async () => {
const hostA = createProjectRemoveClient();
const hostB = createProjectRemoveClient();
const readiness = getProjectRemoveReadiness({
project,
supportsProjectRemove: () => true,
});
expect(readiness).toEqual({
kind: "ready",
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
});
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
getClient: (serverId) => {
if (serverId === "host-a") return hostA.client;
if (serverId === "host-b") return hostB.client;
return null;
},
});
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
});
it("reports disconnected hosts before sending any remove request", async () => {
const hostA = createProjectRemoveClient();
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
});
expect(outcome).toEqual({ kind: "host_disconnected", serverIds: ["host-b"] });
expect(hostA.removedProjectKeys).toEqual([]);
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { selectHostFeature } from "@/runtime/host-features";
interface ProjectRemoveHost {
serverId: string;
}
export interface ProjectRemoveProject {
projectKey: string;
hosts: readonly ProjectRemoveHost[];
}
export interface ProjectRemoveTarget {
serverId: string;
}
export type ProjectRemoveReadiness =
| { kind: "ready"; targets: ProjectRemoveTarget[] }
| { kind: "needs_host_update"; serverIds: string[] };
export type ProjectRemoveOutcome =
| { kind: "removed"; serverIds: string[] }
| { kind: "host_disconnected"; serverIds: string[] }
| { kind: "failed"; serverIds: string[] };
type ProjectRemoveClient = Pick<DaemonClient, "removeProject">;
export function getProjectRemoveReadiness(input: {
project: ProjectRemoveProject;
supportsProjectRemove: (serverId: string) => boolean;
}): ProjectRemoveReadiness {
const unsupportedServerIds: string[] = [];
const targets: ProjectRemoveTarget[] = [];
for (const host of input.project.hosts) {
if (!input.supportsProjectRemove(host.serverId)) {
unsupportedServerIds.push(host.serverId);
continue;
}
targets.push({ serverId: host.serverId });
}
if (unsupportedServerIds.length > 0) {
return { kind: "needs_host_update", serverIds: unsupportedServerIds };
}
return { kind: "ready", targets };
}
export function getCurrentProjectRemoveReadiness(
project: ProjectRemoveProject,
): ProjectRemoveReadiness {
const sessionState = useSessionStore.getState();
return getProjectRemoveReadiness({
project,
supportsProjectRemove: (serverId) => selectHostFeature(sessionState, serverId, "projectRemove"),
});
}
export async function removeProjectFromHosts(input: {
projectKey: string;
targets: readonly ProjectRemoveTarget[];
getClient: (serverId: string) => ProjectRemoveClient | null;
}): Promise<ProjectRemoveOutcome> {
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
const disconnectedServerIds: string[] = [];
for (const target of input.targets) {
const client = input.getClient(target.serverId);
if (!client) {
disconnectedServerIds.push(target.serverId);
continue;
}
clients.push({ serverId: target.serverId, client });
}
if (disconnectedServerIds.length > 0) {
return { kind: "host_disconnected", serverIds: disconnectedServerIds };
}
const results = await Promise.allSettled(
clients.map(async ({ client }) => {
await client.removeProject(input.projectKey);
}),
);
const failedServerIds: string[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
const failed = clients[index];
if (failed) {
failedServerIds.push(failed.serverId);
}
}
}
if (failedServerIds.length > 0) {
return { kind: "failed", serverIds: failedServerIds };
}
return { kind: "removed", serverIds: clients.map((entry) => entry.serverId) };
}
+104 -49
View File
@@ -1,11 +1,18 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export interface WorkspaceStructureHostPlacement {
serverId: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
@@ -13,8 +20,6 @@ export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
@@ -42,68 +47,118 @@ function compareWorkspaceStructureProjects(
});
}
export function buildWorkspaceStructureProjects(input: {
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
return projectKind === "git";
}
interface WorkspaceStructureSession {
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}): WorkspaceStructureProject[] {
const workspaceList = Array.from(input.workspaces);
const emptyProjectList = Array.from(input.emptyProjects ?? []);
if (workspaceList.length === 0 && emptyProjectList.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
}
export function buildWorkspaceStructureProjects(input: {
sessions: WorkspaceStructureSession[];
}): WorkspaceStructureProject[] {
const byProject = new Map<
string,
WorkspaceStructureProject & {
{
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const emptyProject of emptyProjectList) {
byProject.set(emptyProject.projectId, {
projectKey: emptyProject.projectId,
projectName:
emptyProject.projectDisplayName || projectDisplayNameFromProjectId(emptyProject.projectId),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
workspaceKeys: [],
workspaces: [],
});
}
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
const placement = {
serverId: session.serverId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
const existing = byProject.get(projectKey);
for (const workspace of workspaceList) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
emptyProject.projectCustomName ??
emptyProject.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
hosts: new Map([[session.serverId, placement]]),
workspaces: [],
});
continue;
}
existing.hosts.set(session.serverId, placement);
}
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
hosts: new Map([
[
session.serverId,
{
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
],
]),
workspaces: [
{
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
},
],
});
continue;
}
existing.hosts.set(session.serverId, {
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${input.serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
existing.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
});
}
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return Object.assign({}, project, {
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
});
},
);
const projects: WorkspaceStructureProject[] = [];
for (const raw of byProject.values()) {
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
projects.push({
projectKey: raw.projectKey,
projectName: raw.projectName,
projectKind: raw.projectKind,
iconWorkingDir: raw.iconWorkingDir,
hosts: Array.from(raw.hosts.values()),
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
});
}
projects.sort(compareWorkspaceStructureProjects);
return projects;
+28
View File
@@ -0,0 +1,28 @@
import type { DaemonServerInfo } from "@/stores/session-store";
export type HostFeatureName = keyof NonNullable<DaemonServerInfo["features"]>;
export interface HostFeatureSessionState {
sessions: Record<
string,
| {
serverInfo: DaemonServerInfo | null;
}
| undefined
>;
}
export function hostSupportsFeature(
serverInfo: DaemonServerInfo | null | undefined,
feature: HostFeatureName,
): boolean {
return serverInfo?.features?.[feature] === true;
}
export function selectHostFeature(
state: HostFeatureSessionState,
serverId: string,
feature: HostFeatureName,
): boolean {
return hostSupportsFeature(state.sessions[serverId]?.serverInfo, feature);
}
@@ -11,7 +11,9 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
import {
HostRuntimeController,
HostRuntimeStore,
readInitialDaemonConnectionHint,
type HostRuntimeControllerDeps,
type HostRuntimeStorage,
} from "./host-runtime";
class FakeDaemonClient {
@@ -122,6 +124,8 @@ class FakeDaemonClient {
afterEach(() => {
vi.useRealTimers();
delete (globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__;
delete (globalThis as { window?: unknown }).window;
});
function useHostRuntimeClock(): void {
@@ -312,6 +316,32 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
return client;
}
function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): HostRuntimeStorage {
const values = new Map(Object.entries(entries));
return {
getItem: async (key) => values.get(key) ?? null,
setItem: async (key, value) => {
values.set(key, value);
},
};
}
function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean): Promise<void> {
if (predicate()) {
return Promise.resolve();
}
return new Promise((resolve) => {
let unsubscribe = (): void => {};
unsubscribe = store.subscribeHostList(() => {
if (!predicate()) {
return;
}
unsubscribe();
resolve();
});
});
}
describe("HostRuntimeController", () => {
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
const oldRelay: HostConnection = {
@@ -1171,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: [
@@ -1885,3 +1957,111 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
});
describe("readInitialDaemonConnectionHint", () => {
it("returns null when no hint is present", () => {
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
});
it("parses a valid listen-only hint", () => {
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
listen: "localhost:6767",
};
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({
listen: "localhost:6767",
useTls: false,
});
});
it("preserves useTls when explicitly true", () => {
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
listen: "paseo.example.com:443",
useTls: true,
};
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({
listen: "paseo.example.com:443",
useTls: true,
});
});
it("ignores invalid shapes", () => {
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = "localhost:6767";
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
(globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__ = {
useTls: true,
};
expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull();
});
});
describe("HostRuntimeStore initial connection hint bootstrap", () => {
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ connection }) => {
if (connection.type === "directTcp") {
seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls });
}
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: "srv_hint",
hostname: "hint host",
};
},
getClientId: async () => "cid_test_runtime",
readInitialConnectionHint: () => ({
listen: "daemon-origin:6767",
useTls: true,
}),
},
storage: createMemoryHostRuntimeStorage(),
});
const hostAdded = onceHostListMatches(store, () => store.getHosts().length > 0);
store.boot();
await hostAdded;
expect(seenProbes).toContainEqual({ endpoint: "daemon-origin:6767", useTls: true });
const host = store.getHosts()[0];
expect(host?.serverId).toBe("srv_hint");
expect(host?.connections).toEqual(
expect.arrayContaining([
expect.objectContaining({ endpoint: "daemon-origin:6767", useTls: true }),
]),
);
store.syncHosts([]);
});
it("does not infer window.location.host when no explicit hint is present", async () => {
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
const firstProbe = createDeferred<void>();
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ connection }) => {
if (connection.type === "directTcp") {
seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls });
}
firstProbe.resolve();
throw new Error("probe unavailable");
},
getClientId: async () => "cid_test_runtime",
readInitialConnectionHint: () => null,
},
storage: createMemoryHostRuntimeStorage(),
});
(globalThis as { window?: unknown }).window = {
location: { host: "metro-host:8081", protocol: "http:" },
};
store.boot();
await firstProbe.promise;
expect(seenProbes).not.toContainEqual(expect.objectContaining({ endpoint: "metro-host:8081" }));
expect(store.getHosts()).toHaveLength(0);
});
});
+108 -4
View File
@@ -25,6 +25,7 @@ import {
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { isWeb } from "@/constants/platform";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { getOrCreateClientId } from "@/utils/client-id";
import {
@@ -136,6 +137,12 @@ export interface HostRuntimeControllerDeps {
hostname: string | null;
}>;
getClientId: () => Promise<string>;
readInitialConnectionHint?: () => InitialDaemonConnectionHint | null;
}
export interface HostRuntimeStorage {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
}
export interface HostRuntimeStartOptions {
@@ -1273,6 +1280,37 @@ const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const LOCALHOST_FALLBACK_ENDPOINT = "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const E2E_STORAGE_KEY = "@paseo:e2e";
const INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY = "__PASEO_INITIAL_DAEMON_CONNECTION__";
export interface InitialDaemonConnectionHint {
listen: string;
useTls?: boolean;
}
function isInitialConnectionHintRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function readInitialDaemonConnectionHint(input?: {
isWebRuntime?: boolean;
}): InitialDaemonConnectionHint | null {
const isWebRuntime = input?.isWebRuntime ?? isWeb;
if (!isWebRuntime || typeof globalThis === "undefined") {
return null;
}
const value = (globalThis as Record<string, unknown>)[INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY];
if (!isInitialConnectionHintRecord(value)) {
return null;
}
const listen = typeof value.listen === "string" ? value.listen.trim() : "";
if (!listen) {
return null;
}
return {
listen,
useTls: value.useTls === true,
};
}
function readConfiguredLocalDaemonOverride(): string | null {
const value = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim();
@@ -1307,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;
@@ -1314,9 +1353,11 @@ export class HostRuntimeStore {
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootStarted = false;
private storage: HostRuntimeStorage;
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage }) {
this.deps = input?.deps ?? createDefaultDeps();
this.storage = input?.storage ?? AsyncStorage;
}
// --- Host registry ---
@@ -1340,6 +1381,10 @@ export class HostRuntimeStore {
return this.hostListVersion;
}
isHostRegistryLoaded(): boolean {
return this.hostRegistryLoaded;
}
boot(): void {
if (this.bootStarted) {
return;
@@ -1351,10 +1396,11 @@ export class HostRuntimeStore {
private async runBoot(): Promise<void> {
const override = readConfiguredLocalDaemonOverride();
await this.loadFromStorage();
this.markHostRegistryLoaded();
let isE2E: string | null = null;
try {
isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
isE2E = await this.storage.getItem(E2E_STORAGE_KEY);
} catch {
return;
}
@@ -1366,6 +1412,16 @@ export class HostRuntimeStore {
return;
}
const initialHint = this.deps.readInitialConnectionHint
? this.deps.readInitialConnectionHint()
: readInitialDaemonConnectionHint();
if (initialHint) {
const bootstrapped = await this.bootstrapInitialConnectionHint(initialHint);
if (bootstrapped) {
return;
}
}
if (override) {
this.bootstrapConfiguredOverride(override);
} else {
@@ -1376,7 +1432,7 @@ export class HostRuntimeStore {
private async loadFromStorage(): Promise<void> {
let shouldPersistHosts = false;
try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY);
if (!stored) {
return;
}
@@ -1404,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)) {
@@ -1469,6 +1533,37 @@ export class HostRuntimeStore {
}
}
private async bootstrapInitialConnectionHint(
hint: InitialDaemonConnectionHint,
): Promise<boolean> {
const connection = connectionFromListen(hint.listen);
if (!connection) {
return false;
}
const connectionWithHint: HostConnection =
connection.type === "directTcp"
? { ...connection, useTls: hint.useTls ?? connection.useTls ?? false }
: connection;
if (registryHasConnection(this.hosts, connectionWithHint)) {
return true;
}
try {
await this.probeAndUpsertConnection({
connection: connectionWithHint,
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
return true;
} catch (error) {
console.warn("[HostRuntime] initial connection hint probe failed", {
listen: hint.listen,
useTls: hint.useTls,
error,
});
return false;
}
}
reconcileServerId(oldServerId: string, newServerId: string): void {
if (oldServerId === newServerId) {
return;
@@ -1746,7 +1841,7 @@ export class HostRuntimeStore {
private async persistHosts(): Promise<void> {
try {
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
} catch (error) {
console.error("[HostRuntime] Failed to persist host registry", error);
}
@@ -2154,6 +2249,15 @@ export function useHostRegistryStatus(): HostRegistryStatus {
);
}
export function useHostRegistryLoaded(): boolean {
const store = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribeHostList(onStoreChange),
() => store.isHostRegistryLoaded(),
() => store.isHostRegistryLoaded(),
);
}
export interface HostMutations {
upsertDirectConnection: (input: {
serverId: string;
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { CommunityLinks } from "@/components/community-links";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useHostChooser } from "@/hosts/host-chooser";
import { usePanelStore } from "@/stores/panel-store";
import {
useIsCompactFormFactor,
@@ -16,7 +17,7 @@ import {
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
@@ -24,14 +25,16 @@ import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import type { Href } from "expo-router";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
export function OpenProjectScreen() {
const { t } = useTranslation();
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const client = useHostRuntimeClient(serverId);
const openProject = useOpenProject(serverId);
const openProjectPicker = useOpenProjectPicker();
const chooseHost = useHostChooser();
const localServerId = useLocalDaemonServerId();
const [importServerId, setImportServerId] = useState<string | null>(null);
const importClient = useHostRuntimeClient(importServerId ?? "");
const openImportedProject = useOpenProject(importServerId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
@@ -50,22 +53,38 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []);
const handleOpenImportSession = useCallback(() => {
chooseHost({
title: "Import from host",
onChooseHost: (serverId) => {
setImportServerId(serverId);
setIsImportSheetOpen(true);
},
});
}, [chooseHost]);
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
const handleImported = useCallback(
(agent: { id: string; cwd: string }) => {
if (!importServerId) return;
void (async () => {
await openProject(agent.cwd);
router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href);
const result = await openImportedProject(agent.cwd);
if (result.ok) {
router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href);
}
})();
},
[openProject, router, serverId],
[importServerId, openImportedProject, router],
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
}, [router, serverId]);
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
},
});
}, [chooseHost, router]);
return (
<View style={styles.container}>
@@ -98,7 +117,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{isLocalDaemon ? (
{localServerId ? (
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
@@ -119,8 +138,8 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
/>
<ImportSessionSheet
visible={isImportSheetOpen}
client={client}
serverId={serverId}
client={importClient}
serverId={importServerId}
onClose={handleCloseImportSession}
onImported={handleImported}
/>
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
@@ -216,7 +217,6 @@ function ProjectSettingsBody({
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId],
);
const projectIconDataByKey = useProjectIconDataByProjectKey({
serverId: null,
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
@@ -942,7 +942,7 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
}
return (
<View testID="host-indicator" style={styles.hostIndicator}>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
@@ -950,18 +950,6 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
);
}
function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
let color: string;
if (status === "online") color = theme.colors.palette.green[400];
else if (status === "connecting") color = theme.colors.palette.amber[500];
else color = theme.colors.palette.red[500];
const dotStyle = useMemo(() => [styles.hostStatusDot, { backgroundColor: color }], [color]);
return <View style={dotStyle} />;
}
interface HostPickerProps {
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry;
@@ -970,52 +958,42 @@ interface HostPickerProps {
function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const hostOptions = useMemo(
() => hosts.map((host) => ({ serverId: host.serverId, label: host.serverName })),
[hosts],
);
const handleOpen = useCallback(() => setOpen(true), []);
const hostOptionTestID = useCallback((serverId: string) => `host-picker-item-${serverId}`, []);
return (
<DropdownMenu>
<DropdownMenuTrigger
<SharedHostPicker
hosts={hostOptions}
value={selectedHost.serverId}
onSelect={onSelectHost}
open={open}
onOpenChange={setOpen}
anchorRef={triggerRef}
searchable={false}
title={t("settings.project.switchHost")}
desktopPlacement="bottom-start"
desktopMinWidth={240}
hostOptionTestID={hostOptionTestID}
>
<Pressable
ref={triggerRef}
accessibilityLabel={t("settings.project.switchHost")}
testID="host-picker"
style={styles.hostIndicator}
onPress={handleOpen}
>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
<ChevronDown size={ICON_SIZE} color={styles.chevronColor.color} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" minWidth={240}>
{hosts.map((host) => (
<HostPickerItem
key={host.serverId}
host={host}
isSelected={host.serverId === selectedHost.serverId}
onSelectHost={onSelectHost}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface HostPickerItemProps {
host: ProjectHostEntry;
isSelected: boolean;
onSelectHost: (serverId: string) => void;
}
function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps) {
const handleSelect = useCallback(
() => onSelectHost(host.serverId),
[host.serverId, onSelectHost],
);
return (
<DropdownMenuItem
testID={`host-picker-item-${host.serverId}`}
selected={isSelected}
onSelect={handleSelect}
>
{host.serverName}
</DropdownMenuItem>
</Pressable>
</SharedHostPicker>
);
}
@@ -1353,11 +1331,6 @@ const styles = StyleSheet.create((theme) => ({
alignSelf: "flex-start",
minWidth: 0,
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostName: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -36,7 +36,6 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
[projects],
);
const iconDataByProjectKey = useProjectIconDataByProjectKey({
serverId: null,
projects: iconTargets,
});
+160 -27
View File
@@ -1,64 +1,146 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import {
ALL_HOSTS_OPTION_ID,
getHostPickerLabel,
HostPicker,
} from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
import { useHosts } from "@/runtime/host-runtime";
import { type HostProfile } from "@/types/host-connection";
import { buildOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
export function SessionsScreen() {
const isFocused = useIsFocused();
if (!isFocused) {
return <View style={styles.container} />;
}
return <SessionsScreenContent serverId={serverId} />;
return <SessionsScreenContent />;
}
function SessionsScreenContent({ serverId }: { serverId: string }) {
function SessionsHostFilter({
hosts,
selectedHost,
onSelectHost,
}: {
hosts: HostProfile[];
selectedHost: string;
onSelectHost: (serverId: string) => void;
}) {
const { theme } = useUnistyles();
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
const selectedHostLabel = useMemo(
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
[hosts, selectedHost],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
return (
<HostPicker
hosts={hosts}
value={selectedHost}
onSelect={onSelectHost}
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
includeAllHost
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="sessions-host-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedHostLabel}`}
>
{selectedHost === ALL_HOSTS_OPTION_ID ? (
<Server size={14} color={theme.colors.foregroundMuted} />
) : (
<HostStatusDotSlot serverId={selectedHost} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</HostPicker>
);
}
function SessionsScreenContent() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } =
const hosts = useHosts();
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const historyServerId = selectedHost === ALL_HOSTS_OPTION_ID ? null : selectedHost;
const { agents, hasMore, isInitialLoad, isLoadingMore, isError, loadMore, refreshAll } =
useAgentHistory({
serverId,
serverId: historyServerId,
});
// Track user-initiated refresh to avoid showing spinner on background revalidation
useEffect(() => {
if (
selectedHost !== ALL_HOSTS_OPTION_ID &&
!hosts.some((host) => host.serverId === selectedHost)
) {
setSelectedHost(ALL_HOSTS_OPTION_ID);
}
}, [hosts, selectedHost]);
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
refreshAll();
void refreshAll().finally(() => setIsManualRefresh(false));
}, [refreshAll]);
// Reset manual refresh flag when revalidation completes
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh]);
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [agents]);
const emptyText =
selectedHost === ALL_HOSTS_OPTION_ID ? t("sessions.empty") : "No sessions for this host";
const showHostFilter = hosts.length > 1;
const showLoadError = isError && sortedAgents.length === 0;
const handleBack = useCallback(() => {
router.navigate(buildHostOpenProjectRoute(serverId));
}, [serverId]);
router.navigate(buildOpenProjectRoute());
}, []);
const listFooterComponent = useMemo(
() =>
hasMore ? (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? t("common.loading") : t("sessions.actions.loadMore")}
{isLoadingMore ? "Loading..." : t("sessions.actions.loadMore")}
</Button>
</View>
) : null,
@@ -68,27 +150,45 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
<View style={styles.filterContainer}>
<SessionsHostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
/>
</View>
) : null}
{isInitialLoad ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
{!isInitialLoad && sortedAgents.length === 0 ? (
{!isInitialLoad && showLoadError ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t("sessions.empty")}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
{t("common.actions.back")}
<Text style={styles.emptyText}>Unable to load sessions</Text>
<Button variant="ghost" onPress={handleRefresh}>
Try again
</Button>
</View>
) : null}
{!isInitialLoad && sortedAgents.length > 0 ? (
{!isInitialLoad && !showLoadError && sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{emptyText}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
</View>
) : null}
{!isInitialLoad && !showLoadError && sortedAgents.length > 0 ? (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
isRefreshing={isManualRefresh}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
showAttentionIndicator={false}
showHostColumn
/>
) : null}
</View>
@@ -100,6 +200,39 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
filterContainer: {
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[4],
},
filterTriggerWrap: {
alignSelf: "flex-start",
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
+46 -211
View File
@@ -1,13 +1,5 @@
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
Pressable,
@@ -26,7 +18,6 @@ import type { TFunction } from "i18next";
import { Buffer } from "buffer";
import {
ArrowLeft,
Monitor,
Settings,
Palette,
Server,
@@ -48,6 +39,7 @@ import { AppDiagnosticSheet } from "@/components/app-diagnostic-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { SettingsSection } from "@/screens/settings/settings-section";
@@ -61,14 +53,9 @@ import {
type ServiceUrlBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostRuntimeIsConnected,
useHosts,
} from "@/runtime/host-runtime";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import type { HostProfile } from "@/types/host-connection";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -82,7 +69,6 @@ import { Button } from "@/components/ui/button";
import { CommunityLinks } from "@/components/community-links";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
@@ -115,7 +101,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
buildHostOpenProjectRoute,
buildOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsSectionRoute,
@@ -774,48 +760,11 @@ function DesktopAppUpdateRow() {
// Sidebar
// ---------------------------------------------------------------------------
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => null,
);
}
/**
* Local daemon first, then remaining hosts in their existing order. Lets the
* picker and the active-host resolver agree on a stable "first" host.
* Local daemon first, then remaining hosts in their existing order.
*/
function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] {
return useMemo(() => {
if (!localServerId) {
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
return useMemo(() => orderHostsLocalFirst(hosts, localServerId), [hosts, localServerId]);
}
interface SidebarSectionButtonProps {
@@ -935,78 +884,9 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
);
}
// Sentinel option id for the "Add host" row appended to the picker list.
const ADD_HOST_OPTION_ID = "__add_host__";
interface HostPickerOptionProps {
serverId: string;
label: string;
isLocal: boolean;
selected: boolean;
active: boolean;
onPress: () => void;
}
function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
}: HostPickerOptionProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const leadingSlot = useMemo(
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
// The local host carries a "Local" marker; the active host is conveyed by the
// row's selected check, so both can coexist on one row.
const trailingSlot = useMemo(
() =>
isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
{t("settings.hostPicker.local")}
</Text>
) : undefined,
[isLocal, t],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={`settings-host-picker-item-${serverId}`}
/>
);
}
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const leadingSlot = useMemo(
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label={t("settings.addHost")}
leadingSlot={leadingSlot}
active={active}
onPress={onPress}
testID="settings-add-host"
/>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
localServerId: string | null;
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
}
@@ -1017,66 +897,19 @@ interface HostPickerProps {
* <Combobox>. The local host is listed first and tagged "Local"; an "Add host"
* row is always reachable from the list — even with a single host.
*/
function HostPicker({
activeServerId,
sortedHosts,
localServerId,
onSelectHost,
onAddHost,
}: HostPickerProps) {
const { theme } = useUnistyles();
function HostPicker({ activeServerId, sortedHosts, onSelectHost, onAddHost }: HostPickerProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const activeHost =
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
const options = useMemo<ComboboxOption[]>(() => {
const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label }));
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: t("settings.addHost") }];
}, [sortedHosts, t]);
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost();
return;
}
onSelectHost(id);
},
[onAddHost, onSelectHost],
);
const renderOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement => {
if (option.id === ADD_HOST_OPTION_ID) {
return <AddHostOption active={active} onPress={onPress} />;
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={localServerId !== null && option.id === localServerId}
selected={selected}
active={active}
onPress={onPress}
/>
);
},
[localServerId],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
const hostLocalMarkerTestID = useCallback(() => "settings-host-local-marker", []);
const hostOptionTestID = useCallback(
(serverId: string) => `settings-host-picker-item-${serverId}`,
[],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
sidebarStyles.pickerTrigger,
@@ -1086,7 +919,23 @@ function HostPicker({
);
return (
<>
<SharedHostPicker
hosts={sortedHosts}
value={activeServerId ?? ""}
onSelect={onSelectHost}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
searchable={false}
title={t("settings.hostPicker.switchHost")}
desktopMinWidth={240}
addHostTestID="settings-add-host"
hostLocalMarkerTestID={hostLocalMarkerTestID}
hostOptionTestID={hostOptionTestID}
>
<ComboboxTrigger
ref={triggerRef}
style={triggerStyle}
@@ -1095,24 +944,12 @@ function HostPicker({
accessibilityLabel={t("settings.hostPicker.switchHost")}
testID="settings-host-picker"
>
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{activeHost ? <HostStatusDotSlot serverId={activeHost.serverId} /> : null}
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
{activeHost?.label ?? t("settings.groups.host")}
</Text>
</ComboboxTrigger>
<Combobox
options={options}
value={activeServerId ?? ""}
onSelect={handleSelect}
renderOption={renderOption}
searchable={false}
title={t("settings.hostPicker.switchHost")}
desktopMinWidth={240}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
/>
</>
</SharedHostPicker>
);
}
@@ -1189,7 +1026,6 @@ function SettingsSidebar({
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
localServerId={localServerId}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
/>
@@ -1254,9 +1090,10 @@ function SettingsSidebar({
export interface SettingsScreenProps {
view: SettingsView;
openAddHostIntent?: string | null;
}
export default function SettingsScreen({ view }: SettingsScreenProps) {
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
const router = useRouter();
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -1267,6 +1104,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1281,8 +1119,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
view.kind === "host" ? view.serverId : null,
);
@@ -1381,6 +1217,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => {
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
return;
}
lastOpenedAddHostIntentRef.current = openAddHostIntent;
handleAddHost();
}, [handleAddHost, openAddHostIntent]);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
@@ -1488,12 +1332,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (navigateToLastWorkspace()) {
return;
}
if (anyOnlineServerId) {
router.replace(buildHostOpenProjectRoute(anyOnlineServerId));
return;
}
router.replace("/");
}, [anyOnlineServerId, router]);
router.replace(buildOpenProjectRoute());
}, [router]);
const detailHeader = ((): {
title: string;
@@ -1849,11 +1689,6 @@ const sidebarStyles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
flex: 1,
},
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
pickerTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -1,6 +1,7 @@
import {
ArrowDown,
ArrowUp,
ArrowUpToLine,
ChevronRight,
Globe,
Monitor,
@@ -53,6 +54,8 @@ import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
@@ -353,6 +356,8 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
);
@@ -709,6 +714,214 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
);
}
function UpdateDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
const [isUpdating, setIsUpdating] = useState(false);
const [progressPhase, setProgressPhase] = useState<string | null>(null);
const isMountedRef = useRef(true);
const unsubscribeRef = useRef<(() => void) | null>(null);
const daemonVersion = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
);
const supportsSelfUpdate = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
);
const appVersion = resolveAppVersion();
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
useEffect(() => {
return () => {
isMountedRef.current = false;
unsubscribeRef.current?.();
};
}, []);
const isHostConnected = useCallback(
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
[host.serverId, runtime],
);
const hasReconnectedAfter = useCallback(
(startGeneration: number | null) => {
const snapshot = runtime.getSnapshot(host.serverId);
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
},
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(
async (startGeneration: number | null) => {
const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
disconnectTimeoutMs,
);
}
const reconnected =
hasReconnectedAfter(startGeneration) ||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
if (isMountedRef.current) {
setIsUpdating(false);
setProgressPhase(null);
if (!reconnected) {
Alert.alert(
t("settings.host.daemon.update.unableToReconnectTitle"),
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
);
}
}
},
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
);
const handleUpdate = useCallback(() => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.update.unavailableTitle"),
t("settings.host.daemon.update.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.update.offlineTitle"),
t("settings.host.daemon.update.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
})
.then((confirmed) => {
if (!confirmed) return;
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
setIsUpdating(true);
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
const requestId = `settings_daemon_update_${host.serverId}`;
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
if (!isMountedRef.current) return;
const { phase } = message.payload;
if (phase === "starting")
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
else if (phase === "downloading")
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
else if (phase === "installing")
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
else if (phase === "complete")
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
});
unsubscribeRef.current = unsubscribe;
void daemonClient
.updateDaemon(requestId)
.then((response) => {
unsubscribeRef.current = null;
unsubscribe();
if (!response.success) {
if (!isMountedRef.current) return undefined;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: response.error ?? "Unknown error",
}),
);
return undefined;
}
// Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startGeneration);
return undefined;
})
.catch((error) => {
unsubscribeRef.current = null;
unsubscribe();
console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
if (!isMountedRef.current) return;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
});
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.dialogFailedMessage"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
const updateIcon = useMemo(
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
// Don't show if the daemon doesn't support self-update or versions match
if (!supportsSelfUpdate || !hasVersionMismatch) {
return null;
}
const buttonLabel = isUpdating
? (progressPhase ?? t("settings.host.daemon.update.updating"))
: t("settings.host.daemon.update.confirm");
return (
<View style={settingsStyles.card} testID="host-page-update-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={updateIcon}
onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button"
>
{buttonLabel}
</Button>
</View>
</View>
);
}
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -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"]);

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