Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 5b3ec572cb feat: service proxy, health monitor, hover card, and homepage section
Built-in service proxy with branch-based URLs, service health
monitoring, workspace hover card with service status, and
"Forget about ports" homepage section.
2026-04-01 10:31:15 +07:00
Mohamed Boudra 806079c7ad Emit running state when workspace setup begins 2026-03-31 16:22:51 +07:00
Mohamed Boudra 1f6ffa9e0e Add workspace setup streaming and setup tab 2026-03-31 09:09:38 +07:00
Mohamed Boudra 266d042528 feat(server): built-in service proxy — absorb portless into daemon
Services defined in paseo.json get reverse-proxied through the daemon
via hostname-based routing on *.localhost. Each service receives $PORT,
$HOST, and $PASEO_SERVICE_URL env vars, and is accessible at
{service}.localhost:6767 (main) or {branch}.{service}.localhost:6767
(worktrees).
2026-03-30 21:41:03 +07:00
130 changed files with 6523 additions and 3355 deletions
-113
View File
@@ -149,119 +149,6 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload manifest artifact
if: env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
-26
View File
@@ -1,31 +1,5 @@
# Changelog
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
- File diff tracking in workspaces now works correctly on Linux.
- iPad layout now renders correctly in desktop mode.
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-wLXSLXXzB1rwuQXPRFFt4EKZsFydN3HTR99ZJqBdXxk=";
npmDepsHash = "sha256-Cz3xidzBIWER4ktn3wWzT9PDm9PnipVA7XnsTQC440U=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.40",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.40",
"version": "0.1.38",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34962,16 +34962,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.40",
"version": "0.1.38",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/expo-two-way-audio": "0.1.38",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/server": "0.1.38",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35088,11 +35088,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.40",
"version": "0.1.38",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/relay": "0.1.38",
"@getpaseo/server": "0.1.38",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35133,11 +35133,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.40",
"version": "0.1.38",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/cli": "0.1.38",
"@getpaseo/server": "0.1.38",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35171,7 +35171,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.40",
"version": "0.1.38",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35372,7 +35372,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.40",
"version": "0.1.38",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35398,7 +35398,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.40",
"version": "0.1.38",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35414,13 +35414,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.40",
"version": "0.1.38",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/relay": "0.1.40",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/relay": "0.1.38",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35818,7 +35818,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.40",
"version": "0.1.38",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.40",
"version": "0.1.38",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
-106
View File
@@ -1,106 +0,0 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
connectArchiveTabDaemonClient,
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
await client?.close();
await tempRepo?.cleanup();
});
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
});
-257
View File
@@ -1,257 +0,0 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
title: string;
cwd: string;
};
type ArchiveTabDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => ArchiveTabDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "codex",
model: "gpt-5.1-codex-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
}
return {
id: created.id,
title: input.title,
cwd: input.cwd,
};
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon, preferences, seedNonce },
);
await page.goto("/");
}
export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.goto("/");
}
export async function openWorkspaceWithAgents(
page: Page,
agents: [ArchiveTabAgent, ArchiveTabAgent],
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function expectWorkspaceArchiveOutcome(
page: Page,
input: { archivedAgentId: string; survivingAgentId: string },
): Promise<void> {
await expectWorkspaceTabHidden(page, input.archivedAgentId);
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
await waitForWorkspaceTabsVisible(page);
}
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 expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
function getSessionRowByTitle(page: Page, title: string) {
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
}
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
): Promise<void> {
const row = getSessionRowByTitle(page, input.title);
await expect(row).toBeVisible({ timeout: 30_000 });
const box = await row.boundingBox();
if (!box) {
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
await expectSessionRowArchived(page, input.title);
}
+159
View File
@@ -0,0 +1,159 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(
cwd: string,
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
createPaseoWorktree(
input: { cwd: string; worktreeSlug?: string },
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
listTerminals(
cwd?: string,
): Promise<{ cwd?: string; terminals: Array<{ id: string; name: string }>; requestId: string }>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
};
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => WorkspaceSetupDaemonClient;
};
return mod.DaemonClient;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `workspace-setup-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
}
export function projectNameFromPath(repoPath: string): string {
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
await gotoAppShell(page);
await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 });
}
function createWorkspaceButton(page: Page, repoPath: string) {
return page.getByRole("button", {
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
});
}
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
await page.getByTestId(`sidebar-project-row-${repoPath}`).hover();
}
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
await revealWorkspaceButton(page, repoPath);
await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 });
await createWorkspaceButton(page, repoPath).click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
export async function expectSetupPanel(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
}
export async function expectSetupStatus(
page: Page,
status: "Running" | "Completed" | "Failed",
): Promise<void> {
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
timeout: 30_000,
});
}
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
timeout: 30_000,
});
}
export async function expectNoSetupMessage(page: Page): Promise<void> {
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
export async function createWorkspaceThroughDaemon(
client: WorkspaceSetupDaemonClient,
input: { cwd: string; worktreeSlug: string },
): Promise<{ id: string; name: string }> {
const result = await client.createPaseoWorktree(input);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
}
return result.workspace;
}
export async function waitForWorkspaceSetupProgress(
client: WorkspaceSetupDaemonClient,
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
timeoutMs = 30_000,
): Promise<WorkspaceSetupProgressPayload> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
}, timeoutMs);
const unsubscribe = client.subscribeRawMessages((message) => {
if (message.type !== "workspace_setup_progress") {
return;
}
if (!predicate(message.payload)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(message.payload);
});
});
}
+22 -1
View File
@@ -10,7 +10,11 @@ type TempRepo = {
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
options?: {
withRemote?: boolean;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
@@ -22,7 +26,24 @@ export const createTempGitRepo = async (
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
if (options?.paseoConfig) {
await writeFile(
path.join(repoPath, "paseo.json"),
JSON.stringify(options.paseoConfig, null, 2),
);
}
for (const file of options?.files ?? []) {
const filePath = path.join(repoPath, file.path);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, file.content);
}
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
if (options?.paseoConfig) {
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
}
for (const file of options?.files ?? []) {
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) {
@@ -0,0 +1,134 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import {
connectWorkspaceSetupClient,
createWorkspaceFromSidebar,
expectSetupPanel,
expectSetupStatus,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
import type { Page } from "@playwright/test";
// ---------------------------------------------------------------------------
// Composable helpers
// ---------------------------------------------------------------------------
/** Waits for the globe icon to appear on a workspace row (proves services are running). */
async function expectGlobeIcon(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 });
}
/** Hovers the workspace row (by visible name) and waits for the hover card to appear. */
async function expectHoverCard(page: Page, workspaceName: string): Promise<void> {
const row = page.getByRole("button", { name: workspaceName }).first();
await row.hover();
await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 });
}
/** Asserts that a service row with the given name exists in the hover card. */
async function expectServiceInCard(page: Page, serviceName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(card.getByTestId(`hover-card-service-${serviceName}`)).toBeVisible({
timeout: 10_000,
});
}
/** Asserts the service status dot indicates "running". */
async function expectServiceRunning(page: Page, serviceName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(
card.getByTestId(`hover-card-service-status-${serviceName}`),
).toHaveAttribute("aria-label", "Running", { timeout: 10_000 });
}
/** Asserts the hover card contains the workspace name. */
async function expectWorkspaceNameInCard(page: Page, name: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(card.getByTestId("hover-card-workspace-name")).toContainText(name, {
timeout: 10_000,
});
}
/** Moves the mouse away from the sidebar and asserts the hover card disappears. */
async function expectHoverCardDismissed(page: Page): Promise<void> {
// Move mouse to the center of the viewport (away from sidebar)
const viewport = page.viewportSize();
await page.mouse.move((viewport?.width ?? 1280) / 2, (viewport?.height ?? 720) / 2);
await expect(page.getByTestId("workspace-hover-card")).not.toBeVisible({ timeout: 10_000 });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Workspace hover card", () => {
test("shows hover card with services when hovering a workspace with running services", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("hovercard-svc-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
services: {
web: {
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await openHomeWithProject(page, repo.path);
await createWorkspaceFromSidebar(page, repo.path);
// Wait for setup to complete and workspace to be usable
await expectSetupPanel(page);
await expectSetupStatus(page, "Completed");
await waitForWorkspaceTabsVisible(page);
// Wait for the globe icon — proves services are running and client has the data
await expectGlobeIcon(page);
// Read the workspace name from the page header (the mnemonic name, e.g. "upbeat-crab")
const workspaceHeader = page.getByTestId("workspace-tabs-row");
await expect(workspaceHeader).toBeVisible({ timeout: 10_000 });
// The workspace name is the second workspace row button in the sidebar under the worktree project
// We can find it by looking for the workspace row that has the globe icon next to it
const globeIcon = page.getByTestId("workspace-globe-icon");
const workspaceRow = page.locator('[data-testid^="sidebar-workspace-row-"]', {
has: globeIcon,
});
const workspaceName =
(await workspaceRow.locator("button").first().innerText()).trim() || "workspace";
// Hover the workspace row — hover card should appear
await expectHoverCard(page, workspaceName);
// Assert the card shows the workspace name
await expectWorkspaceNameInCard(page, workspaceName);
// Assert the "web" service entry exists in the card
await expectServiceInCard(page, "web");
// Assert the status dot shows "running"
await expectServiceRunning(page, "web");
// Assert the service row is a link (has role="link")
const card = page.getByTestId("workspace-hover-card");
const serviceLink = card.getByRole("link", { name: "web service" });
await expect(serviceLink).toBeVisible({ timeout: 10_000 });
// Move mouse away — card should dismiss
await expectHoverCardDismissed(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -0,0 +1,292 @@
import { rm, writeFile } from "node:fs/promises";
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import {
connectWorkspaceSetupClient,
createWorkspaceFromSidebar,
createWorkspaceThroughDaemon,
expectSetupLogContains,
expectSetupPanel,
expectSetupStatus,
openHomeWithProject,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
} from "./helpers/workspace-setup";
test.describe("Workspace setup streaming", () => {
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-open-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await openHomeWithProject(page, repo.path);
await createWorkspaceFromSidebar(page, repo.path);
await expectSetupPanel(page);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
} finally {
await client.close();
await repo.cleanup();
}
});
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
const setupTriggerPath = `/tmp/setup-trigger-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-ui-flow-", {
paseoConfig: {
worktree: {
setup: [
`sh -c 'while [ ! -f "${setupTriggerPath}" ]; do sleep 0.2; done; echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'`,
],
},
},
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await openHomeWithProject(page, repo.path);
await createWorkspaceFromSidebar(page, repo.path);
await expectSetupPanel(page);
await expectSetupStatus(page, "Running");
await writeFile(setupTriggerPath, "start\n");
await expectSetupLogContains(page, "starting setup");
await expectSetupLogContains(page, "loading dependencies");
await expectSetupStatus(page, "Completed");
await expectSetupLogContains(page, "setup complete");
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
await explorerToggle.click();
}
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
timeout: 30_000,
});
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await rm(setupTriggerPath, { force: true });
await client.close();
await repo.cleanup();
}
});
test("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-success",
});
const initialPayload = await initialRunning;
const runningPayload = await runningWithOutput;
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
await repo.cleanup();
}
});
test("streams a failed setup snapshot when setup fails", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-failure-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-failure",
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
} finally {
await client.close();
await repo.cleanup();
}
});
test("emits a completed empty snapshot when no setup commands exist", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-none-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" &&
payload.detail.commands.length === 0 &&
payload.detail.log === "",
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-none",
});
const completedPayload = await completed;
expect(completedPayload.error).toBeNull();
expect(completedPayload.detail.commands).toEqual([]);
expect(completedPayload.detail.log).toBe("");
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches service terminals after setup completes", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
services: {
web: {
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await openHomeWithProject(page, repo.path);
await createWorkspaceFromSidebar(page, repo.path);
await expectSetupPanel(page);
await expectSetupStatus(page, "Completed");
await waitForWorkspaceTabsVisible(page);
// Wait for the service terminal tab to appear in the tabs bar
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]', {
hasText: "web",
});
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the service terminal tab
await terminalTab.click();
// Verify the terminal surface rendered
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({ timeout: 10_000 });
// Verify the terminal output contains "listening on" (xterm renders text in .xterm-rows)
await expect(page.locator(".xterm-rows").first()).toContainText("listening on", {
timeout: 30_000,
});
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches workspace services after setup completes", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-services-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
services: {
editor: {
command: "npm run dev",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-services",
});
await completed;
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspace.id);
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
})
.toMatchObject({
id: expect.any(String),
name: "editor",
});
} finally {
await client.close();
await repo.cleanup();
}
});
});
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.40",
"version": "0.1.38",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/expo-two-way-audio": "0.1.38",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/server": "0.1.38",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
+7 -9
View File
@@ -57,7 +57,7 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
@@ -103,7 +103,7 @@ function PushNotificationRouter() {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
if (getIsDesktop()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
@@ -342,12 +342,12 @@ function AppContainer({
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile: isCompactLayout,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
@@ -363,12 +363,10 @@ function AppContainer({
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>{children}</View>
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
@@ -377,7 +375,7 @@ function AppContainer({
</View>
);
if (!isCompactLayout) {
if (!isMobile) {
return content;
}
@@ -31,6 +31,9 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
if (openIntent.kind === "setup") {
return { kind: "setup", workspaceId: openIntent.workspaceId };
}
return { kind: "draft", draftId: openIntent.draftId };
}
+2 -2
View File
@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isElectronRuntime()) {
if (isDesktop()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);
@@ -1,31 +0,0 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
describe("isProviderModelsQueryLoading", () => {
it("does not treat a disabled pending query as loading", () => {
const queryClient = new QueryClient();
const observer = new QueryObserver(queryClient, {
queryKey: ["providerModels", "server-1", "__missing_provider__"],
enabled: false,
queryFn: async () => [],
});
const result = observer.getCurrentResult();
expect(result.isPending).toBe(true);
expect(result.isLoading).toBe(false);
expect(result.isFetching).toBe(false);
expect(isProviderModelsQueryLoading(result)).toBe(false);
});
it("treats an active fetch as loading", () => {
expect(
isProviderModelsQueryLoading({
isLoading: false,
isFetching: true,
}),
).toBe(true);
});
});
@@ -1,8 +0,0 @@
interface ProviderModelsQueryState {
isFetching: boolean;
isLoading: boolean;
}
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
return input.isLoading || input.isFetching;
}
@@ -33,7 +33,6 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = {
id: string;
@@ -635,7 +634,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
queryKey: [
"providerModels",
serverId,
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
@@ -749,7 +753,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client}
/>
);
@@ -4,7 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import {
usePanelStore,
@@ -13,7 +13,7 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -40,7 +40,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
+1 -1
View File
@@ -119,7 +119,7 @@ function FilePreviewBody({
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
@@ -86,7 +86,7 @@ interface HighlightedTextProps {
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colors.surface0 === "#18181c";
// Get color for a highlight style
const getTokenColor = (style: HighlightStyle | null): string => {
@@ -1,11 +1,10 @@
import type { ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { isCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
@@ -44,7 +43,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
@@ -1,12 +1,11 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
@@ -25,7 +24,7 @@ interface ScreenHeaderProps {
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const padding = useWindowControlsPadding("header");
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -13,7 +13,7 @@ export function KeyboardShortcutsDialog() {
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac],
+6 -13
View File
@@ -20,7 +20,7 @@ import Animated, {
useSharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -39,11 +39,7 @@ import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/deskto
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
@@ -98,7 +94,6 @@ interface MobileSidebarProps extends SidebarSharedProps {
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
isOpen: boolean;
handleViewMore: () => void;
}
@@ -110,7 +105,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -180,7 +175,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
@@ -270,7 +265,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleHostSelect,
};
if (isCompactLayout) {
if (isMobile) {
return (
<MobileSidebar
{...sharedProps}
@@ -288,7 +283,6 @@ export const LeftSidebar = memo(function LeftSidebar({
return (
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleSettings={handleSettingsDesktop}
@@ -633,7 +627,6 @@ function DesktopSidebar({
handleHostSelect,
handleOpenProject,
handleSettings,
insetsTop,
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
@@ -688,7 +681,7 @@ function DesktopSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
@@ -22,7 +22,7 @@ import {
} from "react";
import { router, usePathname } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
@@ -34,6 +34,7 @@ import {
FolderPlus,
FolderGit2,
GitPullRequest,
Globe,
Monitor,
MoreVertical,
Plus,
@@ -42,7 +43,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -87,6 +88,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -664,7 +666,8 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = isCompactFormFactor();
const isMobileBreakpoint =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast();
@@ -694,7 +697,7 @@ function ProjectHeaderRow({
prepareWorkspaceTab({
serverId: serverId!,
workspaceId: workspace.id,
target: { kind: "draft", draftId: "new" },
target: { kind: "setup", workspaceId: workspace.id },
}) as any,
);
},
@@ -835,7 +838,7 @@ function WorkspaceRowInner({
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const isMobile = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
@@ -854,122 +857,132 @@ function WorkspaceRowInner({
onPress();
}, [interaction.didLongPressRef, onPress]);
const isDesktop = !isMobile;
const showGlobe = isDesktop && workspace.hasRunningServices;
return (
<View
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<Pressable
disabled={isArchiving}
style={({ pressed }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
<WorkspaceHoverCard workspace={workspace} isDragging={isDragging}>
<View
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
<Pressable
disabled={isArchiving}
style={({ pressed }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isTouchPlatform) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{showGlobe ? (
<View testID="workspace-globe-icon" accessibilityLabel="Services available">
<Globe size={12} color={theme.colors.foregroundMuted} />
</View>
) : null}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</View>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
</View>
) : null}
</Pressable>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
</View>
) : null}
</Pressable>
</View>
</WorkspaceHoverCard>
);
}
@@ -1637,7 +1650,7 @@ export function SidebarWorkspaceList({
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
@@ -1645,7 +1658,7 @@ export function SidebarWorkspaceList({
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const isDesktopApp = getIsElectronRuntime();
const isDesktopApp = getIsDesktop();
const altDown = useKeyboardShortcutsStore((state) => state.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -21,7 +21,6 @@ import {
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { isCompactFormFactor } from "@/constants/layout";
interface TerminalPaneProps {
serverId: string;
@@ -92,7 +91,7 @@ export function TerminalPane({
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
@@ -350,7 +350,7 @@ export function WebDesktopScrollbarOverlay({
? HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colors.surface0 === "#18181c";
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =
@@ -0,0 +1,490 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
import { Dimensions, Platform, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ExternalLink, FolderGit2, GitPullRequest, Monitor } from "lucide-react-native";
import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { SyncedLoader } from "@/components/synced-loader";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computeHoverCardPosition({
triggerRect,
contentSize,
displayArea,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
offset: number;
}): { x: number; y: number } {
let x = triggerRect.x + triggerRect.width + offset;
let y = triggerRect.y;
// If it overflows right, try left
if (x + contentSize.width > displayArea.width - 8) {
x = triggerRect.x - contentSize.width - offset;
}
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y),
);
return { x, y };
}
const HOVER_GRACE_MS = 100;
const HOVER_CARD_WIDTH = 260;
interface WorkspaceHoverCardProps {
workspace: SidebarWorkspaceEntry;
isDragging: boolean;
}
export function WorkspaceHoverCard({
workspace,
isDragging,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
// Desktop-only: skip on non-web platforms
if (Platform.OS !== "web") {
return <>{children}</>;
}
return (
<WorkspaceHoverCardDesktop workspace={workspace} isDragging={isDragging}>
{children}
</WorkspaceHoverCardDesktop>
);
}
function WorkspaceHoverCardDesktop({
workspace,
isDragging,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
const triggerRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerHoveredRef = useRef(false);
const contentHoveredRef = useRef(false);
const hasServices = workspace.services.length > 0;
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current);
graceTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
clearGraceTimer();
graceTimerRef.current = setTimeout(() => {
if (!triggerHoveredRef.current && !contentHoveredRef.current) {
setOpen(false);
}
graceTimerRef.current = null;
}, HOVER_GRACE_MS);
}, [clearGraceTimer]);
const handleTriggerEnter = useCallback(() => {
triggerHoveredRef.current = true;
clearGraceTimer();
if (!isDragging && hasServices) {
setOpen(true);
}
}, [clearGraceTimer, isDragging, hasServices]);
const handleTriggerLeave = useCallback(() => {
triggerHoveredRef.current = false;
scheduleClose();
}, [scheduleClose]);
const handleContentEnter = useCallback(() => {
contentHoveredRef.current = true;
clearGraceTimer();
}, [clearGraceTimer]);
const handleContentLeave = useCallback(() => {
contentHoveredRef.current = false;
scheduleClose();
}, [scheduleClose]);
// Close when drag starts
useEffect(() => {
if (isDragging) {
clearGraceTimer();
setOpen(false);
}
}, [isDragging, clearGraceTimer]);
// When hasServices becomes true while trigger is already hovered, open the card.
useEffect(() => {
if (!hasServices || isDragging) return;
if (triggerHoveredRef.current) {
setOpen(true);
}
}, [hasServices, isDragging]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearGraceTimer();
};
}, [clearGraceTimer]);
return (
<View
ref={triggerRef}
collapsable={false}
onPointerEnter={handleTriggerEnter}
onPointerLeave={handleTriggerLeave}
>
{children}
{open && hasServices ? (
<WorkspaceHoverCardContent
workspace={workspace}
triggerRef={triggerRef}
onContentEnter={handleContentEnter}
onContentLeave={handleContentLeave}
/>
) : null}
</View>
);
}
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};
function HoverCardStatusIndicator({
workspace,
}: {
workspace: SidebarWorkspaceEntry;
}): ReactElement | null {
const { theme } = useUnistyles();
const showSyncedLoader = shouldRenderSyncedStatusLoader({ bucket: workspace.statusBucket });
if (showSyncedLoader) {
return <SyncedLoader size={11} color={theme.colors.palette.amber[500]} />;
}
const KindIcon =
workspace.workspaceKind === "local_checkout"
? Monitor
: workspace.workspaceKind === "worktree"
? FolderGit2
: null;
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket: workspace.statusBucket, showDoneAsInactive: false });
return (
<View style={styles.hoverStatusIcon}>
<KindIcon size={14} color={theme.colors.foregroundMuted} />
{dotColor ? (
<View
style={[
styles.hoverStatusDotOverlay,
{
backgroundColor: dotColor,
borderColor: theme.colors.surface1,
},
]}
/>
) : null}
</View>
);
}
function WorkspaceHoverCardContent({
workspace,
triggerRef,
onContentEnter,
onContentLeave,
}: {
workspace: SidebarWorkspaceEntry;
triggerRef: React.RefObject<View | null>;
onContentEnter: () => void;
onContentLeave: () => void;
}): ReactElement | null {
const { theme } = useUnistyles();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
enabled: workspace.workspaceKind !== "directory",
});
// Measure trigger — same pattern as tooltip.tsx
useEffect(() => {
if (!triggerRef.current) return;
let cancelled = false;
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect(rect);
});
return () => {
cancelled = true;
};
}, [triggerRef]);
// Compute position when both measurements are available
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
const result = computeHoverCardPosition({
triggerRect,
contentSize,
displayArea,
offset: 4,
});
setPosition(result);
}, [triggerRect, contentSize]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[],
);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<Animated.View
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
onPointerEnter={onContentEnter}
onPointerLeave={onContentLeave}
accessibilityRole="menu"
accessibilityLabel="Workspace services"
testID="workspace-hover-card"
style={[
styles.card,
{
width: HOVER_CARD_WIDTH,
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
<View style={styles.cardHeader}>
<HoverCardStatusIndicator workspace={workspace} />
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
{workspace.name}
</Text>
</View>
{workspace.diffStat ? (
<View style={styles.cardMetaRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{prHint ? (
<Pressable
style={styles.cardMetaRow}
onPress={() => void openExternalUrl(prHint.url)}
>
<GitPullRequest size={12} color={theme.colors.foregroundMuted} />
<Text style={styles.prBadgeText} numberOfLines={1}>
#{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]}
</Text>
</Pressable>
) : null}
<View style={styles.separator} />
<View style={styles.serviceList} testID="hover-card-service-list">
{workspace.services.map((service) => (
<Pressable
key={service.hostname}
accessibilityRole="link"
accessibilityLabel={`${service.serviceName} service`}
testID={`hover-card-service-${service.serviceName}`}
style={({ hovered }) => [
styles.serviceRow,
hovered && styles.serviceRowHovered,
]}
onPress={() => {
if (service.url) {
void openExternalUrl(service.url);
}
}}
disabled={!service.url}
>
<View
testID={`hover-card-service-status-${service.serviceName}`}
accessibilityLabel={service.status === "running" ? "Running" : "Stopped"}
style={[
styles.statusDot,
{
backgroundColor:
service.status === "running"
? theme.colors.palette.green[500]
: theme.colors.palette.red[500],
},
]}
/>
<Text style={styles.serviceName} numberOfLines={1}>
{service.serviceName}
</Text>
{service.url ? (
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
) : null}
</Pressable>
))}
</View>
</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
card: {
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[2],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
zIndex: 1000,
},
cardHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
cardTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
flex: 1,
minWidth: 0,
},
cardMetaRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
diffStatAdditions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
diffStatDeletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
prBadgeText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hoverStatusIcon: {
width: 14,
height: 14,
alignItems: "center",
justifyContent: "center",
position: "relative",
},
hoverStatusDotOverlay: {
position: "absolute",
bottom: -1,
right: -1,
width: 6,
height: 6,
borderRadius: 3,
borderWidth: 1,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
},
serviceList: {
paddingTop: theme.spacing[1],
},
serviceRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
serviceRowHovered: {
backgroundColor: theme.colors.surface2,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
flexShrink: 0,
},
serviceName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flex: 1,
minWidth: 0,
},
}));
+17 -37
View File
@@ -1,6 +1,5 @@
import { Platform } from "react-native";
import { UnistylesRuntime } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
import { isDesktop, isDesktopMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75;
@@ -24,59 +23,40 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in the Electron desktop runtime (any OS)
function isElectronDesktopRuntime(): boolean {
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntime();
return isDesktop();
}
// Check if running in the Electron desktop runtime on macOS
function isElectronDesktopRuntimeMac(): boolean {
// Check if running in desktop host on macOS
function isDesktopEnvironmentMac(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntimeMac();
return isDesktopMac();
}
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isElectronRuntimeMacCached: boolean | null = null;
let _isElectronRuntimeCached: boolean | null = null;
let _isDesktopMacCached: boolean | null = null;
let _isDesktopCached: boolean | null = null;
export function getIsElectronRuntimeMac(): boolean {
if (_isElectronRuntimeMacCached === true) {
export function getIsDesktopMac(): boolean {
if (_isDesktopMacCached === true) {
return true;
}
const result = isElectronDesktopRuntimeMac();
const result = isDesktopEnvironmentMac();
if (result) {
_isElectronRuntimeMacCached = true;
_isDesktopMacCached = true;
}
return result;
}
export function getIsElectronRuntime(): boolean {
if (_isElectronRuntimeCached === true) {
export function getIsDesktop(): boolean {
if (_isDesktopCached === true) {
return true;
}
const result = isElectronDesktopRuntime();
const result = isDesktopEnvironment();
if (result) {
_isElectronRuntimeCached = true;
_isDesktopCached = true;
}
return result;
}
export function isCompactFormFactor(): boolean {
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
}
export function isDesktopFormFactor(): boolean {
return !isCompactFormFactor();
}
export function isTouchDesktopFormFactor(): boolean {
return Platform.OS !== "web" && isDesktopFormFactor();
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
// Keep that capability distinct from desktop-width layout so touch tablets
// can use the desktop shell without entering web-only code paths.
export function supportsDesktopPaneSplits(): boolean {
return Platform.OS === "web";
}
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { isCompactFormFactor } from "@/constants/layout";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
@@ -27,12 +27,12 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceServicePayload } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { patchWorkspaceServices } from "./session-workspace-services";
function workspace(input: {
id: string;
services?: WorkspaceDescriptor["services"];
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "running",
activityAt: null,
diffStat: null,
services: input.services ?? [],
};
}
const runningService: WorkspaceServicePayload = {
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
};
describe("patchWorkspaceServices", () => {
it("patches only the matching workspace services", () => {
const other = workspace({ id: "/repo/other", services: [] });
const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", services: [] })],
[other.id, other],
]);
const next = patchWorkspaceServices(current, {
workspaceId: "/repo/main",
services: [runningService],
});
expect(next).not.toBe(current);
expect(next.get("/repo/main")?.services).toEqual([runningService]);
expect(next.get("/repo/other")).toBe(other);
});
it("ignores updates for unknown workspaces", () => {
const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", services: [] })],
]);
const next = patchWorkspaceServices(current, {
workspaceId: "/repo/missing",
services: [runningService],
});
expect(next).toBe(current);
expect(next.get("/repo/main")?.services).toEqual([]);
});
});
+46 -1
View File
@@ -35,6 +35,7 @@ import {
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
@@ -50,6 +51,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceServices } from "@/contexts/session-workspace-services";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -159,6 +161,10 @@ type WorkspaceUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
>["payload"];
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
update.kind === "remove" ? update.agentId : update.agent.id;
@@ -264,6 +270,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace);
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
@@ -748,6 +757,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
],
);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
upsertWorkspaceSetupProgress({ serverId, payload });
},
[serverId, upsertWorkspaceSetupProgress],
);
const requestCanonicalCatchUp = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
void client
@@ -1090,12 +1106,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") {
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
removeWorkspace(serverId, message.payload.id);
return;
}
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
});
const unsubServiceStatusUpdate = client.on("service_status_update", (message) => {
if (message.type !== "service_status_update") return;
setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload));
});
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload);
});
const unsubWorkspaceSetupStatusResponse = client.on(
"workspace_setup_status_response",
(message) => {
if (message.type !== "workspace_setup_status_response") return;
const { workspaceId, snapshot } = message.payload;
if (snapshot) {
applyWorkspaceSetupProgress({ workspaceId, ...snapshot });
}
},
);
const unsubStatus = client.on("status", (message) => {
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
@@ -1444,6 +1482,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentStream();
unsubAgentTimeline();
unsubWorkspaceUpdate();
unsubServiceStatusUpdate();
unsubWorkspaceSetupProgress();
unsubWorkspaceSetupStatusResponse();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -1469,8 +1510,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentTimelineCursor,
setInitializingAgents,
setAgents,
setWorkspaces,
mergeWorkspaces,
removeWorkspace,
removeWorkspaceSetup,
setAgentLastActivity,
setPendingPermissions,
setHasHydratedAgents,
@@ -1478,6 +1521,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
notifyAgentAttention,
requestCanonicalCatchUp,
applyAgentUpdatePayload,
applyWorkspaceSetupProgress,
applyTimelineResponse,
voiceRuntime,
voiceAudioEngine,
@@ -1681,9 +1725,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
// Cleanup on unmount
useEffect(() => {
return () => {
clearWorkspaceSetupServer(serverId);
clearSession(serverId);
};
}, [clearSession, serverId]);
}, [clearSession, clearWorkspaceSetupServer, serverId]);
return children;
}
@@ -0,0 +1,19 @@
import type { ServiceStatusUpdateMessage } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
export function patchWorkspaceServices(
workspaces: Map<string, WorkspaceDescriptor>,
update: ServiceStatusUpdateMessage["payload"],
): Map<string, WorkspaceDescriptor> {
const existing = workspaces.get(update.workspaceId);
if (!existing) {
return workspaces;
}
const next = new Map(workspaces);
next.set(update.workspaceId, {
...existing,
services: update.services.map((s) => ({ ...s })),
});
return next;
}
@@ -10,7 +10,7 @@ import {
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { isCompactFormFactor } from "@/constants/layout";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
@@ -34,12 +34,12 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
@@ -9,7 +9,7 @@ import { settingsStyles } from "@/styles/settings";
export function DesktopPermissionsSection() {
const { theme } = useUnistyles();
const {
isDesktopApp,
isDesktop,
snapshot,
isRefreshing,
requestingPermission,
@@ -20,7 +20,7 @@ export function DesktopPermissionsSection() {
sendTestNotification,
} = useDesktopPermissions();
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}
@@ -1,4 +1,4 @@
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { getDesktopHost, isDesktop } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -123,7 +123,7 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
}
export function shouldUseDesktopDaemon(): boolean {
return isElectronRuntime();
return isDesktop();
}
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
+3 -3
View File
@@ -97,12 +97,12 @@ export function getDesktopHost(): DesktopHostBridge | null {
return getElectronHost();
}
export function isElectronRuntime(): boolean {
export function isDesktop(): boolean {
return getDesktopHost() !== null;
}
export function isElectronRuntimeMac(): boolean {
if (!isElectronRuntime()) {
export function isDesktopMac(): boolean {
if (!isDesktop()) {
return false;
}
if (typeof navigator === "undefined") {
@@ -9,7 +9,7 @@ import {
import { sendOsNotification } from "@/utils/os-notifications";
export interface UseDesktopPermissionsReturn {
isDesktopApp: boolean;
isDesktop: boolean;
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
@@ -31,7 +31,7 @@ const EMPTY_MICROPHONE_STATUS = {
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const isDesktopApp = shouldShowDesktopPermissionSection();
const isDesktop = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -47,7 +47,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
}, []);
const refreshPermissions = useCallback(async () => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -65,11 +65,11 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setIsRefreshing(false);
}
}
}, [isDesktopApp]);
}, [isDesktop]);
const requestPermission = useCallback(
async (kind: DesktopPermissionKind) => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -110,13 +110,13 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions();
}
},
[isDesktopApp, refreshPermissions],
[isDesktop, refreshPermissions],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -137,18 +137,18 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setIsSendingTestNotification(false);
}
}
}, [isDesktopApp]);
}, [isDesktop]);
useEffect(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
void refreshPermissions();
}, [isDesktopApp, refreshPermissions]);
}, [isDesktop, refreshPermissions]);
return {
isDesktopApp,
isDesktop,
snapshot,
isRefreshing,
requestingPermission,
@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export interface DesktopAppUpdateCheckResult {
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === "web" && isElectronRuntime();
return Platform.OS === "web" && isDesktop();
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
@@ -11,7 +11,7 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktopApp,
isDesktop,
status,
availableUpdate,
errorMessage,
@@ -23,7 +23,7 @@ export function UpdateBanner() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!isDesktopApp) return;
if (!isDesktop) return;
void checkForUpdates({ silent: true });
@@ -36,9 +36,9 @@ export function UpdateBanner() {
clearInterval(intervalRef.current);
}
};
}, [isDesktopApp, checkForUpdates]);
}, [isDesktop, checkForUpdates]);
if (!isDesktopApp) return null;
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
@@ -18,7 +18,7 @@ export type DesktopAppUpdateStatus =
| "error";
export interface UseDesktopAppUpdaterReturn {
isDesktopApp: boolean;
isDesktop: boolean;
status: DesktopAppUpdateStatus;
statusText: string;
availableUpdate: DesktopAppUpdateCheckResult | null;
@@ -75,7 +75,7 @@ function formatStatusText(input: {
}
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const isDesktop = shouldShowDesktopUpdateSection();
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -85,7 +85,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}
@@ -130,11 +130,11 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null;
}
},
[isDesktopApp],
[isDesktop],
);
const installUpdate = useCallback(async () => {
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}
@@ -162,10 +162,10 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message);
return null;
}
}, [isDesktopApp]);
}, [isDesktop]);
return {
isDesktopApp,
isDesktop,
status,
statusText: formatStatusText({
status,
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [formState.workingDir]);
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider],
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
@@ -446,7 +446,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const allProviderModelQueries = useQueries({
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id],
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
@@ -1,46 +1,8 @@
import type { DaemonClient } from "@server/client/daemon-client";
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 { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
serverId: "server-a",
id: "agent-1",
provider: "codex",
status: "running",
createdAt: new Date("2026-04-01T03:00:00.000Z"),
updatedAt: new Date("2026-04-01T03:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-04-01T03:00:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent 1",
cwd: "/repo",
model: null,
labels: {},
archivedAt: null,
...overrides,
};
}
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-archive-agent";
describe("useArchiveAgent", () => {
beforeEach(() => {
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
it("tracks pending archive state in shared react-query cache", () => {
const queryClient = new QueryClient();
@@ -101,42 +63,4 @@ describe("useArchiveAgent", () => {
expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]);
expect(next.pageInfo).toEqual({ hasMore: false });
});
it("applies archived agent close results to session state and cached lists", async () => {
const queryClient = new QueryClient();
useSessionStore
.getState()
.initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents(
"server-a",
new Map([
[
"agent-1",
makeAgent(),
],
]),
);
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
queryClient.setQueryData(["allAgents", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
applyArchivedAgentCloseResults({
queryClient,
serverId: "server-a",
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
});
expect(
useSessionStore.getState().sessions["server-a"]?.agents.get("agent-1")?.archivedAt?.toISOString(),
).toBe("2026-04-01T04:00:00.000Z");
expect(queryClient.getQueryData(["sidebarAgentsList", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
});
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
});
});
});
+10 -41
View File
@@ -9,11 +9,6 @@ export interface ArchiveAgentInput {
agentId: string;
}
export interface ArchivedAgentCloseResult {
agentId: string;
archivedAt: string;
}
type ArchiveAgentPendingState = Record<string, true>;
interface SetAgentArchivingInput extends ArchiveAgentInput {
@@ -135,39 +130,6 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin
});
}
interface ApplyArchivedAgentCloseResultsInput {
queryClient: QueryClient;
serverId: string;
results: ArchivedAgentCloseResult[];
}
export function applyArchivedAgentCloseResults(
input: ApplyArchivedAgentCloseResultsInput,
): void {
if (input.results.length === 0) {
return;
}
for (const result of input.results) {
markAgentArchivedInStore({
serverId: input.serverId,
agentId: result.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(input.queryClient, {
serverId: input.serverId,
agentId: result.agentId,
});
}
void input.queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void input.queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
setAgentArchiving({
...input,
@@ -203,10 +165,17 @@ export function useArchiveAgent() {
});
},
onSuccess: (result, input) => {
applyArchivedAgentCloseResults({
queryClient,
markAgentArchivedInStore({
serverId: input.serverId,
results: [{ agentId: input.agentId, archivedAt: result.archivedAt }],
agentId: input.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(queryClient, input);
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
},
onSettled: (_result, _error, input) => {
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
export interface AudioCaptureConfig {
sampleRate?: number;
@@ -157,7 +157,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isElectronRuntime();
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
+3 -3
View File
@@ -17,7 +17,7 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
@@ -110,8 +110,8 @@ function resolveActionShortcutKeys(
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const platform = { isMac, isDesktop: isDesktopApp };
const isDesktop = getIsDesktop();
const platform = { isMac, isDesktop };
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import type {
DictationAudioSource,
@@ -168,7 +168,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isElectronRuntime();
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host";
@@ -96,7 +96,7 @@ function getSystemColorScheme(): ColorScheme {
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
@@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import {
normalizePickedImageAssets,
openImagePathsWithDesktopDialog,
@@ -45,7 +45,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isElectronRuntime()) {
if (Platform.OS === "web" && isDesktop()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;
@@ -17,12 +17,12 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim();
const isDesktopApp = shouldUseDesktopDaemon();
const isDesktop = shouldUseDesktopDaemon();
const query = useQuery({
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadDesktopDaemonServerId,
enabled: isDesktopApp,
enabled: isDesktop,
staleTime: Infinity,
gcTime: Infinity,
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
@@ -32,7 +32,7 @@ export function useIsLocalDaemon(serverId: string): boolean {
retry: false,
});
if (!isDesktopApp || normalizedServerId.length === 0) {
if (!isDesktop || normalizedServerId.length === 0) {
return false;
}
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -66,7 +66,7 @@ export function useKeyboardShortcuts({
if (Platform.OS !== "web") return;
if (isMobile) return;
const isDesktopApp = getIsElectronRuntime();
const isDesktopApp = getIsDesktop();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {
+4 -4
View File
@@ -4,15 +4,15 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const isDesktop = getIsDesktop();
return useMemo(() => {
const platform = { isMac, isDesktop: isDesktopApp };
const platform = { isMac, isDesktop };
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return null;
@@ -23,5 +23,5 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
}, [actionId, overrides, isMac, isDesktopApp]);
}, [actionId, overrides, isMac, isDesktop]);
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceServicePayload } from "@server/shared/messages";
import {
appendMissingOrderKeys,
applyStoredOrdering,
@@ -19,7 +20,7 @@ function workspace(
Partial<
Pick<
WorkspaceDescriptor,
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" | "services"
>
>,
): WorkspaceDescriptor {
@@ -34,9 +35,26 @@ function workspace(
status: input.status,
activityAt: input.activityAt,
diffStat: null,
services: input.services ?? [],
};
}
const runningService: WorkspaceServicePayload = {
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
};
const stoppedService: WorkspaceServicePayload = {
serviceName: "api",
hostname: "main.api.localhost",
port: 3001,
url: "http://main.api.localhost:6767",
status: "stopped",
};
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
@@ -117,6 +135,27 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
});
it("threads services into workspace rows and derives hasRunningServices", () => {
const projects = buildSidebarProjectsFromWorkspaces({
serverId: "srv",
workspaces: [
workspace({
id: "/repo/main",
projectId: "project-1",
name: "main",
status: "running",
activityAt: new Date("2026-01-01T00:00:00.000Z"),
services: [runningService, stoppedService],
}),
],
projectOrder: [],
workspaceOrderByScope: {},
});
expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]);
expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true);
});
it("preserves stored project order even when activity changes", () => {
const initialWorkspaces: WorkspaceDescriptor[] = [
workspace({
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
@@ -20,6 +21,8 @@ export interface SidebarWorkspaceEntry {
activityAt: Date | null;
statusBucket: SidebarStateBucket;
diffStat: { additions: number; deletions: number } | null;
services: WorkspaceDescriptor["services"];
hasRunningServices: boolean;
}
export interface SidebarProjectEntry {
@@ -135,6 +138,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
activityAt: workspace.activityAt,
statusBucket: workspace.status,
diffStat: workspace.diffStat,
services: workspace.services,
hasRunningServices: workspace.services.some((service) => service.status === "running"),
};
project.workspaces.push(row);
@@ -257,8 +262,12 @@ function toWorkspaceDescriptor(payload: {
name: string;
status: WorkspaceDescriptor["status"];
activityAt: string | null;
services?: WorkspaceDescriptorPayload["services"];
}): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
return normalizeWorkspaceDescriptor({
...payload,
services: payload.services ?? [],
});
}
export function useSidebarWorkspacesList(options?: {
@@ -2,6 +2,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel";
import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry";
import { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel";
let panelsRegistered = false;
@@ -12,6 +13,7 @@ export function ensurePanelsRegistered(): void {
}
registerPanel(draftPanelRegistration);
registerPanel(agentPanelRegistration);
registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration);
registerPanel(filePanelRegistration);
panelsRegistered = true;
+444
View File
@@ -0,0 +1,444 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import invariant from "tiny-invariant";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
function useSetupPanelDescriptor(
target: { kind: "setup"; workspaceId: string },
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const key = buildWorkspaceTabPersistenceKey({
serverId: context.serverId,
workspaceId: target.workspaceId,
});
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
if (snapshot?.status === "completed") {
return {
label: "Setup",
subtitle: "Setup completed",
titleState: "ready",
icon: CheckCircle2,
statusBucket: null,
};
}
if (snapshot?.status === "failed") {
return {
label: "Setup",
subtitle: "Setup failed",
titleState: "ready",
icon: CircleAlert,
statusBucket: null,
};
}
return {
label: "Setup",
subtitle: "Workspace setup",
titleState: "ready",
icon: SquareTerminal,
statusBucket: snapshot?.status === "running" ? "running" : null,
};
}
type CommandStatus = "running" | "completed" | "failed";
function CommandStatusIcon({ status }: { status: CommandStatus }) {
const { theme } = useUnistyles();
if (status === "running") {
return <ActivityIndicator size={14} color={theme.colors.foreground} />;
}
if (status === "completed") {
return <CheckCircle2 size={14} color={theme.colors.palette.green[500]} />;
}
return <CircleAlert size={14} color={theme.colors.palette.red[500]} />;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
/**
* Process carriage returns in log text so progress-bar output renders cleanly.
* Splits on \r, keeps only the last segment per CR-delimited group (unless followed by \n).
*/
function processCarriageReturns(text: string): string {
if (!text.includes("\r")) return text;
return text
.split("\n")
.map((line) => {
if (!line.includes("\r")) return line;
const segments = line.split("\r");
return segments[segments.length - 1];
})
.join("\n");
}
function SetupPanel() {
const { theme } = useUnistyles();
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
const client = useHostRuntimeClient(serverId);
const key = buildWorkspaceTabPersistenceKey({
serverId,
workspaceId: target.workspaceId,
});
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// On mount, if no snapshot in the store, request cached status from server
const requestedRef = useRef(false);
useEffect(() => {
if (snapshot || requestedRef.current || !client) return;
requestedRef.current = true;
client
.fetchWorkspaceSetupStatus(target.workspaceId)
.then((response) => {
if (response.snapshot) {
upsertProgress({
serverId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
}
})
.catch(() => {
// Server may not support this yet — ignore
});
}, [client, snapshot, serverId, target.workspaceId, upsertProgress]);
const commands = snapshot?.detail.commands ?? [];
const log = snapshot?.detail.log ?? "";
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(new Set());
const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => {
setExpandedIndices((prev) => {
const next = new Set(prev);
if (next.has(index) || isAutoExpanded) {
next.delete(index);
// If this was auto-expanded, record that the user manually collapsed it
if (isAutoExpanded) {
setManuallyCollapsed((mc) => new Set(mc).add(index));
}
} else {
next.add(index);
// If the user re-expands, remove from manually collapsed
setManuallyCollapsed((mc) => {
const next = new Set(mc);
next.delete(index);
return next;
});
}
return next;
});
}, []);
// Determine which command should auto-expand (running or last completed).
const autoExpandIndex = (() => {
const running = commands.find((c) => c.status === "running");
if (running) return running.index;
if (commands.length > 0) return commands[commands.length - 1].index;
return null;
})();
const statusLabel = snapshot?.status === "running"
? "Running"
: snapshot?.status === "completed"
? "Completed"
: snapshot?.status === "failed"
? "Failed"
: "Waiting for setup output";
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
testID="workspace-setup-panel"
>
{/* Hidden element for status — preserves testID for E2E */}
<Text
style={styles.hiddenStatus}
testID="workspace-setup-status"
>{statusLabel}</Text>
{isWaiting ? (
<View style={styles.waitingContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.waitingText}>Setting up workspace...</Text>
</View>
) : hasNoSetupCommands ? (
<View style={styles.emptyContainer}>
<Text
style={styles.emptyText}
accessible
accessibilityLabel="No setup commands ran for this workspace"
>
No setup commands ran for this workspace.
</Text>
</View>
) : (
<View style={styles.commandList}>
{commands.map((command) => {
const isExpanded = expandedIndices.has(command.index);
const hasError = command.status === "failed" && snapshot?.error;
// Per-command log: use command.log if available, fall back to detail.log for the auto-expand target
const commandLog = (() => {
if ("log" in command && typeof command.log === "string") {
return command.log;
}
// Fallback: show detail.log on the auto-expand target command
if (command.index === autoExpandIndex) return log;
return "";
})();
const hasLog = commandLog.trim().length > 0;
// All non-running commands are expandable (completed/failed)
const isExpandable = command.status !== "running" || hasLog || !!hasError;
// Auto-expand the active command unless the user manually collapsed it
const isAutoExpanded =
command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
const showDetail = isExpanded || isAutoExpanded;
const processedLog = hasLog ? processCarriageReturns(commandLog) : "";
return (
<View key={`${command.index}:${command.command}`} style={styles.commandItem}>
<Pressable
onPress={() => toggleExpanded(command.index, isAutoExpanded)}
style={({ pressed }) => [
styles.commandRow,
showDetail && styles.commandRowExpanded,
pressed && styles.commandRowPressed,
]}
accessibilityRole="button"
accessibilityState={{ expanded: showDetail }}
>
<View style={styles.commandStatusIcon}>
<CommandStatusIcon status={command.status} />
</View>
<Text style={styles.commandText} numberOfLines={1}>
{command.command}
</Text>
{command.durationMs != null ? (
<Text style={styles.commandDuration}>
{formatDuration(command.durationMs)}
</Text>
) : null}
<ChevronRight
size={14}
color={theme.colors.foregroundMuted}
style={[
styles.chevron,
showDetail && styles.chevronExpanded,
]}
/>
</Pressable>
{showDetail ? (
<View style={styles.commandDetail}>
{hasLog ? (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
horizontal={false}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{processedLog}
</Text>
</ScrollView>
) : (
<View
style={styles.logScrollContent}
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text style={styles.emptyLogText}>No output</Text>
</View>
)}
{hasError ? (
<View style={styles.errorCard}>
<Text selectable style={styles.errorText}>
{snapshot.error}
</Text>
</View>
) : null}
</View>
) : null}
</View>
);
})}
{/* If there's log but no commands yet, or log without a target command, show standalone */}
{commands.length === 0 && log.trim().length > 0 ? (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{log}
</Text>
</ScrollView>
) : null}
{/* Show error at top level if no commands failed but there's a setup error */}
{snapshot?.error && !commands.some((c) => c.status === "failed") ? (
<View style={styles.errorCard}>
<Text selectable style={styles.errorText}>
{snapshot.error}
</Text>
</View>
) : null}
</View>
)}
</ScrollView>
);
}
export const setupPanelRegistration: PanelRegistration<"setup"> = {
kind: "setup",
component: SetupPanel,
useDescriptor: useSetupPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
padding: theme.spacing[4],
flexGrow: 1,
},
hiddenStatus: {
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
opacity: 0,
},
waitingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
waitingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
emptyText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
commandList: {
gap: theme.spacing[2],
},
commandItem: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
commandRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
commandRowExpanded: {
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
commandRowPressed: {
opacity: 0.8,
},
commandStatusIcon: {
width: 18,
height: 18,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
commandText: {
flex: 1,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
commandDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 0,
},
chevron: {
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
commandDetail: {
backgroundColor: theme.colors.surface0,
},
logScroll: {
maxHeight: 400,
},
logScrollContent: {
padding: theme.spacing[3],
},
logText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: 20,
color: theme.colors.foreground,
},
emptyLogText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontStyle: "italic",
},
errorCard: {
padding: theme.spacing[3],
backgroundColor: theme.colors.palette.red[100],
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.palette.red[800],
},
}));
+2 -90
View File
@@ -6,7 +6,7 @@ import type {
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
HostRuntimeController,
HostRuntimeStore,
@@ -112,7 +112,6 @@ function makeFetchAgentsEntry(input: {
title?: string | null;
requiresAttention?: boolean;
attentionReason?: "permission" | "error" | null;
archivedAt?: string | null;
}): FetchAgentsEntry {
return {
agent: {
@@ -146,7 +145,7 @@ function makeFetchAgentsEntry(input: {
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null,
archivedAt: input.archivedAt ?? null,
archivedAt: null,
labels: {},
},
project: {
@@ -1135,93 +1134,6 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => {
const host = makeHost({
serverId: "srv_archived_rehydrate",
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:30:00.000Z",
archivedAt: "2026-03-30T15:31:00.000Z",
title: "Archived remotely",
}),
],
subscriptionId: "app:srv_archived_rehydrate",
}),
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
useSessionStore.getState().setAgents(host.serverId, () => {
const stale = makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:29:00.000Z",
archivedAt: null,
title: "Stale active copy",
}).agent;
const staleAgent: Agent = {
...stale,
serverId: host.serverId,
createdAt: new Date(stale.createdAt),
updatedAt: new Date(stale.updatedAt),
lastUserMessageAt: null,
lastActivityAt: new Date(stale.updatedAt),
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
};
return new Map([
[
stale.id,
staleAgent,
],
]);
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
let archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
while (!archivedAt && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
}
expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z");
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("records unavailable startup probes when no connection can be established", async () => {
const host = makeHost({
connections: [
@@ -4,7 +4,7 @@ import type { ImageAttachment } from "@/components/message-input";
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
@@ -40,7 +40,7 @@ import {
} from "@/runtime/host-runtime";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
import { encodeImages } from "@/utils/encode-images";
@@ -233,7 +233,7 @@ function DraftAgentScreenContent({
isCreateFlow: true,
onlineServerIds,
});
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
@@ -8,7 +8,6 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor } from "@/constants/layout";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -17,14 +16,14 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
if (!isCompactLayout) {
if (!isMobile) {
openAgentList();
}
}, [isCompactLayout, openAgentList]);
}, [isMobile, openAgentList]);
return (
<View style={styles.container}>
+27 -26
View File
@@ -4,7 +4,7 @@ import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native
import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Buffer } from "buffer";
import {
Sun,
@@ -51,7 +51,7 @@ import {
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop as isDesktopHost } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
@@ -59,7 +59,6 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { isCompactFormFactor } from "@/constants/layout";
// ---------------------------------------------------------------------------
// Section definitions
@@ -80,7 +79,7 @@ interface SettingsSectionDef {
icon: ComponentType<{ size: number; color: string }>;
}
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
@@ -89,7 +88,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
{ id: "about", label: "About", icon: Info },
];
if (context.isDesktopApp) {
if (context.isDesktop) {
sections.push(
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "daemon", label: "Daemon", icon: Settings },
@@ -175,6 +174,7 @@ interface HostsSectionProps {
daemons: HostProfile[];
settings: AppSettings;
routeServerId: string;
isDesktop: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
@@ -464,10 +464,10 @@ function DiagnosticsSection({
interface AboutSectionProps {
appVersionText: string;
isDesktopApp: boolean;
isDesktop: boolean;
}
function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>About</Text>
@@ -478,7 +478,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
{isDesktop ? <DesktopAppUpdateRow /> : null}
</View>
</View>
);
@@ -496,7 +496,7 @@ interface SettingsSectionContentProps {
aboutProps: AboutSectionProps;
appVersion: string | null;
isLocalDaemon: boolean;
isDesktopApp: boolean;
isDesktop: boolean;
}
function SettingsSectionContent({
@@ -507,7 +507,7 @@ function SettingsSectionContent({
aboutProps,
appVersion,
isLocalDaemon,
isDesktopApp,
isDesktop,
}: SettingsSectionContentProps) {
switch (sectionId) {
case "hosts":
@@ -521,9 +521,9 @@ function SettingsSectionContent({
case "about":
return <AboutSection {...aboutProps} />;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
return isDesktop ? <DesktopPermissionsSection /> : null;
case "daemon":
return isDesktopApp ? (
return isDesktop ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
) : null;
}
@@ -619,7 +619,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
function DesktopAppUpdateRow() {
const {
isDesktopApp,
isDesktop,
statusText,
availableUpdate,
errorMessage,
@@ -631,23 +631,23 @@ function DesktopAppUpdateRow() {
useFocusEffect(
useCallback(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return undefined;
}
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktopApp]),
}, [checkForUpdates, isDesktop]),
);
const handleCheckForUpdates = useCallback(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
void checkForUpdates();
}, [checkForUpdates, isDesktopApp]);
}, [checkForUpdates, isDesktop]);
const handleInstallUpdate = useCallback(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -667,9 +667,9 @@ function DesktopAppUpdateRow() {
console.error("[Settings] Failed to open app update confirmation", error);
Alert.alert("Error", "Unable to open the update confirmation dialog.");
});
}, [installUpdate, isDesktopApp]);
}, [installUpdate, isDesktop]);
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}
@@ -745,7 +745,7 @@ export default function SettingsScreen() {
const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const isDesktop = isDesktopHost();
const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -929,13 +929,14 @@ export default function SettingsScreen() {
}
}, [isPlaybackTestRunning, voiceAudioEngine]);
const isCompactLayout = isCompactFormFactor();
const sections = getSettingsSections({ isDesktopApp });
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sections = getSettingsSections({ isDesktop });
const hostsProps: HostsSectionProps = {
daemons,
settings,
routeServerId,
isDesktop,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
@@ -984,7 +985,7 @@ export default function SettingsScreen() {
const aboutProps: AboutSectionProps = {
appVersionText,
isDesktopApp,
isDesktop,
};
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
@@ -994,7 +995,7 @@ export default function SettingsScreen() {
aboutProps,
appVersion,
isLocalDaemon,
isDesktopApp,
isDesktop,
};
if (isLoading) {
@@ -1008,7 +1009,7 @@ export default function SettingsScreen() {
return (
<View style={styles.container}>
<MenuHeader title="Settings" />
{isCompactLayout ? (
{isMobile ? (
<SettingsMobileLayout sections={sections} sectionContentProps={sectionContentProps} />
) : (
<SettingsDesktopLayout sections={sections} sectionContentProps={sectionContentProps} />
@@ -17,7 +17,7 @@ import {
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
@@ -93,8 +93,8 @@ export function KeyboardShortcutsSection() {
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
const isDesktop = getIsDesktop();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
function cancelCapture() {
setCapturedCombos([]);
@@ -180,10 +180,7 @@ export function KeyboardShortcutsSection() {
<Text style={styles.subsectionTitle}>{section.title}</Text>
<View style={settingsStyles.card}>
{section.rows.map(function (row, index) {
const bindingId = getBindingIdForAction(row.id, {
isMac,
isDesktop: isDesktopApp,
});
const bindingId = getBindingIdForAction(row.id, { isMac, isDesktop });
const overrideCombo = bindingId ? overrides[bindingId] : undefined;
return (
@@ -102,17 +102,6 @@ describe("workspace agent visibility", () => {
).toBe(true);
});
it("prunes pinned archived agent tabs because archive state is authoritative", () => {
expect(
shouldPruneWorkspaceAgentTab({
agentId: "archived-agent",
agentsHydrated: true,
knownAgentIds: new Set(["archived-agent"]),
activeAgentIds: new Set<string>(),
}),
).toBe(true);
});
it("does not prune active agent tabs", () => {
const knownAgentIds = new Set(["active-agent"]);
const activeAgentIds = new Set(["active-agent"]);
@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -72,90 +71,4 @@ describe("workspace bulk close helpers", () => {
"This will close 1 terminal(s). Any running process in a closed terminal will be stopped immediately.",
);
});
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
makeTerminalTab("t2"),
makeFileTab("/repo/README.md"),
]);
const closedTabIds: string[] = [];
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const closeItems = vi.fn(async () => ({
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
],
requestId: "req-1",
}));
const result = await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
},
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
logLabel: "all tabs",
});
expect(closeItems).toHaveBeenCalledTimes(1);
expect(closeItems).toHaveBeenCalledWith({
agentIds: ["a1"],
terminalIds: ["t1", "t2"],
});
expect(result).toEqual({
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
],
requestId: "req-1",
});
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
expect(cleanupCalls).toEqual([
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ tabId: "file_/repo/README.md" },
]);
});
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
makeFileTab("/repo/README.md"),
]);
const closedTabIds: string[] = [];
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const warn = vi.fn();
const result = await closeBulkWorkspaceTabs({
groups,
client: {
closeItems: async () => {
throw new Error("rpc failed");
},
},
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
},
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
warn,
logLabel: "others",
});
expect(warn).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
});
});
@@ -1,4 +1,3 @@
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export type BulkClosableTabGroups = {
@@ -7,22 +6,6 @@ export type BulkClosableTabGroups = {
otherTabs: Array<{ tabId: string }>;
};
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
interface CloseWorkspaceTabWithCleanupInput {
tabId: string;
target?: WorkspaceTabDescriptor["target"];
}
interface CloseBulkWorkspaceTabsInput {
client: Pick<DaemonClient, "closeItems"> | null;
groups: BulkClosableTabGroups;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
logLabel: string;
warn?: (message: string, payload: object) => void;
}
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
const groups: BulkClosableTabGroups = {
agentTabs: [],
@@ -67,72 +50,3 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
}
return `This will archive ${agentTabs.length} agent(s).`;
}
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
}
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
[],
);
}
export async function closeBulkWorkspaceTabs(
input: CloseBulkWorkspaceTabsInput,
): Promise<CloseItemsPayload | null> {
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
let payload: CloseItemsPayload | null = null;
if (hasDestructiveTabs && client) {
try {
payload = await client.closeItems({
agentIds: groups.agentTabs.map((tab) => tab.agentId),
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
});
} catch (error) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
}
} else if (hasDestructiveTabs) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
error: new Error("Daemon client not available"),
});
}
const successfulAgentIds = toSuccessfulAgentIds(payload);
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
for (const { tabId, agentId } of groups.agentTabs) {
if (!successfulAgentIds.has(agentId)) {
continue;
}
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
for (const { tabId, terminalId } of groups.terminalTabs) {
if (!successfulTerminalIds.has(terminalId)) {
continue;
}
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
});
}
for (const { tabId } of groups.otherTabs) {
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId });
});
}
return payload;
}
@@ -8,17 +8,7 @@ import {
View,
type LayoutChangeEvent,
} from "react-native";
import {
CopyX,
ArrowLeftToLine,
ArrowRightToLine,
Columns2,
Copy,
Rows2,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import {
@@ -83,13 +73,15 @@ type WorkspaceDesktopTabsRowProps = {
externalDndContext?: boolean;
activeDragTabId?: string | null;
tabDropPreviewIndex?: number | null;
showPaneSplitActions?: boolean;
};
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
}
if (tab.target.kind === "setup") {
return "Setup";
}
if (tab.target.kind === "terminal") {
return "Terminal";
}
@@ -269,14 +261,7 @@ function TabChip({
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
{tab.target.kind === "agent" ? (
<View style={styles.tooltipAgentRow}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
</View>
) : (
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
)}
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
@@ -291,28 +276,6 @@ function TabChip({
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
}
})()}
trailing={
entry.hint ? (
<Text style={styles.menuItemHint}>{entry.hint}</Text>
) : undefined
}
>
{entry.label}
</ContextMenuItem>
@@ -347,7 +310,6 @@ export function WorkspaceDesktopTabsRow({
externalDndContext = false,
activeDragTabId = null,
tabDropPreviewIndex = null,
showPaneSplitActions = true,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
@@ -524,52 +486,48 @@ export function WorkspaceDesktopTabsRow({
</View>
</TooltipContent>
</Tooltip>
{showPaneSplitActions ? (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitRight}
accessibilityRole="button"
accessibilityLabel="Split pane right"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitDown}
accessibilityRole="button"
accessibilityLabel="Split pane down"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitRight}
accessibilityRole="button"
accessibilityLabel="Split pane right"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onSplitDown}
accessibilityRole="button"
accessibilityLabel="Split pane down"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
</View>
);
@@ -837,17 +795,4 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
tooltipAgentRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipAgentId: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
menuItemHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));
@@ -14,9 +14,6 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import {
CopyX,
ArrowLeftToLine,
ArrowRightToLine,
ChevronDown,
Copy,
Ellipsis,
@@ -24,12 +21,11 @@ import {
PanelRight,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
@@ -77,7 +73,7 @@ import {
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
@@ -86,10 +82,6 @@ import {
WorkspaceTabIcon,
WorkspaceTabOptionRow,
} from "@/screens/workspace/workspace-tab-presentation";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
@@ -111,10 +103,8 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation";
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -149,6 +139,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
}
if (tab.target.kind === "setup") {
return "Setup";
}
if (tab.target.kind === "terminal") {
return "Terminal";
}
@@ -162,6 +155,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
}
if (tab.target.kind === "setup") {
return "Workspace setup";
}
if (tab.target.kind === "agent") {
return "Agent";
}
@@ -342,28 +338,6 @@ function MobileWorkspaceTabOption({
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
}
})()}
trailing={
entry.hint ? (
<Text style={styles.menuItemHint}>{entry.hint}</Text>
) : undefined
}
>
{entry.label}
</DropdownMenuItem>
@@ -581,7 +555,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast();
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
@@ -984,6 +958,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
if (
canPruneAgentTabs &&
tab.target.kind === "agent" &&
!pinnedAgentIds.has(tab.target.agentId) &&
shouldPruneWorkspaceAgentTab({
agentId: tab.target.agentId,
agentsHydrated: hasHydratedAgents,
@@ -1413,45 +1388,62 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return;
}
const closeItemsPayload = await closeBulkWorkspaceTabs({
client,
groups,
closeTab,
closeWorkspaceTabWithCleanup: (cleanupInput) => {
if (!persistenceKey) {
return;
}
closeWorkspaceTabWithCleanup(cleanupInput);
},
logLabel,
warn: (message, payload) => {
console.warn(message, payload);
},
});
if (closeItemsPayload) {
for (const terminal of closeItemsPayload.terminals) {
if (!terminal.success) {
continue;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
if (!current) {
return current;
for (const { tabId, terminalId } of groups.terminalTabs) {
await closeTab(tabId, async () => {
try {
await killTerminalAsync(terminalId);
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
});
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
}
return {
...current,
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
};
});
}
} catch (error) {
console.warn(`[WorkspaceScreen] Failed to close terminal tab ${logLabel}`, {
terminalId,
error,
});
}
});
}
if (normalizedServerId) {
applyArchivedAgentCloseResults({
queryClient,
serverId: normalizedServerId,
results: closeItemsPayload.agents,
});
for (const { tabId, agentId } of groups.agentTabs) {
if (!normalizedServerId) {
continue;
}
await closeTab(tabId, async () => {
try {
await archiveAgent({ serverId: normalizedServerId, agentId });
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
}
} catch (error) {
console.warn(`[WorkspaceScreen] Failed to archive agent tab ${logLabel}`, {
agentId,
error,
});
}
});
}
for (const { tabId } of groups.otherTabs) {
await closeTab(tabId, async () => {
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId });
}
});
}
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
@@ -1459,9 +1451,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
client,
archiveAgent,
closeTab,
closeWorkspaceTabWithCleanup,
killTerminalAsync,
normalizedServerId,
persistenceKey,
queryClient,
@@ -1708,8 +1701,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
});
const activeTabDescriptor = activeTab?.descriptor ?? null;
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
useEffect(() => {
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
return;
@@ -1888,17 +1879,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[buildPaneContentModel],
);
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
tabs.map((tab) => ({
tab,
isActive: tab.tabId === activeTabDescriptor?.tabId,
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: closingTabIds.has(tab.tabId),
})),
[activeTabDescriptor?.tabId, closingTabIds, hoveredCloseTabKey, tabs],
);
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
if (!persistenceKey || paneFocusSuppressedRef.current) {
return;
@@ -1950,19 +1930,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[persistenceKey, reorderWorkspaceTabsInPane],
);
const handleReorderTabsInFocusedPane = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
if (!focusedPaneId) {
return;
}
handleReorderTabsInPane(
focusedPaneId,
nextTabs.map((tab) => tab.tabId),
);
},
[focusedPaneId, handleReorderTabsInPane],
);
const renderSplitPaneEmptyState = useCallback(function renderSplitPaneEmptyState() {
return (
<View style={styles.emptyState}>
@@ -2218,32 +2185,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneId ?? undefined}
isFocused
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
onReorderTabs={handleReorderTabsInFocusedPane}
onNewTerminalTab={handleCreateTerminal}
onSplitRight={() => {}}
onSplitDown={() => {}}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
@@ -2251,7 +2192,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</GestureDetector>
) : (
<View style={styles.content}>
{canRenderDesktopPaneSplits && workspaceLayout && persistenceKey ? (
{workspaceLayout && persistenceKey ? (
<SplitContainer
layout={workspaceLayout}
focusModeEnabled={isFocusModeEnabled && !isMobile}
@@ -2487,10 +2428,6 @@ const styles = StyleSheet.create((theme) => ({
mobileTabMenuTriggerActive: {
backgroundColor: theme.colors.surface2,
},
menuItemHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
tabsContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
@@ -19,6 +19,7 @@ describe("workspace source of truth consumption", () => {
status: "running",
activityAt: new Date("2026-03-01T00:00:00.000Z"),
diffStat: null,
services: [],
};
const header = resolveWorkspaceHeader({ workspace });
@@ -8,8 +8,6 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
hint?: string;
disabled?: boolean;
destructive?: boolean;
testID: string;
@@ -78,6 +76,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return `workspace-draft-close-${tab.target.draftId}`;
}
if (tab.target.kind === "setup") {
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}
@@ -108,7 +109,6 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "copy-resume-command",
label: "Copy resume command",
icon: "copy",
testID: `${menuTestIDBase}-copy-resume-command`,
onSelect: () => {
void onCopyResumeCommand(agentId);
@@ -118,8 +118,6 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "copy-agent-id",
label: "Copy agent id",
icon: "copy",
hint: agentId.slice(0, 7),
testID: `${menuTestIDBase}-copy-agent-id`,
onSelect: () => {
void onCopyAgentId(agentId);
@@ -135,7 +133,6 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close-before",
label: buildCloseBeforeLabel(surface),
icon: "arrow-left-to-line",
disabled: isFirstTab,
testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`,
onSelect: () => {
@@ -146,7 +143,6 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close-after",
label: buildCloseAfterLabel(surface),
icon: "arrow-right-to-line",
disabled: isLastTab,
testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`,
onSelect: () => {
@@ -157,7 +153,6 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close-others",
label: "Close other tabs",
icon: "copy-x",
disabled: isOnlyTab,
testID: `${menuTestIDBase}-close-others`,
onSelect: () => {
@@ -168,7 +163,6 @@ export function buildWorkspaceTabMenuEntries(
kind: "item",
key: "close",
label: "Close",
icon: "x",
testID: `${menuTestIDBase}-close`,
onSelect: () => {
void onCloseTab(tab.tabId);
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "./session-store";
function workspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
services: input.services ?? [],
};
}
afterEach(() => {
useSessionStore.getState().clearSession("test-server");
});
describe("normalizeWorkspaceDescriptor", () => {
it("normalizes workspace services and invalid activity timestamps", () => {
const services = [
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running" as const,
},
];
const workspace = normalizeWorkspaceDescriptor({
id: "/repo/main",
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "running",
activityAt: "not-a-date",
diffStat: null,
services,
});
expect(workspace.activityAt).toBeNull();
expect(workspace.services).toEqual([
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
},
]);
expect(workspace.services).not.toBe(services);
});
it("defaults missing services to an empty array", () => {
const payload = {
id: "/repo/main",
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
} as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.services).toEqual([]);
});
});
describe("mergeWorkspaces", () => {
it("preserves services on merged workspace entries", () => {
const store = useSessionStore.getState();
store.initializeSession("test-server", null as unknown as DaemonClient);
store.setWorkspaces(
"test-server",
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
);
store.mergeWorkspaces("test-server", [
workspace({
id: "/repo/main",
services: [
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
},
],
}),
]);
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
},
]);
});
});
+2
View File
@@ -119,6 +119,7 @@ export interface WorkspaceDescriptor {
status: WorkspaceDescriptorPayload["status"];
activityAt: Date | null;
diffStat: { additions: number; deletions: number } | null;
services: WorkspaceDescriptorPayload["services"];
}
export function normalizeWorkspaceDescriptor(
@@ -136,6 +137,7 @@ export function normalizeWorkspaceDescriptor(
status: payload.status,
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
diffStat: payload.diffStat ?? null,
services: (payload.services ?? []).map((s) => ({ ...s })),
};
}
@@ -0,0 +1,72 @@
import type { SessionOutboundMessage } from "@server/shared/messages";
import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
updatedAt: number;
}
interface WorkspaceSetupStoreState {
snapshots: Record<string, WorkspaceSetupSnapshot>;
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
clearServer: (serverId: string) => void;
}
function buildWorkspaceSetupKey(input: {
serverId: string;
workspaceId: string;
}): string | null {
return buildWorkspaceTabPersistenceKey(input);
}
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
snapshots: {},
upsertProgress: ({ serverId, payload }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
if (!key) {
return;
}
set((state) => ({
snapshots: {
...state.snapshots,
[key]: {
...payload,
updatedAt: Date.now(),
},
},
}));
},
removeWorkspace: ({ serverId, workspaceId }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
return;
}
set((state) => {
if (!(key in state.snapshots)) {
return state;
}
const next = { ...state.snapshots };
delete next[key];
return { snapshots: next };
});
},
clearServer: (serverId) => {
set((state) => {
const nextEntries = Object.entries(state.snapshots).filter(
([key]) => !key.startsWith(`${serverId}:`),
);
if (nextEntries.length === Object.keys(state.snapshots).length) {
return state;
}
return { snapshots: Object.fromEntries(nextEntries) };
});
},
}));
@@ -200,4 +200,19 @@ describe("workspace-tabs-store retargetTab", () => {
expect(reopenedFileTabId).toBe(fileTabId);
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId);
});
it("builds a deterministic setup tab keyed by workspace id", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "setup", workspaceId: WORKSPACE_ID },
});
expect(tabId).toBe(`setup_${WORKSPACE_ID}`);
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId);
});
});
@@ -6,7 +6,8 @@ export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
| { kind: "file"; path: string }
| { kind: "setup"; workspaceId: string };
export type WorkspaceTab = {
tabId: string;
@@ -60,6 +61,10 @@ function normalizeTabTarget(
const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
}
return null;
}
@@ -79,6 +84,9 @@ function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): b
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId;
}
return false;
}
@@ -92,6 +100,9 @@ function buildDeterministicTabId(target: WorkspaceTabTarget): string {
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
if (target.kind === "setup") {
return `setup_${target.workspaceId}`;
}
return `file_${target.path}`;
}
+22 -22
View File
@@ -175,21 +175,21 @@ const lightSemanticColors = {
} as const;
const darkSemanticColors = {
// Surfaces (layers) — subtle teal tint
surface0: "#181B1A", // App background
surface1: "#1E2120", // Subtle hover
surface2: "#272A29", // Elevated: badges, inputs, sheets
surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis
surfaceSidebar: "#141716", // Sidebar background (darker than main)
// Surfaces (layers)
surface0: "#18181c", // App background
surface1: "#1f1f23", // Subtle hover
surface2: "#27272a", // Elevated: badges, inputs, sheets
surface3: "#3f3f46", // Highest elevation
surface4: "#52525b", // Extra emphasis
surfaceSidebar: "#121216", // Sidebar background (darker than main)
// Text
foreground: "#fafafa",
foregroundMuted: "#A1A5A4",
foregroundMuted: "#a1a1aa",
// Borders
border: "#252B2A",
borderAccent: "#2F3534",
border: "#27272a",
borderAccent: "#34343a",
// Brand
accent: "#20744A",
@@ -203,28 +203,28 @@ const darkSemanticColors = {
successForeground: "#ffffff",
// Legacy aliases (for gradual migration)
background: "#181B1A",
popover: "#272A29",
background: "#18181c",
popover: "#27272a",
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: "#181B1A",
secondary: "#272A29",
primaryForeground: "#18181b",
secondary: "#27272a",
secondaryForeground: "#fafafa",
muted: "#272A29",
mutedForeground: "#A1A5A4",
accentBorder: "#2F3534",
input: "#272A29",
muted: "#27272a",
mutedForeground: "#a1a1aa",
accentBorder: "#34343a",
input: "#27272a",
ring: "#d4d4d8",
terminal: {
background: "#181B1A",
background: "#18181c",
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: "#181B1A",
cursorAccent: "#18181c",
selectionBackground: "rgba(255, 255, 255, 0.2)",
selectionForeground: "#fafafa",
black: "#141716",
black: "#121214",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
@@ -233,7 +233,7 @@ const darkSemanticColors = {
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#434645",
brightBlack: "#3f3f46",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",
+7 -7
View File
@@ -1,15 +1,15 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import {
getIsElectronRuntimeMac,
getIsElectronRuntime,
getIsDesktopMac,
getIsDesktop,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
DESKTOP_WINDOW_CONTROLS_WIDTH,
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import { usePanelStore } from "@/stores/panel-store";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
@@ -56,7 +56,7 @@ export function isInteractiveDesktopDragTarget(target: unknown): boolean {
export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false);
const lastPointerDownAt = useRef(0);
const isActive = Platform.OS === "web" && isElectronRuntime();
const isActive = Platform.OS === "web" && isDesktop();
useEffect(() => {
if (!isActive) return;
@@ -136,7 +136,7 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsElectronRuntime()) return;
if (Platform.OS !== "web" || !getIsDesktop()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -188,11 +188,11 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
}, []);
return useMemo((): RawWindowControlsPadding => {
if (!getIsElectronRuntime() || isFullscreen) {
if (!getIsDesktop() || isFullscreen) {
return { left: 0, right: 0, top: 0 };
}
if (getIsElectronRuntimeMac()) {
if (getIsDesktopMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
@@ -82,6 +82,10 @@ describe("workspace route parsing", () => {
kind: "file",
path: "src/index.ts",
});
expect(parseWorkspaceOpenIntent("setup:L3RtcC9yZXBv")).toEqual({
kind: "setup",
workspaceId: "/tmp/repo",
});
});
it("uses the plain workspace route when workspace context is provided", () => {
+9 -1
View File
@@ -99,7 +99,8 @@ export type WorkspaceOpenIntent =
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string }
| { kind: "draft"; draftId: string };
| { kind: "draft"; draftId: string }
| { kind: "setup"; workspaceId: string };
export function parseWorkspaceOpenIntent(
value: string | null | undefined,
@@ -136,6 +137,13 @@ export function parseWorkspaceOpenIntent(
}
return { kind: "file", path: decodedPath };
}
if (kind === "setup") {
const workspaceId = decodeWorkspaceIdFromPathSegment(payload);
if (!workspaceId) {
return null;
}
return { kind: "setup", workspaceId };
}
return null;
}
+2 -2
View File
@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut";
export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") {
return Platform.OS === "ios" ? "mac" : "non-mac";
}
if (getIsElectronRuntimeMac()) return "mac";
if (getIsDesktopMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? "";
@@ -18,6 +18,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
activityAt: null,
statusBucket: "done",
diffStat: null,
services: [],
hasRunningServices: false,
...overrides,
};
}
@@ -87,8 +89,8 @@ describe("buildSidebarProjectRowModel", () => {
});
});
it("flattens git projects with a single workspace and keeps the new worktree action", () => {
const flattenedWorkspace = workspace({
it("keeps single-workspace git projects as sections with a new worktree action", () => {
const workspaceEntry = workspace({
workspaceId: "/repo/main",
workspaceKind: "local_checkout",
});
@@ -96,16 +98,14 @@ describe("buildSidebarProjectRowModel", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: "git",
workspaces: [flattenedWorkspace],
workspaces: [workspaceEntry],
}),
collapsed: true,
});
expect(result).toEqual({
kind: "workspace_link",
workspace: flattenedWorkspace,
selected: false,
chevron: null,
kind: "project_section",
chevron: "expand",
trailingAction: "new_worktree",
});
});
@@ -131,10 +131,10 @@ describe("buildSidebarProjectRowModel", () => {
});
describe("isSidebarProjectFlattened", () => {
it("returns true for single-workspace projects regardless of kind", () => {
it("returns true only for single-workspace non-git projects", () => {
expect(
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
).toBe(true);
).toBe(false);
expect(
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
).toBe(true);
@@ -16,6 +16,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
activityAt: null,
statusBucket: "done",
diffStat: null,
services: [],
hasRunningServices: false,
};
}
@@ -76,7 +78,7 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
});
it("ignores collapsed state for flattened single-workspace projects", () => {
it("still hides collapsed single-workspace git projects because they are not flattened", () => {
const projects = [project("p1", [workspace("s1", "/repo/main")])];
const model = buildSidebarShortcutModel({
@@ -84,7 +86,7 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(["p1"]),
});
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
expect(model.visibleTargets).toEqual([]);
expect(model.shortcutTargets).toEqual([]);
});
});
@@ -96,6 +96,7 @@ describe("tool-call-display", () => {
index: 1,
command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
log: "",
status: "running",
exitCode: null,
},
@@ -19,6 +19,7 @@ function workspace(
status: input.status ?? "done",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
services: input.services ?? [],
};
}
@@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget(
const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
}
return null;
}
@@ -44,6 +48,9 @@ export function workspaceTabTargetsEqual(
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId;
}
return false;
}
@@ -57,6 +64,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
if (target.kind === "setup") {
return `setup_${target.workspaceId}`;
}
return `file_${target.path}`;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import type {
AudioEngine,
AudioEngineCallbacks,
@@ -289,7 +289,7 @@ export function createAudioEngine(
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isElectronRuntime();
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.40",
"version": "0.1.38",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/relay": "0.1.38",
"@getpaseo/server": "0.1.38",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.40",
"version": "0.1.38",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/cli": "0.1.38",
"@getpaseo/server": "0.1.38",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.40",
"version": "0.1.38",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.40",
"version": "0.1.38",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.40",
"version": "0.1.38",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.40",
"version": "0.1.38",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -63,8 +63,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/relay": "0.1.40",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/relay": "0.1.38",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -213,6 +213,82 @@ describe("DaemonClient", () => {
expect(client.getConnectionState().status).toBe("disposed");
});
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
client.subscribe((event) => {
events.push(event);
});
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
mock.triggerMessage(
wrapSessionMessage({
type: "workspace_setup_progress",
payload: {
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "phase-one\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
log: "phase-one\n",
status: "running",
exitCode: null,
},
],
},
error: null,
},
}),
);
expect(events).toContainEqual({
type: "workspace_setup_progress",
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
payload: {
workspaceId: "/tmp/project/.paseo/worktrees/feature-a",
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "phase-one\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
log: "phase-one\n",
status: "running",
exitCode: null,
},
],
},
error: null,
},
});
});
test("sends explicit shutdown_server_request via shutdownServer", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1927,59 +2003,6 @@ describe("DaemonClient", () => {
]);
});
test("sends close_items_request and resolves close_items_response", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.closeItems(
{
agentIds: ["agent-1"],
terminalIds: ["term-1"],
},
"req-close-items",
);
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "close_items_request",
agentIds: ["agent-1"],
terminalIds: ["term-1"],
requestId: "req-close-items",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "close_items_response",
payload: {
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-items",
},
}),
);
await expect(responsePromise).resolves.toEqual({
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-items",
});
});
test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", async () => {
vi.useFakeTimers();
try {
+28 -22
View File
@@ -36,6 +36,7 @@ import type {
ProjectIconResponse,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
ListCommandsResponse,
ListProviderModelsResponseMessage,
ListAvailableProvidersResponse,
@@ -43,7 +44,6 @@ import type {
CreateTerminalResponse,
SubscribeTerminalResponse,
TerminalState,
CloseItemsResponse,
KillTerminalResponse,
CaptureTerminalResponse,
TerminalInput,
@@ -124,6 +124,11 @@ export type DaemonEvent =
workspaceId: string;
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
}
| {
type: "workspace_setup_progress";
workspaceId: string;
payload: Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }>["payload"];
}
| {
type: "agent_stream";
agentId: string;
@@ -238,7 +243,6 @@ type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type CloseItemsPayload = CloseItemsResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
type ChatCreatePayload = Extract<
@@ -459,6 +463,7 @@ export type InspectScheduleOptions = {
};
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
export type FetchAgentResult = {
agent: AgentSnapshotPayload;
@@ -1315,6 +1320,21 @@ export class DaemonClient {
});
}
async fetchWorkspaceSetupStatus(
workspaceId: string,
requestId?: string,
): Promise<WorkspaceSetupStatusPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "workspace_setup_status_request",
workspaceId,
},
responseType: "workspace_setup_status_response",
timeout: 10000,
});
}
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
@@ -2831,26 +2851,6 @@ export class DaemonClient {
});
}
async closeItems(
input: { agentIds?: string[]; terminalIds?: string[] },
requestId?: string,
): Promise<CloseItemsPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "close_items_request",
agentIds: input.agentIds ?? [],
terminalIds: input.terminalIds ?? [],
requestId: resolvedRequestId,
});
return this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "close_items_response",
timeout: 10000,
options: { skipQueue: true },
});
}
async captureTerminal(
terminalId: string,
options?: { start?: number; end?: number; stripAnsi?: boolean },
@@ -3545,6 +3545,12 @@ export class DaemonClient {
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
payload: msg.payload,
};
case "workspace_setup_progress":
return {
type: "workspace_setup_progress",
workspaceId: msg.payload.workspaceId,
payload: msg.payload,
};
case "agent_stream":
return {
type: "agent_stream",
@@ -48,11 +48,14 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ServiceRouteStore } from "../service-proxy.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore;
getDaemonTcpPort?: () => number | null;
paseoHome?: string;
logger: Logger;
}
@@ -296,21 +299,25 @@ export async function createAgentManagementMcpServer(
};
let resolvedCwd = expandUserPath(cwd);
let worktreeConfig: WorktreeConfig | undefined;
let worktreeBootstrap:
| {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
| undefined;
if (worktreeName) {
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createAgentWorktree({
worktreeBootstrap = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
worktreeSlug: worktreeName,
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
}
const provider: AgentProvider = agentType ?? "claude";
@@ -322,10 +329,11 @@ export async function createAgentManagementMcpServer(
title: normalizedTitle ?? undefined,
});
if (worktreeConfig) {
if (worktreeBootstrap) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: options.terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
@@ -339,6 +347,8 @@ export async function createAgentManagementMcpServer(
agentId: snapshot.id,
item,
}),
serviceRouteStore: options.serviceRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger,
});
}
@@ -2263,50 +2263,6 @@ describe("AgentManager", () => {
expect(attentionReasons).toEqual(["error", "error"]);
});
test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000131",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Archive target",
});
const lifecycles: string[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === agent.id) {
lifecycles.push(event.agent.lifecycle);
}
},
{ agentId: agent.id, replayState: false },
);
const { archivedAt } = await manager.archiveAgent(agent.id);
const stored = await storage.get(agent.id);
expect(stored).toMatchObject({
id: agent.id,
archivedAt,
updatedAt: archivedAt,
lastStatus: "idle",
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]);
});
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
const storagePath = join(workdir, "agents");
@@ -883,13 +883,11 @@ export class AgentManager {
await this.registry.upsert({
...stored,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
this.notifyAgentState(agentId);
await this.closeAgent(agentId);
return { archivedAt };
@@ -178,6 +178,7 @@ export type ToolCallDetail =
index: number;
command: string;
cwd: string;
log: string;
status: "running" | "completed" | "failed";
exitCode: number | null;
durationMs?: number;
+11 -3
View File
@@ -28,11 +28,14 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ServiceRouteStore } from "../service-proxy.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore;
getDaemonTcpPort?: () => number | null;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
@@ -428,6 +431,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
let resolvedCwd: string;
let resolvedMode: string | undefined;
let worktreeConfig: WorktreeConfig | undefined;
let shouldBootstrapWorktree: boolean | undefined;
if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
@@ -464,15 +468,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
worktreeSlug: worktreeName,
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
worktreeConfig = worktreeBootstrap.worktree;
shouldBootstrapWorktree = worktreeBootstrap.shouldBootstrap;
}
resolvedMode = initialMode;
@@ -497,6 +502,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
shouldBootstrap: shouldBootstrapWorktree,
terminalManager: terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
@@ -510,6 +516,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
agentId: snapshot.id,
item,
}),
serviceRouteStore: options.serviceRouteStore,
daemonPort: options.getDaemonTcpPort?.() ?? null,
logger: childLogger,
});
}
@@ -1,55 +0,0 @@
import { describe, expect, test } from "vitest";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
describe("CodexAppServerAgentClient spawn error handling", () => {
const logger = createTestLogger();
test("listModels rejects gracefully when the codex binary does not exist", async () => {
const client = new CodexAppServerAgentClient(logger, {
command: {
mode: "replace",
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
},
});
const uncaughtErrors: unknown[] = [];
const onUncaught = (err: unknown) => {
uncaughtErrors.push(err);
};
process.on("uncaughtException", onUncaught);
try {
await expect(client.listModels()).rejects.toThrow();
// Drain microtask queue to ensure no deferred uncaught errors
await new Promise((resolve) => setTimeout(resolve, 100));
expect(uncaughtErrors).toHaveLength(0);
} finally {
process.off("uncaughtException", onUncaught);
}
});
test("listPersistedAgents rejects gracefully when the codex binary does not exist", async () => {
const client = new CodexAppServerAgentClient(logger, {
command: {
mode: "replace",
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
},
});
const uncaughtErrors: unknown[] = [];
const onUncaught = (err: unknown) => {
uncaughtErrors.push(err);
};
process.on("uncaughtException", onUncaught);
try {
await expect(client.listPersistedAgents()).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(uncaughtErrors).toHaveLength(0);
} finally {
process.off("uncaughtException", onUncaught);
}
});
});
@@ -511,18 +511,6 @@ class CodexAppServerClient {
}
});
child.on("error", (err) => {
this.logger.error({ err }, "Codex app-server child process error");
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(err);
}
this.pending.clear();
this.disposed = true;
this.resolveExitPromise?.();
this.resolveExitPromise = null;
});
child.on("exit", (code, signal) => {
const message =
code === 0 && !signal

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